There is a particular flavor of memory bug where the runtime is honest, the kernel is honest, and they disagree by an order of magnitude.
I was running a long-lived service on Bun, a relatively new JavaScript runtime in the same family as Node. The service handles workloads where data arrives in pieces over time — think of how a chat reply types itself out word by word, or how a video stream buffers chunk by chunk. Each piece flows through a few stages of processing before reaching the consumer. Under sustained traffic, the container running this service ran out of memory and got killed by the kernel within minutes of starting up, reliably.
The first thing I checked was the runtime’s own memory accounting. In Node and Bun, you call process.memoryUsage() and get back a small object describing how much memory the runtime believes is in use. The relevant numbers looked like this:
{
rss: 1_452_900_352, // 1.4 GB total resident memory
heapTotal: 268_435_456, // 256 MB reserved for the heap
heapUsed: 232_417_984, // 232 MB live JS objects
external: 129_503_232, // 129 MB native buffers the runtime tracks
arrayBuffers: 51_842_048 // 51 MB ArrayBuffer payloads
}
The heap is the big shared pool where every JavaScript object lives. The runtime managed it directly: 232 MB of live objects out of 256 MB reserved. The other tracked categories — native buffers, ArrayBuffer payloads — together added another 180 MB. So in total, the runtime believed it was using about 412 MB.
Meanwhile, the operating system thought the process was using 1.4 GB and climbing. That number is rss, short for resident set size — the kernel’s count of how much physical memory the process actually has mapped in. The container was on its way to dying when rss reached 6 GB. Of that 6 GB, only 412 MB would be accounted for by the runtime. Roughly 5.5 GB was somewhere the runtime had no record of.
That gap is the whole post.
What the runtime saw
Heap snapshots — point-in-time dumps of every JavaScript object the runtime is tracking — taken between samples looked clean. Live object counts steady. No retained tree growing without bound. The garbage collector was running on schedule. From the runtime’s perspective, this was a healthy program.
What the kernel saw
I dropped to the operating system level and started reading /proc/<pid>/smaps_rollup, a file Linux exposes that summarises every memory region a process has mapped:
Anonymous: 1_239_440 kB
Anonymous: 1_287_896 kB
Anonymous: 1_341_220 kB
The growth was in anonymous mappings — pages of memory the kernel had handed the process directly, not backed by any file on disk. That observation eliminates several categories. It is not the program’s compiled code (mapped from the binary). It is not data loaded from files (mapped from those files). It is memory the runtime explicitly asked the kernel for, then never gave back.
Here is the part that matters: every JavaScript runtime — Node, Bun, V8, JavaScriptCore — is itself a C++ program. The C++ side runs the JIT compiler, manages the heap, parses your code, and crucially can also allocate memory directly from the kernel for its own purposes. That C++-side memory does not get reported in the JavaScript-side counters. It shows up only in the kernel’s view.
The runtime saw 412 MB. The kernel saw 1.4 GB. The difference was the runtime’s C++ underbelly, allocating something it never told the JS side about.
The wrong paths
I had a candidate before I started looking. One of the open-source libraries in the stack accumulates every streamed chunk into an internal buffer, intending the buffer for late-joining consumers who want to replay the stream from the start. The buffer is never cleared. I had patched this weeks earlier and verified the patch saved roughly 100 MB per long task in heap snapshots. Reasonable suspect.
I rebuilt without the patch, ran the workload, took snapshots. Then with the patch. Then with the patch again to be sure. The leak was the same in all three runs. The patch was doing real work — heap diffs confirmed the savings — but it was not the dominant cost. Something an order of magnitude bigger was running on top of it.
A real fix for a real leak does not become the answer to a different, bigger leak just because you want it to.
My second suspect was subprocess output buffering. The service shells out to subprocesses for some kinds of work, and long subprocesses produce a lot of standard-out text; I was sure something in the buffering chain between subprocess and consumer was retaining those bytes. I added per-second rss sampling at the boundaries of every subprocess call and watched the timeline. The spikes did not align with subprocess execution. They aligned with the streaming response stage — the part that moves chunks out of the upstream and through the merge pipeline. Wrong direction, but it told me where to look.
Both wrong paths shared a property: the runtime’s heap accounting said everything was fine. I kept hunting on the heap side anyway, because the heap side was the side I knew how to read.
What it actually was
I went back to the heap snapshots and started comparing them more carefully — counts of every object type, run over run. One thing stood out. Each run had +442 more WritableStream and WritableStreamDefaultController objects than the run before. Hundreds of them, then thousands, accumulating without bound.
These are objects from the standard Web Streams API — the modern JavaScript interface for handling data that arrives in pieces. They are tiny on the JavaScript side. A few kilobytes each. But here is the thing: each one is really just a handle, a small JS object that points to a much larger data structure the runtime keeps internally on its C++ side. When you finish using a WritableStream and the JS handle becomes garbage, the C++ side is supposed to release the underlying structure too.
On Bun, it wasn’t.
The objects came from a specific code shape that several libraries in the JavaScript ecosystem use to combine streams. Vercel’s AI SDK is the most prominent example; a handful of frameworks downstream have copied the same shape verbatim. It looks like this:
const merged = new ReadableStream({
async start(controller) {
await source.pipeThrough(transform).pipeTo(
new WritableStream({
write(chunk) { controller.enqueue(chunk); },
close() { controller.close(); },
}),
);
},
});
The pattern reads cleanly. Take a source stream, push it through a transform, and pipe the result into a fresh WritableStream whose only job is to forward chunks back to the outer controller. Standard Web Streams. Looks fine.
But on Bun, that pipeTo(new WritableStream(...)) call allocates a small bookkeeping object on the JavaScript side and a much larger native structure underneath it. When the streams close and the JS object gets garbage-collected, the native structure stays. Run this pattern thousands of times — and a long streamed response can flow through it thousands of times — and you have quietly leaked gigabytes that no JavaScript-side tool can see.
The same shape lived in 24 nested copies across node_modules — @mastra/core, @mastra/memory, Vercel’s AI SDK, and a couple of browser-automation packages all forked from it.
The arithmetic checked out. Each leaked allocation retains roughly 3 KB of native memory per chunk that flows through it. A long streamed response carries thousands of chunks, and the pipeline puts each chunk through several stages of this pattern. Conservatively: 5 stages × 3 KB × 1000 chunks per second = 15 MB per second of growth. A few minutes of that comfortably overshoots a multi-gigabyte container limit. The math matched what the dashboard was showing, which was the depressing kind of confirmation.
I built a minimal reproduction and filed it as Bun issue #30090.
The fix
The workaround is to stop using the leaky API entirely and read from the source manually:
const merged = new ReadableStream({
async start(controller) {
const reader = source.pipeThrough(transform).getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) { controller.close(); break; }
controller.enqueue(value);
}
} finally {
try { reader.releaseLock(); } catch {}
}
},
});
Same observable behaviour. No WritableStream ever gets created. The cost is readability — the manual pump is harder to scan than a single pipeTo call, and I would not write code in this shape if I had a choice.
I bundled the rewrites into a small post-install script that walks node_modules, finds every site matching the leaky shape, and patches them in place. 24 sites in total. The script is idempotent, runs on bun install, and survives lockfile updates. Same workload, same hardware: the container survives, memory plateaus comfortably under the limit, and the workload that used to die in minutes runs through to completion.
What I’ll trust earlier next time
The runtime’s heap counter and the kernel’s rss reading disagreed by an order of magnitude for the entire investigation. That gap was the answer in plain sight on the first dashboard I opened. I spent two days not trusting it because the heap-side work was productive — every fix on that side genuinely saved memory. But “saving memory” and “the cause of this OOM” are different questions, and the fact that the kernel kept disagreeing should have settled which one I was answering.
When the runtime swears everything is fine and the kernel is killing the container, the runtime is telling you, indirectly, where to look.
The heap can be perfectly clean and the process can still be doomed.