4 min read

The Silent Leak: Promise.all() in Server-Side Props That Evades Heap Snapshots

A Next.js application grown by 200 MB overnight despite removing all visible data.
The Silent Leak: Promise.all() in Server-Side Props That Evades Heap Snapshots

The Silent Leak

A production dashboard began consuming 180 megabytes of memory over three days. By the time the team ran a heap snapshot, every tool pointed to active components and growing arrays — nothing resembling a clear culprit. The stack traces showed normal Promise.all resolutions, and the code review flagged no obvious retention patterns.

The root cause lived inside a custom hook that fetched dependencies for a table view. Two lookup queries were combined with Promise.all. Each query returned a large DTO containing aggregated row counts, computed metrics, and associated asset URLs. Those DTOs were stored in state, passed down to a tally component, and eventually rendered as a progress indicator. At first glance, the pattern seemed textbook: fetch parallel data, aggregate results, display summary. No one expected this kind of accumulation to grow beyond the initial dataset size.

The leak was not in the network calls themselves. The leaks were in how the resulting DTOs were consumed and retained across re-renders.

The Surface View

In useDashboardData, two asynchronous lookups happen concurrently:

const [primary, secondary] = await Promise.all([
  fetchPrimaryMetrics(),
  fetchSecondaryMetrics(),
]);

// Both datasets are merged into one payload
return { primary, secondary };

The returning object is placed in state.measurements via useReducer. Every time primary or secondary updates, the reducer runs and updates the entire measurements array. The tally component subscribes to this state slice and increments its own counter. Because measurements holds references to both cached DTOs, each merge operation prevents garbage collection of the underlying object trees.

From a developer perspective, the code looks sound. The Promise.all is balanced with error handling, the state is managed correctly, and the UI responds instantly. The only thing that changes over time is the size of the measured data.

Digging Deeper

To understand why the leak persists, we must trace the lifecycle of each DTO after it enters measurements. Consider what happens on the third data refresh cycle:

  1. fetchPrimaryMetrics completes and the resulting primary DTO is added to the cache.
  2. fetchSecondaryMetrics completes and secondary is added.
  3. The reducer recomposes measurements from these two DTOs, but critically, the old entries in previousMeasurements also remain until the old state is replaced entirely.
  4. Because primary and secondary are each large objects (tens of kilobytes per metric set), the cache never shrinks. It merely appends new slices of data while keeping the previous ones reachable through internal memoization.

What makes this particularly insidious is that the leaking objects are not just primitive values. They contain nested objects, relationships, and sometimes file-path strings that act as retention anchors. When the components that display these DTOs are unmounted, the cleanup code often fails to reach past the DTO — instead of calling .pop() or nullifying the reference, the framework preserves the entire subtree.

Standard heap snapshots capture the live object graph. The problem is that this graph extends far beyond what a developer would typically inspect. The main thread shows modest growth early on, then accelerates as the number of completed lookups increases. A typical profiling session reveals the top consumers, but the largest consumers are the components themselves rather than the pending promises — meaning the actual leak lives in the component tree, not in the async machinery.

The Fix

Three changes address the core issue simultaneously:

1. Truncate the measurement history. Instead of accumulating every refresh, limit the stored data to a fixed window (e.g., last 50 refreshes) and prune older entries explicitly.

2. Decouple computation from state. Move the merging logic out of the component and into a pure middleware function that produces a stable snapshot. This breaks the direct dependency between state mutations and the component lifecycle.

3. Use a weak map for caching. Replace the regular object map with a WeakMap<Id, DTO> so that when a particular measurement ID is no longer referenced anywhere else, the garbage collector can reclaim the entire DTO subtree.

Here is a cleaned-up version of the hook that avoids the leak:

import { useReducer, useEffect } from "react";
import { cache, getOrCreateCache } from "/lib/cache";

interface Measurement {
  id: string;
  primary: PrimaryMetric[];
  secondary: SecondaryMetric[];
}

const measurements = useReducer(
  (state, action) => {
    const { id, primary, secondary } = action.payload;
    // Only store up to N distinct keys; drop older ones
    if (!getOrCreateCache().allowTruncation) return state;
    const newCount = getOrCreateCache().current.length + 1;
    if (newCount > 50) {
      // Remove oldest entry to stay within bounds
      removeOldest(getOrCreateCache().current);
    }
    // Merge rather than append raw arrays — creates stable references
    return {
      ...state,
      measurements: {
        ...state.measurements,
        [id]: { primary, secondary },
      },
    };
  },
  { measurements: {} },
);

const measure = async () => {
  const [primary, secondary] = await Promise.all([
    fetchPrimaryMetrics(),
    fetchSecondaryMetrics(),
  ]);
  // Pure function — no side effects, easy to test, no retained references
  return { primary, secondary };
};

The removeOldest helper operates on the backing object, ensuring that when a DTO becomes unreferenced (because its id is outside the sliding window), the cache discards its entire subtree. With this change, the heap stops growing proportionally with refresh cycles.

Guardrails

This pattern does not fix every memory leak scenario. Several conditions determine whether the approach is applicable:

  • If the component is truly ephemeral, consider removing the Promise.all altogether. Pre-fetching everything at build time eliminates runtime retention concerns.
  • If the data genuinely needs to persist, implement explicit cleanup. The WeakMap strategy works well for finite-document-type data (users, orders). For infinite streams, prefer streaming architectures that never buffer the full history.
  • If the app relies on optimistic updates, the measurement state may need to survive unmount. In that case, ensure the storage path has a strong identity anchor — the id field — so that cleanup routines can reliably target stale entries.
  • When working with external libraries that mutate objects in place, be aware that even immutable-seeming APIs can leave hidden references if the library internally maintains weak links. Profile with a dedicated GC pause to confirm which objects are being held.

Memory leaks from Promise.all specifically tend to hide because the promises themselves resolve cleanly. The dead weight resides downstream in whatever processing attaches to each resolution. Treating Promise.all as a black box — assuming its results will vanish once consumed — underestimates how persistent the attached data structures can become. The real work begins when you audit the consumer side of your async pipeline, not the factory that builds the results.

A Takeaway for Production

When you notice a steady upward trend in node RSS despite normal CPU utilization, check the component hierarchy first. Often the culprit is an async boundary that collects state faster than the UI can release it. A Promise.all in a render prop, a server component fallback, or a swagger-generated response handler can silently accumulate references long enough to exhaust memory.

The fix usually involves two steps: limiting the retention window and breaking tight coupling between state updates and component rendering. After applying the changes above, monitor the heap again. The growth should plateau or decline, and the slider bars in your monitoring dashboards should stop climbing.