Measuring Memory in Production Browsers
Lab profiling tells you how your app behaves on your machine with your data; it does not tell you how much memory real users’ tabs consume after two hours of use, on their devices, with their extensions and their data volumes. This topic, part of Browser DevTools & Performance Profiling Workflows, covers the browser APIs that measure memory in the field — performance.measureUserAgentSpecificMemory(), the legacy performance.memory, and out-of-memory crash reports through the Reporting API — and how to turn their output into decisions. It is written for frontend engineers and performance teams who already know the lab tools in Mastering the Chrome DevTools Memory Tab and now need to know whether real sessions leak.
Conceptual Grounding
Field memory measurement is harder than lab measurement for three reasons. First, privacy: exact memory numbers can leak information across origins — how big a cross-origin resource is, whether a user is logged in elsewhere — so browsers restrict precise APIs to pages that are cross-origin isolated, and the legacy API is deliberately coarse. Second, timing: a heap reading taken at an arbitrary moment includes garbage, so reliable measurement has to wait for a garbage collection, which the modern API does for you and the legacy API does not. Third, cost: measurement must not disturb the thing it measures, so sampling has to be infrequent, randomised and cheap.
The three available signals answer different questions:
performance.measureUserAgentSpecificMemory()returns the memory attributed to the page — JavaScript heaps of the main frame, same-origin iframes and workers, and DOM memory in supporting browsers — with a breakdown by attribution. It resolves after the next garbage collection, so its numbers reflect live memory rather than garbage. It requirescrossOriginIsolated.performance.memory(Chromium only, non-standard) exposesusedJSHeapSize,totalJSHeapSizeandjsHeapSizeLimitfor the current context. It is synchronous and available everywhere in Chromium, but the values are coarse, include garbage and cover only the JavaScript heap. It remains useful as a fallback trend signal; replacing performance.memory in RUM explains how to migrate.- Reporting API crash reports are sent by Chromium when a page’s renderer crashes, including crashes with reason
oom. They do not measure memory at all; they tell you when memory ran out, which is the outcome you ultimately care about.
The right mental model is a funnel. Crash reports tell you whether memory is a user-visible problem and on which pages. Field measurements tell you how memory evolves across session length, device class and route. Lab tools then tell you why. Sampling memory telemetry without hurting performance covers how to collect the middle layer cheaply.
Diagnostic Workflow
- Start collecting crash reports. Add a
Reporting-Endpointsheader naming adefaultendpoint and store incoming crash reports. Expected output: a daily count of crash reports, with the share whosereasonisoom, per URL. Metric: OOM crashes per 10,000 page views. - Enable cross-origin isolation where you can. Serve
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp(orcredentialless) on the pages you want to measure. Expected output:self.crossOriginIsolated === truein the Console on those pages. - Sample memory at randomised intervals. Call
performance.measureUserAgentSpecificMemory()on a Poisson-distributed schedule (mean interval of several minutes) and fall back toperformance.memorywhere isolation is not available. Expected output: samples tagged with session age, route, device memory and API used. Metric: p50/p75/p95 bytes per session-age bucket. - Plot memory against session age. Group samples into buckets such as 0–5, 5–15, 15–60 and 60+ minutes. Expected output: a flat or saturating curve for healthy pages; a steadily rising curve indicates a leak that real sessions hit. Metric: MB per hour of session.
- Break down by route and attribution. Use the API’s
breakdownentries (frame URL, worker scope) and your route tags to see which part of the app grows. Expected output: one or two routes or workers responsible for most growth. - Reproduce in the lab and fix. Take the route and flow that grows in the field, reproduce it with the three-snapshot technique, fix, and ship. Expected output: after release, the session-age curve for that route flattens and OOM crash rate drops. Metric: before/after MB per hour and crash rate.
Code Patterns & Signatures
Use case: a minimal, feature-detected memory sample. Prefer the modern API; fall back to the legacy one and record which was used, because their numbers are not comparable.
// memory-sample.js
export async function sampleMemory() {
// Modern API: page + same-origin frames + workers, post-GC, needs isolation
if (self.crossOriginIsolated && 'measureUserAgentSpecificMemory' in performance) {
try {
const result = await performance.measureUserAgentSpecificMemory();
return { api: 'uasm', bytes: result.bytes, breakdown: result.breakdown.length };
} catch (err) {
// SecurityError if isolation is lost, or the API is disabled
console.debug('uasm unavailable', err);
}
}
// Legacy Chromium API: JS heap of this context only, coarse, includes garbage
if (performance.memory) {
return { api: 'legacy', bytes: performance.memory.usedJSHeapSize };
}
return null; // Safari/Firefox without isolation: no signal
}
Use case: schedule samples with exponentially distributed intervals. Randomised timing avoids aligning samples with periodic app behaviour and spreads measurement cost.
// memory-scheduler.js
import { sampleMemory } from './memory-sample.js';
const MEAN_INTERVAL_MS = 5 * 60 * 1000; // one sample per ~5 minutes
const sessionStart = performance.now();
function nextDelay() {
// Exponential distribution → Poisson process of samples
return -Math.log(1 - Math.random()) * MEAN_INTERVAL_MS;
}
function schedule() {
setTimeout(async () => {
if (document.visibilityState === 'visible') { // measure active sessions only
const s = await sampleMemory();
if (s) queueBeacon({
...s,
sessionMinutes: Math.round((performance.now() - sessionStart) / 60000),
route: location.pathname.replace(/\/\d+/g, '/:id'), // low-cardinality route
deviceMemory: navigator.deviceMemory ?? null,
});
}
schedule();
}, nextDelay());
}
schedule();
Use case: headers that enable both isolation and crash reporting. Set them at the edge for the pages you want to measure.
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
Reporting-Endpoints: default="https://rum.example.com/reports"
Reading the Result Object
The promise returned by measureUserAgentSpecificMemory() resolves to an object with a total bytes value and a breakdown array. Each breakdown entry has its own bytes, a types array naming the kind of memory (for example "JavaScript" or "DOM", depending on what the browser can attribute), and an attribution array describing where the memory lives: the URL of the frame, the scope (such as "Window", "DedicatedWorkerGlobalScope" or "SharedWorkerGlobalScope"), and for iframes a container object with the iframe element’s id and src attributes. Memory the browser cannot attribute to a specific context appears in an entry with an empty attribution array.
That structure is what makes the modern API diagnostically useful. A single total tells you a session is large; the breakdown tells you whether the growth is in the main window’s JavaScript, in a dedicated worker that processes data, or in an embedded iframe you do not control. In practice, teams store three values per sample — the total, the main-window JavaScript bytes, and the sum of worker bytes — plus the route and session age. That keeps the data small while preserving the most common diagnostic splits. Store the full breakdown only for a small sub-sample if you need to investigate iframes or unusual contexts.
The legacy performance.memory object is much simpler. usedJSHeapSize is the currently used JavaScript heap of the current context (garbage included), totalJSHeapSize is what V8 has reserved, and jsHeapSizeLimit is the maximum the heap may grow to. Without cross-origin isolation Chromium quantises these values, so small differences are meaningless; with isolation they are more precise but still include garbage. Use usedJSHeapSize trends across many sessions, never individual readings.
Turning Field Data into Decisions
Raw samples are only useful once they are aggregated along the dimensions that separate leaks from normal usage. The most informative view is memory by session age: a healthy page rises during the first few minutes as caches warm and then plateaus; a leaking page keeps rising. Plot p50, p75 and p95 per session-age bucket rather than averages, because a small fraction of very long sessions can dominate a mean while the percentiles show the shape.
The second view is memory by route or feature. Tag each sample with a normalised route (replace IDs with placeholders to keep cardinality low) and, for single-page apps, the number of navigations in the session. A route whose p75 grows with navigation count is retaining something per visit — the signature of the SPA leaks covered in the profiling best practices. The third view is memory by device tier: combining samples with navigator.deviceMemory shows whether low-memory devices sit uncomfortably close to the budgets you set in setting memory budgets for low-end devices.
Finally, correlate memory with outcomes. Join memory samples with OOM crash reports and with interaction latency (for example long animation frames) by session. If sessions in the top memory decile also account for most OOM crashes and slow interactions, you have a business case for fixing the leak and a metric to track the fix. After releasing a fix, compare the same views for the new version against the previous one; field data is the only place a leak fix is truly confirmed, because it is the only place the long sessions happen.
Rolling out measurement safely
Introduce field measurement in stages. Start with crash reporting, which needs only a response header and has no runtime cost. Next, add legacy performance.memory sampling at a low rate to get a coarse baseline. Then enable cross-origin isolation for a small percentage of traffic, verify that nothing breaks — watch for failed image and script loads in your error logs — and expand gradually, switching those sessions to the modern API. Keep the api tag on every sample so dashboards never mix the two measurements, and keep the collector bounded: a telemetry library that queues samples indefinitely in a long session is itself a small leak.
Symptom-to-Fix Reference
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
measureUserAgentSpecificMemory is undefined or throws SecurityError |
Page is not cross-origin isolated | Serve COOP same-origin and COEP require-corp/credentialless |
API available; post-GC, attributed samples |
| Field p75 memory rises steadily with session age | A leak real users hit in long sessions | Segment by route; reproduce the growing route in the lab | Identifies the leaking feature from field data |
| OOM crash reports concentrated on one URL | That page exceeds device limits | Budget and reduce its footprint; check images and DOM | OOM crash rate for the URL falls |
| Legacy and modern numbers disagree wildly | Different scopes and GC timing | Never mix APIs in one metric; tag samples with api |
Clean, comparable time series |
| Measurement itself shows up in long frames | Sampling too often or doing heavy work in the callback | Poisson schedule with minutes-long mean; beacon on page hide | Negligible overhead in RUM |
| Crash reports missing for Safari/Firefox users | Crash reporting is Chromium-specific | Infer from unexpected reload flags in sessionStorage |
Partial coverage for non-Chromium browsers |
| Enabling COEP breaks third-party embeds | Resources lack CORP/CORS headers | Use credentialless, or isolate only measurement cohorts |
Isolation without broken pages |
Edge Cases & Gotchas
Isolation changes how your page loads
Cross-Origin-Embedder-Policy: require-corp blocks cross-origin resources that do not opt in with Cross-Origin-Resource-Policy or CORS. Images from CDNs, ad frames and analytics scripts can break. credentialless loads cross-origin no-CORS resources without credentials instead of blocking them and is usually the easier path; test thoroughly and consider enabling isolation for a percentage of traffic first.
The modern API can take a long time to resolve
measureUserAgentSpecificMemory() waits for a suitable garbage collection rather than forcing one, so the promise can take many seconds to settle. Never await it on a critical path, and do not treat its latency as a performance problem.
Memory is shared across same-site tabs
Several tabs of the same site can share a renderer process. Process-level signals such as crash reports then reflect all of them, while per-page measurements do not. Record the number of open tabs where you can infer it (for example with a BroadcastChannel ping) when interpreting crash rates.
Numbers are not comparable across browsers or versions
Different engines count different things, and Chrome’s internals change between versions. Compare trends within one browser and version range, and annotate dashboards with browser releases.
Single-page apps need navigation counts, not just time
In a single-page app, a user who opens fifty records in ten minutes exercises retention paths far more than a user who reads one record for an hour. Session age alone hides that difference. Record the number of client-side navigations since load with each sample, and plot memory against navigation count as well as time; per-navigation leaks show up as a straight line on that axis even when the time-based curve looks noisy.
Workers and iframes change the picture
Heavy work moved into a dedicated worker disappears from performance.memory on the main thread, which can make a migration to workers look like a memory win when the memory simply moved. The modern API’s breakdown includes workers, so compare totals rather than main-window figures when evaluating architectural changes.
Privacy and data volume
Memory samples are low-sensitivity, but tags are not always: routes with IDs, user-specific paths and precise timestamps can identify users. Normalise routes, round values, and send samples in batches on page hide.
Frequently Asked Questions
Which API should I use for field memory measurement?
Use performance.measureUserAgentSpecificMemory() wherever you can make the page cross-origin isolated; it measures live memory after GC with attribution. Use performance.memory only as a coarse fallback trend in Chromium, and add Reporting API crash reports to learn when memory actually causes crashes.
How often should I sample memory in production?
Rarely and randomly: a mean interval of several minutes with exponentially distributed gaps is typical. You need enough samples across the population to estimate percentiles per session-age bucket, not a dense time series for each user.
Can I measure memory in Safari and Firefox?
Neither exposes performance.memory, and support for the isolation-gated API varies. You can still infer memory trouble indirectly, for example by detecting unexpected reloads after the page was hidden, and by measuring in Chromium, whose trends usually apply to other engines as well.
What does a healthy field memory curve look like?
For most pages, memory rises during the first minutes of a session as code is compiled, caches fill and data is loaded, then flattens into a plateau with small fluctuations. The plateau’s height depends on the page’s purpose — an editor holding a large document legitimately sits higher than a settings page — but its shape should be flat. A curve that keeps rising in the 15–60 and 60+ minute buckets, or that rises with the number of client-side navigations, is the signature of a leak that real users hit. A curve that is flat but very high is a footprint problem instead: nothing leaks, but the page needs a smaller budget for low-memory devices.
How do I know a fix worked in production?
Compare the same views — session-age percentiles, navigation-count percentiles and OOM crash rate for the affected route — between the release before and the release after the fix, restricted to the same browser versions and device tiers. Allow a week or two, because long sessions accumulate slowly and crash reports can arrive late. A real fix flattens the curve for long sessions and lowers the crash rate; a change that only moves the early-session numbers probably affected warm-up rather than the leak.
Does field measurement replace lab profiling?
No. Field data shows whether and where memory grows for real users; lab tools such as heap snapshots are still needed to find which references cause it. Use field data to choose what to investigate and to confirm that fixes worked.
Related
- Using measureUserAgentSpecificMemory with Cross-Origin Isolation — enabling and calling the modern API
- Replacing performance.memory in Real User Monitoring — migrating existing RUM dashboards
- Detecting Out-of-Memory Crashes with the Reporting API — collecting and triaging OOM crashes
- Sampling Memory Telemetry Without Hurting Performance — keeping measurement overhead negligible
- Browser DevTools & Performance Profiling Workflows — the parent section