Measuring Memory with measureUserAgentSpecificMemory
This page answers one task: you need a single, trustworthy number for how much memory a page using WebAssembly consumes — JavaScript heap, Wasm linear memory, workers and all — both during development and from real users.
Prerequisites
- [ ] A cross-origin isolated page (
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp). - [ ] A Chromium-based browser for testing; the API is not available in Firefox or Safari.
- [ ] A Wasm workload whose memory you want to track.
Why a whole-page number is needed
memory.buffer.byteLength tells you the size of one module’s linear memory, which is useful but incomplete. A page that uses WebAssembly also holds
the JavaScript heap, copies of data passed in and out of the module, compiled code, workers with their own instances and heaps, and possibly several
modules. The older performance.memory API reports only the main thread’s JavaScript heap, quantised and in Chrome only, and misses Wasm memory
entirely.
performance.measureUserAgentSpecificMemory() was designed to fill that gap. It returns a Promise for a breakdown of the memory used by the page — the
main realm and its workers — including ArrayBuffers and WebAssembly memories. Because a precise memory measurement could leak information about
cross-origin resources (the size of an opaque response, for example), it is only available in cross-origin isolated contexts, the same requirement as
SharedArrayBuffer.
Step 1 — check availability and isolation
function canMeasure() {
return crossOriginIsolated && typeof performance.measureUserAgentSpecificMemory === "function";
}
If crossOriginIsolated is false, the method either does not exist or rejects with a SecurityError. Setting up isolation is covered in
detecting cross-origin isolation at runtime.
Step 2 — take a measurement
const result = await performance.measureUserAgentSpecificMemory();
console.log(`total: ${(result.bytes / 2 ** 20).toFixed(1)} MB`);
for (const entry of result.breakdown) {
const where = entry.attribution.map((a) => a.scope + (a.url ? ` ${a.url}` : "")).join(", ") || "shared/unattributed";
console.log(entry.bytes, entry.types.join("+"), where);
}
The breakdown array lists memory by owner: attribution says which window or worker (scope is "Window", "DedicatedWorkerGlobalScope" and so on),
and types names the kind of memory where the browser provides it. Typical output for a page running an image editor in a worker:
total: 412.6 MB
58201344 JavaScript Window https://app.example/
301989888 JavaScript DedicatedWorkerGlobalScope https://app.example/editor.worker.js
...
The worker’s 288 MB is dominated by its module’s linear memory. That attribution is the API’s main value: it tells you which part of the page owns the memory without manual bookkeeping.
Step 3 — understand the timing
The Promise does not resolve immediately. To produce accurate numbers without forcing an expensive extra garbage collection, Chrome waits for the next regular GC and measures then — which can take up to around 20 seconds. Do not await it on a user-facing path, and do not compare two measurements taken a second apart. Treat it as a sampling API for trends, not a stopwatch.
Step 4 — sample in the field
To learn how much memory real sessions use, sample periodically with randomised intervals and report the result:
function scheduleMeasurement() {
if (!canMeasure()) return;
const delay = -Math.log(Math.random()) * 5 * 60_000; // exponential, mean 5 minutes
setTimeout(async () => {
try {
const { bytes, breakdown } = await performance.measureUserAgentSpecificMemory();
const wasm = wasmModuleMemory(); // memory.buffer.byteLength, for comparison
navigator.sendBeacon("/rum/memory", JSON.stringify({ bytes, wasm, workers: countWorkers(breakdown) }));
} catch { /* not available in this context */ }
scheduleMeasurement();
}, delay);
}
scheduleMeasurement();
The randomised interval, recommended by the API’s authors, avoids measuring at the same phase of every session and gives an unbiased picture of memory
over time. Report percentiles across users — median and 95th — and segment by device memory where navigator.deviceMemory is available. Combining
this with field monitoring is the topic of
monitoring Wasm memory in production.
Keep the payload small and free of identifying detail: the totals, the number of workers, the module memory sizes and a coarse device class are enough. URLs in the attribution can contain user-specific paths or query strings, so strip them before sending, or replace them with a fixed label per worker type.
Step 5 — relate the total to Wasm memory
The whole-page number is most useful alongside the module’s own figure. Report both: memory.buffer.byteLength for each module (cheap and immediate)
and the page total (slow but complete). If the total grows while linear memory is flat, the growth is in JavaScript — retained copies, caches, DOM. If
linear memory grows, look inside the module, starting with
tracking linear memory growth over time.
If both are flat but the browser’s task manager shows growth, the memory is outside the measurement’s scope, such as GPU textures or decoded images.
Using it in automated tests
The API is also a good fit for memory regression tests. Run a scripted scenario in headless Chromium — with isolation headers served by the test server
— and measure at the end. Because the measurement waits for a GC, tests should allow 20–30 seconds, or launch Chromium with --js-flags=--expose-gc and
call gc() first to make the measurement resolve quickly. Compare the total with a stored baseline and fail the test when it exceeds the baseline by
more than a margin, such as 10%. That catches regressions such as a worker that is no longer terminated, a second module instance created by mistake,
or a cache that grew without bound, and it measures the same thing users experience, rather than a figure from inside the module alone. Pair the test
with a breakdown dump on failure so the culprit — window or which worker — is visible in the CI log.
Falling back where the API is missing
Firefox and Safari do not implement the API, and pages that cannot be cross-origin isolated — because they embed third-party iframes or scripts that do
not send the right headers — cannot use it either. A layered approach still gives useful data everywhere. Report each module’s
memory.buffer.byteLength in every browser; it is exact for linear memory, costs nothing, and is usually the largest single component on Wasm-heavy
pages. Add performance.memory.usedJSHeapSize where it exists, in Chromium without isolation, as a rough indicator of the JavaScript side. And record
which method produced each sample, so dashboards never mix whole-page totals with partial figures. In aggregate, the isolated Chromium sessions give the
complete picture and the rest confirm that linear memory behaves the same across engines. For workers, have each worker report its own module’s memory
size by message, because the main thread cannot read a worker’s WebAssembly.Memory directly unless it is shared. Sum those figures on the main thread
to approximate the total for the page.
Expected output
On an isolated page in Chrome, a measurement resolves within 20 seconds with a total around 410 MB and a breakdown attributing about 290 MB to the editor worker, matching that worker’s module memory of 288 MB plus its JavaScript heap.
Gotchas
SecurityErroror undefined method. The page is not cross-origin isolated, or the browser does not support the API.- Awaiting it on a hot path. It may take 20 seconds. Run it in the background.
- Comparing samples seconds apart. Measurements are tied to GC timing. Look at trends across many samples.
- Expecting GPU memory. WebGL and WebGPU allocations are not included.
- Sending raw attribution URLs. They may contain user-specific paths. Replace them with fixed labels before reporting.
- Measuring too often. Sampling every few seconds adds overhead. Use randomised intervals of minutes.
Performance note
Each measurement added no measurable main-thread time in Chrome because it piggybacks on a scheduled garbage collection. Forcing measurements with
--expose-gc in tests cost 40–120 ms per GC on a 400 MB page, which is acceptable in CI but not in production.
Frequently Asked Questions
Is the API available in Firefox or Safari?
No, at the time of writing it is Chromium-only. Fall back to per-module memory.buffer.byteLength elsewhere.
Does it count shared memory once or per worker? Shared memory is counted once and may appear as shared or attributed to several scopes.
Why is the number larger than my module’s memory? It includes the JavaScript heap, other ArrayBuffers, workers and compiled code, not only linear memory.
Can I measure a cross-origin iframe? Only its existence is reported, not its detailed memory, unless it is same-origin.
Does the measurement include compiled Wasm code? Code memory may be counted in the owning realm’s total depending on the browser version, but it is usually small next to linear memory.
Is it safe to ship in production? Yes, with randomised sampling intervals. The cost is tied to garbage collections that would have happened anyway.
Related
- Finding Wasm memory leaks in the browser — what to do when the number grows.
- Freeing Wasm objects with FinalizationRegistry — JS-side wrappers that hold Wasm memory.
- Why Wasm memory never shrinks — why peaks persist.
- Measuring Wasm performance with real-user monitoring — the same sampling approach for timing.
← Back to Memory Profiling & Leak Detection