Replacing performance.memory in Real User Monitoring

Your RUM dashboard has charted performance.memory.usedJSHeapSize for years, the numbers jump around for no apparent reason, and you have heard the API is deprecated. This guide from Measuring Memory in Production Browsers, in the Browser DevTools & Performance Profiling Workflows section, explains what the legacy numbers actually measure, why they are noisy, and how to migrate to measureUserAgentSpecificMemory() without breaking your historical trends.

Symptom Root Cause Immediate Action Measurable Impact
usedJSHeapSize jumps ±30% between consecutive samples Readings include uncollected garbage Aggregate percentiles over many sessions; never alert on one reading Stable trend lines
Values change in coarse steps Chromium quantises values without isolation Compare distributions, not individual values Avoids reading meaning into rounding
Memory looks fine but tabs crash Legacy API covers only the JS heap of one context Add DOM/worker coverage via the modern API Captures memory the old metric missed
Dashboard breaks when switching APIs Old and new numbers measure different things Run both in parallel and tag by api Continuous history with a clear handover
No data from Safari or Firefox performance.memory is Chromium-only Accept Chromium-only memory RUM; infer elsewhere Honest coverage statement on dashboards

Root Cause: A Legacy API With Narrow Scope and Built-In Noise

performance.memory was added to Chromium long before standard memory APIs existed. It exposes three numbers for the current JavaScript context: usedJSHeapSize (bytes currently occupied in the heap), totalJSHeapSize (bytes V8 has reserved) and jsHeapSizeLimit (the maximum the heap may grow to). It is non-standard, available only in Chromium-based browsers, and has limitations that make it misleading if read naïvely.

The first limitation is garbage. usedJSHeapSize includes objects that are already unreachable but not yet collected. Between collections it rises with every allocation; after a collection it drops. Two samples taken seconds apart on the same idle page can differ by tens of megabytes depending on where they fell in the collection cycle — the sawtooth you see in the Performance panel’s memory track. The second limitation is quantisation: to reduce its value as a side channel, Chromium rounds the values into coarse buckets unless the page is cross-origin isolated. The third is scope: the numbers cover only the JavaScript heap of the calling context. DOM memory, decoded images, other frames and workers are invisible, so a page leaking detached DOM trees or image bitmaps can look healthy.

performance.measureUserAgentSpecificMemory() addresses all three: it waits for a garbage collection so it measures live memory, it reports precise numbers because it requires cross-origin isolation, and it covers the page’s frames and workers with DOM attribution where the browser supports it. The catch is that the numbers are different — systematically lower than usedJSHeapSize for JS-heavy pages (garbage removed) and potentially higher for DOM-heavy pages (DOM included). Switching a dashboard from one to the other overnight creates a step change that looks like a regression or an improvement that never happened.

Legacy readings include garbage; modern readings do not Over a ten minute session, legacy usedJSHeapSize samples swing between 60 and 110 megabytes as garbage accumulates and is collected, forming a sawtooth. Modern measureUserAgentSpecificMemory samples of the same session sit on a smoother line around 62 to 70 megabytes because they are taken after garbage collection, and they also include 15 megabytes of DOM memory that the legacy API cannot see. 120 MB 0 legacy usedJSHeapSize (with garbage) modern total, post-GC, incl. DOM one session, 10 minutes

Step-by-Step Fix

  1. Tag every existing sample with its API. Add api: "legacy" to current performance.memory samples before changing anything else. Verification: your analytics store can filter by API.
  2. Stop drawing conclusions from single readings. Change dashboards to show p50/p75/p95 per session-age bucket over many sessions. Verification: trend lines are stable day to day, even though individual readings still jump.
  3. Enable isolation for a cohort. Roll out COOP/COEP to a slice of traffic and collect modern samples tagged api: "uasm", while continuing legacy sampling in the same sessions. Verification: isolated sessions report both APIs.
  4. Establish the relationship between the two. For sessions with both, compare percentiles by route and session age. Verification: you can state, for example, “modern p75 is about 20% below legacy p75 on JS-heavy routes and 10% above on DOM-heavy routes”.
  5. Switch the primary chart with an annotation. Make the modern API the primary metric for isolated traffic, keep the legacy series for non-isolated traffic, and annotate the switch date. Verification: stakeholders see two clearly labelled series rather than one line with a mysterious step.
  6. Retire legacy sampling where the modern API is universal. Once isolation covers all traffic you care about, stop legacy sampling there. Verification: the legacy series only continues for pages that cannot be isolated.
A migration without a mysterious step Phase one, legacy only, with samples tagged api legacy. Phase two, a cross-origin isolated cohort reports both APIs in parallel for at least two weeks so the relationship can be measured. Phase three, the modern API becomes the primary series with an annotation on the switch date, and legacy continues only for pages that cannot be isolated. 1. Legacy only tag samples api: legacy percentiles, not readings 2. Both, in parallel isolated cohort, ≥ 2 weeks measure the offset 3. Modern primary annotated switch date legacy only where needed never overwrite one series with the other — they measure different things

Command and Code Reference

Use case: a dual-API sampler for the migration period. Both readings are taken in the same session so they can be compared directly.

// dual-sampler.js — legacy and modern side by side during migration
export async function dualSample(context) {
  const out = [];

  if (performance.memory) {
    out.push({
      api: 'legacy',
      bytes: performance.memory.usedJSHeapSize,      // JS heap, with garbage
      reserved: performance.memory.totalJSHeapSize,
      ...context,
    });
  }

  if (self.crossOriginIsolated && performance.measureUserAgentSpecificMemory) {
    try {
      const r = await performance.measureUserAgentSpecificMemory(); // post-GC, JS + DOM
      out.push({ api: 'uasm', bytes: r.bytes, ...context });
    } catch { /* isolation lost: legacy sample still recorded */ }
  }
  return out;
}

Use case: compare the two APIs per route in your warehouse. A simple SQL query shows the offset you need to annotate the switch.

-- p75 bytes per route and API, isolated sessions only, last 14 days
SELECT route,
       api,
       approx_quantiles(bytes, 100)[OFFSET(75)] / 1048576 AS p75_mb,
       COUNT(*) AS samples
FROM rum_memory
WHERE isolated = TRUE
  AND ts > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY)
GROUP BY route, api
ORDER BY route, api;

Verification and Regression Prevention

The migration is complete when dashboards show clearly labelled series for each API, the parallel-run period has produced a documented offset per route, and alerts are defined on percentiles of the modern series for isolated traffic. Validate the new metric by checking that a known leak — introduced deliberately in a staging build or found in a past incident — produces a rising session-age curve in the modern series.

Protect the data from silent changes: monitor the ratio of uasm to legacy samples (a drop means isolation was lost somewhere), and keep route normalisation and session-age bucketing in one shared library so both series stay comparable. For the collection mechanics — intervals, beacons and overhead — follow sampling memory telemetry without hurting performance.

Completing the metric migration Run the legacy and modern series in parallel, document the offset between them per route, validate the modern series by checking a known leak produces a rising session-age curve, then move alerts to percentiles of the modern series for isolated traffic and monitor the sample ratio. Parallel run legacy and uasm series Per-route offset documented, not guessed Known-leak check session-age curve rises Alerts on uasm percentiles, isolated traffic watch the uasm : legacy sample ratio — a drop means isolation broke

Edge Cases and Gotchas

jsHeapSizeLimit is not a device limit

jsHeapSizeLimit reports V8’s configured maximum heap for the context, typically several gigabytes on desktop. The tab will usually be killed by the operating system or browser long before the heap reaches it on mobile devices. Do not use it as a budget.

totalJSHeapSize lags reality

The reserved figure grows in chunks and shrinks lazily. It is useful for seeing whether V8 is holding a lot of empty space after a spike, not for measuring usage.

Workers need their own sampling

performance.memory inside a worker reports that worker’s heap, if available at all. The modern API measures workers from the page, which is one of the main reasons to migrate for apps that do heavy work off the main thread.

Background tabs skew samples

Hidden tabs are throttled and collect garbage on a different schedule. Sample only when document.visibilityState is visible, or tag hidden-state samples and analyse them separately.

Frequently Asked Questions

Is performance.memory deprecated?

It is non-standard and Chromium-only, and it has never been part of a specification. Browsers other than Chromium never implemented it. It still works in Chromium today, but new monitoring should use measureUserAgentSpecificMemory() where cross-origin isolation is possible.

Why is usedJSHeapSize so noisy?

Because it includes garbage that has not been collected yet. The value rises with every allocation and drops after each collection, so the reading depends on when in that cycle you sample. Percentiles across many sessions smooth this out; individual readings do not.

What should I alert on after the migration?

Alert on changes in distribution, not on thresholds for individual sessions: for example, p75 of the modern total for a route in the 60+ minute session bucket rising more than 20% week over week, or the share of sessions above your device-tier budget increasing after a release. Individual readings are too variable to page anyone, while percentile shifts across thousands of sessions reliably indicate a real change.

Should I keep performance.memory for non-isolated pages?

Yes, as long as you keep it clearly labelled. A coarse Chromium-only JS-heap trend is still better than nothing for pages that cannot be isolated because of third-party embeds. Just do not compare its values with the modern series, and do not use it for budgets that include DOM or image memory.

Can I convert legacy numbers into modern ones?

Not exactly, because they measure different scopes at different moments. You can measure the typical offset per route during a parallel-run period and use it to interpret historical trends, but keep the series separate rather than adjusting one into the other.