Sampling Memory Telemetry Without Hurting Performance
You want memory data from every kind of session, but the monitoring snippet itself shows up in long-frame attributions, keeps an ever-growing array of samples, and sends a request every minute. This guide from Measuring Memory in Production Browsers, in Browser DevTools & Performance Profiling Workflows, shows how to design a memory sampler whose cost is negligible and whose data is statistically sound.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| RUM script appears in long animation frame attributions | Sampling and serialising on the main thread during interactions | Run sampling in idle time, off interaction paths | Collector drops out of long-frame attributions |
| Memory grows with session length even on idle pages | Collector keeps every sample in memory | Bound the queue; flush on page hide | Collector’s own footprint is constant |
| Samples cluster at page load and every 60 s | Fixed intervals align with app timers and load | Use exponentially distributed (Poisson) intervals | Unbiased coverage of session time |
| Beacons on every sample waste bandwidth | Sending individually | Batch samples; send with sendBeacon on hide |
1 request per session instead of dozens |
| Percentiles swing wildly between days | Too few samples per bucket, or long sessions over-weighted | Sample per session with caps; weight by session | Stable daily percentiles |
Root Cause: Measurement Has Costs, and Naive Schedules Bias the Data
Every memory sample costs something. performance.measureUserAgentSpecificMemory() is asynchronous and waits for a garbage collection, which is cheap for the page but not free for the browser. The legacy performance.memory getter is cheap, but serialising a sample, adding route and device tags, and sending it involves string building, JSON encoding and a network request — trivial once, noticeable when done during an interaction, and wasteful when repeated every few seconds. And collectors often have a hidden memory cost: an array of samples appended forever, or a closure per scheduled timer, grows with session length — precisely the long sessions you most want to measure.
Scheduling also matters statistically. A fixed interval (every 60 seconds) samples the same phase of any periodic behaviour in your app — a polling cycle, an animation, a cache refresh — and can systematically over- or under-count memory. It also makes sampling cost predictable in a bad way: every client does work at the same moments relative to page load. Sampling at exponentially distributed intervals produces a Poisson process, which samples every moment of the session with equal probability and cannot align with periodic behaviour. This is the approach recommended for measureUserAgentSpecificMemory() itself.
Finally, population statistics depend on weighting. Long sessions produce more samples than short ones; if you compute percentiles over all samples, long sessions dominate. That is sometimes what you want (memory at 60+ minutes is where leaks show), but it should be explicit — bucket by session age, as described in Measuring Memory in Production Browsers, and cap samples per session so a handful of all-day sessions cannot drown everyone else. Keeping the collector cheap also keeps it out of the long animation frame attributions you may be collecting alongside.
Step-by-Step Fix
- Choose a mean interval and a per-session cap. A mean of 3–10 minutes and a cap of around 20 samples per session suit most apps. Verification: estimated samples per day per route are enough for stable percentiles (hundreds per session-age bucket).
- Schedule with exponential gaps. Compute each delay as
-ln(1 - U) × meanwithUuniform in [0, 1). Verification: a histogram of delays from a test run looks exponential, not spiked at one value. - Do the work in idle time and only when visible. When a delay elapses, wait for
requestIdleCallback(with a timeout) and skip the sample if the page is hidden. Verification: in a Performance recording, the collector’s work appears as small idle-period tasks, never inside input handlers. - Keep a bounded in-memory queue. Store compact sample objects in a fixed-size array and drop the oldest when full. Verification: the collector’s own retained size, checked in a heap snapshot after an hour, is a few kilobytes.
- Send in batches on page hide. Flush the queue with
navigator.sendBeacononvisibilitychangeto hidden, plus an occasional flush when the queue is full. Verification: the network log shows one or two small requests per session. - Validate overhead. Compare interaction latency and long-frame counts between sessions with and without the collector (for example via a 50/50 flag). Verification: no statistically meaningful difference between the two groups.
Command and Code Reference
Use case: a complete low-overhead sampler. Poisson timing, idle-time work, visibility checks, a bounded queue and page-hide flushing.
// memory-telemetry.js
const MEAN_MS = 5 * 60 * 1000; // mean gap between samples
const MAX_SAMPLES = 20; // per session
const QUEUE_LIMIT = 10; // flush when this many are waiting
const queue = [];
let taken = 0;
const start = performance.now();
const expDelay = () => -Math.log(1 - Math.random()) * MEAN_MS;
async function readMemory() {
if (self.crossOriginIsolated && performance.measureUserAgentSpecificMemory) {
try { return { api: 'uasm', bytes: (await performance.measureUserAgentSpecificMemory()).bytes }; }
catch { /* fall through */ }
}
return performance.memory ? { api: 'legacy', bytes: performance.memory.usedJSHeapSize } : null;
}
function flush() {
if (!queue.length) return;
navigator.sendBeacon('/rum/memory', JSON.stringify(queue.splice(0))); // empties the queue
}
function scheduleNext() {
if (taken >= MAX_SAMPLES) return; // per-session cap
setTimeout(() => {
requestIdleCallback(async () => { // stay off interaction paths
if (document.visibilityState === 'visible') {
const m = await readMemory();
if (m) {
taken++;
queue.push({ ...m, min: Math.round((performance.now() - start) / 60000),
route: location.pathname.replace(/\/\d+/g, '/:id') });
if (queue.length >= QUEUE_LIMIT) flush(); // bounded memory
}
}
scheduleNext();
}, { timeout: 10_000 });
}, expDelay());
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
scheduleNext();
Use case: check the collector’s overhead with an experiment flag. A random half of sessions runs without it so you can compare user-facing metrics.
// Enable the collector for ~50% of sessions and tag every metric with the arm
const arm = sessionStorage.getItem('memArm') ?? (Math.random() < 0.5 ? 'on' : 'off');
sessionStorage.setItem('memArm', arm);
if (arm === 'on') import('./memory-telemetry.js');
reportWebVitalsWithTag({ memArm: arm }); // compare INP and long-frame counts by arm
Verification and Regression Prevention
Verify three properties before rolling out widely. Overhead: interaction metrics (INP, long animation frames per session) do not differ between the collector’s on and off arms beyond normal variance. Footprint: a heap snapshot after a long idle session shows the collector retaining a few kilobytes at most, with no growing arrays. Data quality: daily percentiles per route and session-age bucket are stable, and the delay histogram is exponential. Record these results with the collector’s version so a future change can be compared against them.
Protect the collector like production code: unit-test the queue bounds and per-session cap, and include it in the leak tests described in writing memory leak tests with Vitest and --expose-gc. A monitoring script that leaks is a particularly confusing bug, because it makes every page look worse in exactly the metric it reports.
Edge Cases and Gotchas
requestIdleCallback in Safari
Safari has historically lacked requestIdleCallback. Feature-detect it and fall back to a short setTimeout, accepting slightly less ideal timing on those browsers — which also lack the memory APIs, so the fallback rarely matters.
Pages that are never hidden
Kiosk displays and dashboards may stay visible for days and never fire visibilitychange. The queue-limit flush covers them; make sure it exists, or those sessions never report.
sendBeacon size limits
Browsers cap beacon payloads (commonly around 64 KB in total queued data). Compact samples keep you far below that; full breakdowns from the modern API can exceed it if many are queued, which is another reason to summarise before queuing.
Sampling rate versus cost of storage
The on-device cost is tiny; the storage and query cost on your side is not. Sample a fraction of sessions if traffic is high — memory trends need thousands of sessions, not millions.
Frequently Asked Questions
Why use random intervals instead of every minute?
Fixed intervals can align with periodic app behaviour and systematically bias measurements, and they make all clients do work at the same moments. Exponentially distributed intervals sample every moment of the session with equal probability, giving unbiased estimates with the same average number of samples.
How many samples do I need?
Enough per bucket (route × session-age range × device tier) for stable percentiles — a few hundred samples per bucket per day is usually plenty. Work backwards from traffic to choose the mean interval and per-session cap.
Does calling measureUserAgentSpecificMemory slow the page?
The call itself returns immediately; the result arrives after a garbage collection that would have happened anyway or that the browser schedules at low cost. The main costs are the surrounding bookkeeping and network requests, which idle-time scheduling and batching keep negligible.
Related
- Measuring Memory in Production Browsers — the parent topic
- Replacing performance.memory in Real User Monitoring — which API the sampler should prefer
- Module-Level Caches and Global Singleton Leaks — the pattern an unbounded collector falls into
- Browser DevTools & Performance Profiling Workflows — the section overview