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.

Fixed intervals versus Poisson sampling The top timeline shows a periodic cache refresh every 60 seconds and fixed samples every 60 seconds that always land just after each refresh, so every sample sees the post-refresh peak. The bottom timeline shows the same refreshes with samples at random, exponentially distributed intervals, landing at varied phases and giving an unbiased picture of memory over the session. Fixed 60 s interval: always samples the post-refresh peak biased: always high Poisson sampling: random phases, unbiased cache refresh (every 60 s)

Step-by-Step Fix

  1. 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).
  2. Schedule with exponential gaps. Compute each delay as -ln(1 - U) × mean with U uniform in [0, 1). Verification: a histogram of delays from a test run looks exponential, not spiked at one value.
  3. 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.
  4. 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.
  5. Send in batches on page hide. Flush the queue with navigator.sendBeacon on visibilitychange to hidden, plus an occasional flush when the queue is full. Verification: the network log shows one or two small requests per session.
  6. 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.
The collector's own footprint With an unbounded array that keeps every sample and its full breakdown, the collector's retained memory grows to about 3 megabytes over a four hour session. With a bounded 20-entry queue of compact samples that flushes on page hide, it stays at about 4 kilobytes. 3 MB 0 session hours 0 → 4 unbounded samples[] with full breakdowns bounded 20-entry queue, compact samples

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.

Collector checks before wide rollout Before rolling out widely, confirm overhead by comparing INP and long animation frames between collector on and off arms, footprint by checking the collector retains only a few kilobytes after a long idle session, and data quality by checking stable daily percentiles and an exponential delay histogram. Verify three properties Overhead INP and long frames per session match between on and off arms. Footprint After a long idle session the collector retains a few KB, no growing arrays. Data quality Stable daily percentiles per route; exponential delay histogram.

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.