How Async Functions and Generators Keep Frames on the Heap

Memory stays high while an import job is “just waiting” on network calls, and heap snapshots show JSAsyncFunctionObject or generator objects retaining large arrays that the code finished using long ago. This guide from Stack vs Heap Memory Allocation in JavaScript, in JavaScript Memory Fundamentals & Runtime Mechanics, explains why suspended functions keep their locals alive and how to structure async code so waiting does not mean retaining.

Symptom Root Cause Immediate Action Measurable Impact
Heap stays high during long await chains Suspended frame keeps every live local in a heap object Null out or scope large locals before the next await Large buffers released while waiting
Many pending async functions retain request data Each in-flight call holds its own suspended frame Bound concurrency; process and drop payloads early Heap proportional to concurrency, not backlog
Generator objects retained after iteration stopped Unfinished generator keeps its frame and locals Call return() or finish iteration; avoid storing iterators Frames released when iteration ends
for await over a stream holds each chunk too long Chunk variables alive across the next await Process and drop the chunk before awaiting the next Peak memory ≈ one chunk
Snapshot shows (async function) retainer paths Promise reactions keep suspended frames reachable Cancel abandoned operations with AbortSignal Abandoned frames become collectable

Root Cause: Suspension Moves the Frame to the Heap

An ordinary function’s frame exists only while the function runs; when it returns, the frame is gone. An async function or a generator can suspend — at an await or a yield — and resume later, after other code has run and the stack has unwound. Its local state must survive the suspension, so V8 cannot keep it on the machine stack.

When such a function suspends, V8 copies its live interpreter registers — the locals and temporaries it still needs — into a heap-allocated array owned by the function’s generator object (JSGeneratorObject, or JSAsyncFunctionObject for async functions). On resumption, it copies them back. While suspended, that object is the frame: it keeps every value in it reachable. For an async function, the object is referenced by the reaction registered on the awaited promise, so as long as the promise is pending and reachable, the suspended frame and all its locals are too — the chain described in unsettled promises that leak their closures. For a generator, the generator object itself holds the frame for as long as anyone holds the iterator.

This has a practical consequence that surprises many developers: a large value assigned to a local variable earlier in an async function is still alive at every later await if the interpreter considers it live, even if the code never reads it again. V8’s liveness analysis can drop registers that are provably dead, but values held in variables that are read later — or whose liveness cannot be proved — are kept. A function that parses a 50 MB response, extracts ten fields, and then awaits five more network calls may hold the parsed object for the whole duration. Multiply by the number of concurrent invocations and the heap grows with the backlog. Unlike the closure context objects created at call time, these suspended frames exist only while suspended — but in I/O-bound code, that is most of the time.

What a suspended async frame keeps alive Top row: importBatch parses a 50 megabyte payload into data, extracts ids, then awaits three network calls. Because data is still referenced later, it is kept in the suspended frame during all three awaits. Bottom row: the fixed version extracts ids in a helper and never keeps data in a variable that is live across awaits, so the payload is released before the first await. Leaky: data (50 MB) lives in the suspended frame across every await parse → data await fetch #1 — data held await fetch #2 — data held await fetch #3 — data held Fixed: only ids (a few KB) survive into the suspended frame parse → ids only await fetch #1 — ids await fetch #2 — ids await fetch #3 — ids with 40 concurrent imports: ~2 GB retained vs a few MB the suspended frame is a heap object referenced by the awaited promise

Step-by-Step Fix

  1. Confirm suspended frames are retaining data. Take a heap snapshot while the workload is mid-flight and sort by Retained Size. Verification: large arrays or objects have retainer paths through objects such as JSAsyncFunctionObject, (async function) or a generator, and then a PromiseReaction.
  2. Identify the function and the retained variable. Follow the path to the async function or generator and inspect which local holds the large value. Verification: you can point to a variable assigned before an await that is not needed afterwards, or only partly needed.
  3. Extract what you need before awaiting. Move the parsing and extraction into a synchronous helper that returns only the small result, so the large value never lives in a variable of the async function. Verification: after the change, no local that holds the large value is live across an await.
  4. Bound concurrency. Process work with a fixed-size pool (for example 4–8 concurrent operations) rather than starting every task at once. Verification: the number of suspended frames in a snapshot is at most the pool size.
  5. Finish or close iterators you abandon. Break out of for...of/for await...of loops (which calls return() automatically) instead of discarding a half-consumed iterator, and call iterator.return() explicitly when you stop using one manually. Verification: no generator objects remain in snapshots after consumers stop.
  6. Re-measure under load. Run the same workload. Verification: heap peak scales with concurrency × small state, not with backlog × payload size.
Peak heap for a 400-file import Starting all 400 imports at once with the parsed payload held across awaits peaks at 3.1 gigabytes and crashes. Limiting concurrency to 8 while still holding the payload peaks at 460 megabytes. Limiting concurrency to 8 and extracting ids before awaiting peaks at 95 megabytes. Peak heap, 400 files of ~8 MB each All at once, payload held 3.1 GB → OOM Pool of 8, payload held 460 MB Pool of 8, ids only 95 MB

Command and Code Reference

Use case: stop an async function from holding a large payload across awaits.

// Leaky: `data` is live across three awaits because it is read at the end
async function importBatch(url) {
  const data = await (await fetch(url)).json();   // ~50 MB parsed object
  const ids = data.items.map((i) => i.id);
  await saveIds(ids);                             // data kept in the suspended frame
  await notify(ids.length);                       // ...and here
  await audit(url, data.meta.source);             // read here → data stays live
}

// Fixed: extract everything needed synchronously, keep only small values
function summarise(data) {
  return { ids: data.items.map((i) => i.id), source: data.meta.source };
}
async function importBatchFixed(url) {
  const { ids, source } = summarise(await (await fetch(url)).json()); // data never named
  await saveIds(ids);
  await notify(ids.length);
  await audit(url, source);
}

Use case: bounded concurrency so the number of suspended frames stays small.

// Run tasks with at most `limit` in flight; each suspended frame is small
async function runPool(items, limit, worker) {
  const results = [];
  let next = 0;
  async function lane() {
    while (next < items.length) {
      const i = next++;                     // claim the next item
      results[i] = await worker(items[i]);  // one suspended frame per lane
    }
  }
  await Promise.all(Array.from({ length: limit }, lane));
  return results;
}

await runPool(fileUrls, 8, importBatchFixed);

Use case: release a generator you stop consuming.

function* readRecords(buffer) {
  const view = new DataView(buffer);       // held in the generator's frame
  for (let off = 0; off < buffer.byteLength; off += 64) yield parse(view, off);
}

const it = readRecords(bigBuffer);
const first = it.next().value;
it.return();                               // finishes the generator: frame and buffer released

Verification and Regression Prevention

Verify under realistic concurrency: run the workload with production-like input sizes and take a snapshot mid-flight. The number of suspended async frames should match your concurrency limit, and none of them should retain the large payloads. The peak heap should scale with the pool size rather than with the number of queued tasks; doubling the backlog should not change the peak.

For prevention, adopt two conventions: parse-and-extract helpers are synchronous and return small results, and any fan-out over collections uses a bounded pool rather than Promise.all(items.map(...)). Lint rules that flag Promise.all over unbounded arrays, plus a load test that asserts peak heap under a large backlog, catch most regressions. On servers, the same pattern prevents the heap exhaustion described in SSR heap exhaustion and per-request memory.

Suspended frames under load Under production-like input, starting every task at once creates one suspended async frame per queued item, each retaining its locals, so the heap scales with queue length. With a concurrency pool the number of suspended frames stays at the pool size and the peak heap scales with the pool. frames items queued Promise.all over everything pool of N concurrent tasks

Edge Cases and Gotchas

Setting a variable to null works, but is fragile

Assigning data = null before the next await releases the value, and is a quick fix. It is easy to break in later edits, though; moving the large value into a synchronous helper makes the release structural.

Destructuring still holds the source during the expression

const { items } = await getBig() keeps only items afterwards, but items itself may be most of the payload. Extract the small fields you need, not the large sub-structures.

Async iterators over streams

for await (const chunk of stream) keeps the current chunk live until the next iteration begins. Do heavy per-chunk work and drop references before awaiting other I/O inside the loop body, or peak memory becomes two or more chunks per consumer.

Top-level await in modules

A module suspended at a top-level await keeps its module-level variables alive, as it always would, and also blocks importers. Keep large top-level initialisation out of modules that await slow resources.

Frequently Asked Questions

Do async functions use more memory than callbacks?

Not inherently. A callback-based function captures the variables its callbacks use in a closure context; an async function keeps its live locals in a suspended frame object. The memory cost depends on what you keep live while waiting, not on the syntax.

Does V8 drop locals that are no longer used?

Its bytecode liveness analysis avoids saving registers that are provably dead at a suspension point. Values that are read later, or whose liveness cannot be proved, are saved and retained. Do not rely on the analysis for memory safety; structure code so large values are not live across awaits.

What keeps a suspended async function alive?

The promise it is awaiting, through the reaction registered on it. If that promise is reachable — referenced by a pending request map, a timer, a stream — the suspended frame is too. If nothing references the promise, the frame is collectable even though it never resumed.

Are generators different from async functions here?

The mechanism is the same: the generator object stores the suspended frame. The difference is ownership — a generator’s frame lives as long as the iterator object is referenced, so storing half-consumed iterators in long-lived structures retains their frames.