Tracking real-world memory leaks with measureUserAgentSpecificMemory()

Browser-managed memory is an abstraction that hides the cost of every object your page creates. The browser allocates chunks of memory as objects are instantiated and relies on garbage collection to reclaim them once they are unreachable. Because reachability is only an approximation of necessity — a fact rooted in the Halting Problem — the browser will keep memory for objects that can still be reached, even if they will never be used again.

Consider a callback that holds a reference to a large, obsolete array:

// Example of an accidental memory leak
let object = { b: new Array(1000000) };
function callback() {
  // object.b is still reachable here, so its memory is never freed
}

Here, the browser won't reclaim the large array because it remains reachable through object.b. Such leaks are easy to introduce: forgetting to unregister an event listener, keeping accidental references to iframe objects, leaving workers open, or accumulating data in arrays all leak memory. The result is a page that grows progressively slower and more resource-hungry over time.

The first step toward solving this is measurement. The performance.measureUserAgentSpecificMemory() API is designed for production use, giving you a way to detect leaks that local testing might miss.

What changes from the legacy performance.memory

The older performance.memory API returns the size of the JavaScript heap — a value that becomes unreliable when Chrome shares a single heap across multiple pages or instances of the same page. Because it is tied to an implementation-specific concept like "heap", it was never a viable candidate for standardization.

The new API estimates the total memory used by your web page rather than just the heap. It also performs the measurement during garbage collection, which reduces noise in the results, though at the cost of delayed responses — the API waits for the next GC cycle to resolve. Other browsers may implement this without relying on garbage collection.

Intended usage patterns

Individual measurements are noisy and vary with user actions, event timing, and garbage collection schedules. The API is meant for aggregating data from production. Common scenarios include:

  • Catching new memory leaks during a rollout by comparing new versions against previous ones.
  • A/B testing features to assess their memory overhead and leak potential.
  • Comparing memory usage against session length to verify the absence of leaks.
  • Correlating memory data with user experience metrics to understand its real-world impact.

Browser support and enabling the API

As of Chrome 89, measureUserAgentSpecificMemory() is available only in Chromium-based browsers. The results are highly implementation-dependent — browsers represent objects and estimate memory differently, and some may omit memory regions where accounting is too costly. Comparing values across browsers is meaningless; only comparisons within the same browser are valid.

The API also enforces a security boundary. It will fail with a SecurityError unless the page is cross-origin isolated. Activation requires setting COOP and COEP headers on your page. At runtime, feature detection looks like this:

if (performance.measureUserAgentSpecificMemory) {
  // Safe to call
}

Even then, calling the API does not resolve immediately — Chrome waits for the next garbage collection. A fallback timeout of 20 seconds forces a GC if none occurs. During debugging, starting Chrome with the --enable-blink-features='ForceEagerMeasureMemory' flag reduces that timeout to zero, so you can test locally without waiting.

Building an unbiased memory monitor

The recommended approach is a global monitor that samples whole-page memory usage and sends results to a server for aggregation. Periodic sampling at fixed intervals biases the data: peaks can happen between samples, and valleys skew the average. A better method models sampling as a Poisson process, which guarantees samples are uniformly distributed over time.

Start by scheduling the next measurement on a randomized interval:

function scheduleMeasurement() {
  // Randomize the interval to avoid sampling bias
  const delay = measurementInterval();
  setTimeout(async () => {
    await performMeasurement();
    scheduleMeasurement();
  }, delay);
}

The measurementInterval() function returns a random delay in milliseconds, averaging one measurement every five minutes, based on an exponential distribution.

function measurementInterval() {
  // Average interval is 5 minutes = 300000 ms
  const MEAN_INTERVAL_MS = 5 * 60 * 1000;
  return -Math.log(Math.random()) * MEAN_INTERVAL_MS;
}

Then perform the measurement, record the result, and ensure the sampling loop continues:

async function performMeasurement() {
  if (!performance.measureUserAgentSpecificMemory) {
    return;
  }
  try {
    const result = await performance.measureUserAgentSpecificMemory();
    // Send 'result' to your analytics endpoint for aggregation
  } catch (error) {
    // Handle failures, e.g. lack of cross-origin isolation
  }
}

Start the monitor once the page loads:

if (performance.measureUserAgentSpecificMemory) {
  scheduleMeasurement();
}

A sample result might look like this:

{
  bytes: 84000000,
  breakdown: [
    {
      bytes: 40000000,
      attribution: [
        { url: 'https://my-app.example.com/', scope: 'Window' }
      ],
      types: ['JavaScript', 'DOM']
    },
    {
      bytes: 30000000,
      attribution: [
        { url: 'https://my-app.example.com/worker.js', scope: 'DedicatedWorker' }
      ],
      types: ['JavaScript']
    }
  ]
}

The bytes field is your top-level estimate. It includes JavaScript and DOM memory from all iframes, related windows, and web workers running in the current process. The breakdown list attributes portions of that memory to specific windows, frames, and workers via their URLs; each entry also carries a types list for implementation-specific memory categories.

Both breakdown and the attribution lists inside it should be treated generically. Browsers may return an empty breakdown, an empty attribution, or multiple entries in attribution when they can't determine which of several items owns a particular memory region. Hardcoding assumptions to one browser's output will break under another.