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.
Step-by-Step Fix
- 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 aPromiseReaction. - 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
awaitthat is not needed afterwards, or only partly needed. - 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. - 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.
- Finish or close iterators you abandon. Break out of
for...of/for await...ofloops (which callsreturn()automatically) instead of discarding a half-consumed iterator, and calliterator.return()explicitly when you stop using one manually. Verification: no generator objects remain in snapshots after consumers stop. - Re-measure under load. Run the same workload. Verification: heap peak scales with concurrency × small state, not with backlog × payload size.
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.
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.
Related
- Stack vs Heap Memory Allocation in JavaScript — the parent topic
- Where Closure Variables Live: V8 Context Objects — the other heap home for locals
- Async Iterators and Stream Backpressure — suspended frames in streaming code
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview