Unsettled Promises That Leak Their Closures
Heap snapshots show thousands of Promise objects in the pending state, each retaining a reaction callback that captured a component, a request payload or a DOM subtree — this guide from Closure Memory Leaks in Modern JavaScript, in Browser DevTools & Performance Profiling Workflows, explains why a promise that never settles can become a leak and how to make every await bounded.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Growing count of Promise in snapshots |
Promises created per request/interaction never settle | Filter Summary by Promise and diff across repetitions |
Confirms pending-promise accumulation per action |
Unmounted components retained via PromiseReaction |
.then/await continuation captures component state |
Cancel with AbortController on unmount |
Component subtree becomes collectable |
| Map of pending requests keeps growing | Resolvers stored by ID but responses never arrive | Add a timeout that rejects and deletes the entry | Map size stays bounded under packet loss |
| Memory grows only when a backend is slow or down | Awaits wait forever on hung connections | Wrap network awaits with timeouts | Growth stops during outages |
Promise.race used for timeouts still leaks |
The losing promise and its reactions stay alive | Cancel the loser, not just ignore it | Removes retained work behind each timeout |
Root Cause: Pending Promises Keep Their Reactions
A promise holds a list of reactions — the callbacks registered with .then, .catch, .finally, or implicitly by await. When it settles, V8 schedules those reactions as microtasks and clears the list. Until then, the promise object keeps every reaction alive, and every reaction keeps its closure’s context alive: the component that awaited, the variables of the async function (an async function’s suspended frame is itself heap-allocated), and anything those reference.
A pending promise is not automatically a leak. If nothing references it, the promise and its reactions are unreachable and are collected together — a promise that will never resolve and that nobody can resolve is simply garbage. The leak happens when the promise remains reachable through something long-lived while it waits. The usual long-lived holders are: a map of in-flight requests keyed by message ID, waiting for a response that never arrives; a resolver function stored on a module-level queue or event emitter; a subscription or observable wrapper that keeps its internal promise; a long-poll loop that re-awaits forever; or a native operation — an unconsumed fetch body, a WebSocket that never closes, an IntersectionObserver callback that resolves a promise only if an element becomes visible.
In each case, the holder keeps the promise, the promise keeps the reaction, the reaction keeps the async function’s frame and captured variables, and those keep whatever your component or request owned. Because the chain passes through engine-internal objects, it looks like this in a snapshot’s Retainers pane: your component → context → (closure) → PromiseReaction → Promise → table in Map → pendingRequests. Reading such paths is covered in reading the Retainers panel.
Step-by-Step Fix
- Diff promises across repetitions. Take a snapshot, repeat the suspect flow ten times, take another, and open Comparison in DevTools → Memory. Filter by
Promise. Verification:# DeltaforPromise(and oftenPromiseReactionor(closure)) is a multiple of your repetition count. - Inspect one pending promise. Expand the
Promisegroup and select a new instance. In the bottom pane, expand its internal slots. Verification: its state is pending, and its reactions list is non-empty. - Follow the promise’s retainers. Read the Retainers pane upwards from the promise. Verification: you reach a long-lived holder — a
Mapof pending requests, a queue array, an emitter’s listener list, or a native object such as a socket. - Follow the reactions downwards. Open the reaction’s closure and its context in the Containment view. Verification: you can see which component, request or DOM the continuation captured.
- Bound the wait. Add a timeout that rejects and removes the holder’s entry, and pass an
AbortSignalso the operation can be cancelled when its owner goes away. Verification: in code, every await on external input has either a timeout or an abort path, and the holder deletes the entry in both success and failure branches. - Re-run and re-diff. Repeat the flow ten times with the backend delayed or offline to exercise the failure path. Verification:
Promisedelta is near zero and the holder’s size returns to its baseline after the timeout.
Command and Code Reference
Use case: a request/response channel over postMessage or WebSocket. Resolvers are stored by ID; if a response never arrives, the entry, the promise and every awaiting continuation leak.
// Leaky: entries are deleted only when a response arrives
const pending = new Map();
let nextId = 0;
function call(method, params) {
const id = nextId++;
return new Promise((resolve) => {
pending.set(id, resolve); // held until a reply — maybe forever
socket.send(JSON.stringify({ id, method, params }));
});
}
socket.onmessage = (e) => {
const { id, result } = JSON.parse(e.data);
pending.get(id)?.(result);
pending.delete(id);
};
// Fixed: timeout rejects and deletes; AbortSignal lets callers cancel early
function callBounded(method, params, { timeoutMs = 10_000, signal } = {}) {
const id = nextId++;
return new Promise((resolve, reject) => {
const cleanup = () => { clearTimeout(timer); pending.delete(id); };
const timer = setTimeout(() => { cleanup(); reject(new Error(`${method} timed out`)); }, timeoutMs);
signal?.addEventListener('abort', () => { cleanup(); reject(signal.reason); }, { once: true });
pending.set(id, (result) => { cleanup(); resolve(result); });
socket.send(JSON.stringify({ id, method, params }));
});
}
Use case: cancel an await when a component unmounts. Passing the signal through to fetch both aborts the network request and settles the promise, releasing the continuation.
// Framework-agnostic component lifecycle
function mountProfile(el, userId) {
const controller = new AbortController();
(async () => {
try {
const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
const user = await res.json();
el.textContent = user.name; // continuation captures `el`
} catch (err) {
if (err.name !== 'AbortError') throw err; // aborted on unmount: ignore
}
})();
return () => controller.abort(); // unmount: settle the pending await
}
Verification and Regression Prevention
Verify the fix under the failure condition, not only the happy path. Throttle the network to Offline in DevTools → Network, or point the app at a stub server that never replies, then repeat the flow and diff snapshots. The count of Promise objects and the size of any pending-request map should plateau at roughly the number of requests issued within one timeout window, then return to zero after the timeout — the plateau shape in the chart above.
To keep it that way, expose the size of every pending-work structure (pending.size, queue lengths) to your debug telemetry and alert when it exceeds a sane ceiling. In code review, treat an await on something external without a timeout or abort path as a defect, the same way you would treat an addEventListener without a matching removal — the patterns in event listener leaks and AbortController cleanup apply equally to promises.
Edge Cases and Gotchas
Async iterators that are never finished
A for await loop over a stream or an async generator suspends on each next() call. If the consumer breaks out of a loop, the runtime calls the iterator’s return() method; if the consumer is simply abandoned mid-await — the component unmounted while waiting — the generator stays suspended, holding its frame. Give long-running async iterators an abort signal and check it on every iteration.
Unhandled rejections are not a leak, but swallowed ones can hide one
A promise that rejects with no handler is reported and then collected. The problematic pattern is a .catch(() => {}) that silences a rejection and leaves a pending entry behind in a map or queue. Make cleanup unconditional with finally, so both success and failure paths delete the bookkeeping entry.
Microtask chains that never end
A recursive async loop — async function poll() { await tick(); return poll(); } — creates a new promise on every iteration, each resolved by the next. Modern engines collect the finished links, but if any link is stored (for example the outermost promise is kept by a caller that awaits it), the chain can grow. Write polling loops as while loops with an explicit stop condition instead of recursion.
Third-party SDKs with internal request maps
Analytics, chat and payment SDKs often keep their own pending-request maps. If your app calls them during a flow and they wait for a response that never comes — blocked by an ad blocker, for instance — their entries accumulate. Wrap such calls in your own timeouts and avoid calling them repeatedly when they are failing.
Frequently Asked Questions
Does a promise that never resolves always leak?
No. If nothing references the promise, it is collected along with its reactions, even though it never settled. It only leaks when something long-lived — a map, a queue, an emitter, a native resource — keeps a reference to the promise or its resolver while it waits.
Does Promise.race with a timeout fix the leak?
Not by itself. Promise.race settles the race promise, but the losing promise is still pending and still referenced by whatever created it, with its reactions attached. Actually cancel the underlying operation — abort the fetch, delete the pending entry — so the loser settles or becomes unreachable.
How do I see pending promises in DevTools?
Heap snapshots include Promise objects; expanding one shows its internal state and reactions. The Console’s queryObjects(Promise) lists all promises in the current context, which is a quick way to count them before and after a flow while debugging.
Related
- Closure Memory Leaks in Modern JavaScript — the parent topic
- Event Listener Leaks and AbortController Cleanup — the same cancellation pattern for listeners
- How Async Functions and Generators Keep Frames on the Heap — why a suspended await retains its locals
- Browser DevTools & Performance Profiling Workflows — the section overview