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)PromiseReactionPromisetable in MappendingRequests. Reading such paths is covered in reading the Retainers panel.

How a pending promise retains a component A module-level pendingRequests map holds a resolver and its pending promise. The promise holds a PromiseReaction, whose closure is the continuation of an async loadProfile function. That suspended frame captured the component instance, which holds the component's DOM subtree. None of it can be collected until the promise settles or the map entry is deleted. pendingRequests module Map Promise state: pending PromiseReaction await continuation async frame loadProfile() Component unmounted DOM subtree detached break the chain at either end: settle or delete the map entry (timeout), or abort the await when the component unmounts

Step-by-Step Fix

  1. 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: # Delta for Promise (and often PromiseReaction or (closure)) is a multiple of your repetition count.
  2. Inspect one pending promise. Expand the Promise group 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.
  3. Follow the promise’s retainers. Read the Retainers pane upwards from the promise. Verification: you reach a long-lived holder — a Map of pending requests, a queue array, an emitter’s listener list, or a native object such as a socket.
  4. 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.
  5. Bound the wait. Add a timeout that rejects and removes the holder’s entry, and pass an AbortSignal so 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.
  6. Re-run and re-diff. Repeat the flow ten times with the backend delayed or offline to exercise the failure path. Verification: Promise delta is near zero and the holder’s size returns to its baseline after the timeout.
Pending entries during a ten-minute outage Without timeouts, the pendingRequests map grows linearly from 0 to about 6,000 entries over a ten minute backend outage, each entry retaining its component. With a ten second timeout that rejects and deletes entries, the map plateaus at about 100 entries, the number of requests issued in any ten second window. 6,000 0 minutes into the outage (0 → 10) no timeout: grows forever 10 s timeout: plateaus ~100

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.

Pending work under an offline network With DevTools Network throttled to Offline, a flow without timeouts accumulates pending Promise objects with every request. After the fix, pending work plateaus at roughly the number of requests issued within one timeout window, then drops back toward zero after the timeout. pending requests issued while offline no timeout: every request stays pending with timeout + abort: bounded by one window Expose pending-map sizes in development builds so a plateau is easy to watch.

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.