Using the Allocation Sampling Profiler in Chrome

Your page allocates hundreds of megabytes during a workflow and you need to know which functions are responsible without the heavy overhead of a full recording — this guide, part of Mastering the Chrome DevTools Memory Tab in the Browser DevTools & Performance Profiling Workflows section, shows how to use the Allocation sampling profile to answer exactly that.

Symptom Root Cause Immediate Action Measurable Impact
JS heap climbs 50–200 MB during one user flow A hot function allocates short-lived objects per call Record an Allocation sampling profile over the flow Identifies the top allocator by Total Size in under 2 minutes
Allocation timeline recording slows the page to a crawl Instrumented recording tracks every object Switch to sampling, which only records every ~32 KB Profiling overhead drops to a few percent of CPU
Heap snapshot shows no retained leak, but memory churns Garbage is created and collected, not retained Sample allocations including objects discarded by GC Reveals churn that snapshots cannot see
Minor GC events fire every few hundred milliseconds New space fills rapidly from one call site Sort Heavy (Bottom Up) by Self Size Pinpoints the line creating the young-generation pressure

Root Cause: Why Sampling Finds Allocators That Snapshots Miss

A heap snapshot answers the question “what is alive right now, and who keeps it alive?” It says nothing about objects that were allocated and then collected a moment later. That blind spot matters, because a large share of real-world memory pressure is churn: a render loop that builds a fresh array of 5,000 objects on every frame, a JSON transform that clones a payload three times before discarding it, or a string builder that concatenates in a loop. None of those leak, so a snapshot comparison stays clean, yet each one forces the young generation to fill and triggers frequent scavenges that steal main-thread time.

The Allocation sampling profile is built on V8’s sampling heap profiler. Instead of instrumenting every allocation, V8 picks allocations at random intervals averaging about 32 KB of allocated bytes, and records the full JavaScript stack at each sampled point. Because the interval is measured in bytes rather than in calls, large allocations are proportionally more likely to be sampled, so the aggregated profile approximates where the bytes came from with very little overhead. By default the profile reports only objects still alive when you stop recording; enabling Include objects discarded by major GC and Include objects discarded by minor GC in the Memory panel turns it into a churn profiler, which is usually what you want when chasing GC pressure rather than a leak.

The trade-off is precision. A function that allocates a single 200-byte object once will rarely appear in the profile, and exact byte counts are statistical estimates, not measurements. That is fine: the goal is to rank allocators, and the ranking is stable across repeated recordings. When you need exact per-object lifetimes and the ability to click an allocation and see its retainers, move to the allocation timeline instead; sampling is for finding the hot spot, the timeline is for dissecting it.

How byte-interval sampling builds the allocation profile A horizontal stream of allocations of different sizes is shown. Sample markers fall roughly every 32 kilobytes of allocated bytes, so large allocations are caught more often. Each sampled allocation records its call stack, and the stacks are aggregated into per-function totals shown on the right. Allocation stream (left to right in time) sample sample sample ~32 KB of allocated bytes between samples big (yellow) allocations are hit more often than tiny ones Aggregated by stack buildRows() cloneDeep() formatCell() (other) bar = estimated Total Size

Step-by-Step Fix

  1. Open the profiler in a clean context. Launch an incognito window (extensions disabled) and open DevTools → Memory, then select the Allocation sampling radio button. Tick Include objects discarded by major GC and Include objects discarded by minor GC if you are hunting churn rather than retention. Verification: the Start button label reads “Start” and the JS heap size shown at the bottom of the panel is stable while idle.
  2. Record exactly one user flow. Click Start, perform the flow you suspect (open a report, scroll a table, switch tabs five times), then click Stop. Keep recordings between 10 and 60 seconds so the profile is dominated by the flow rather than idle timers. Verification: a new entry appears under SAMPLING PROFILES in the left sidebar.
  3. Switch to Heavy (Bottom Up) and sort by Self Size. Use the view dropdown at the top of the profile to choose Heavy (Bottom Up), then click the Self Size column header. The top rows are the functions whose own bodies allocated the most bytes. Verification: the top one to three rows usually account for more than half of the total; if the list is flat, record a longer flow.
  4. Walk up to the caller that controls the loop. Expand the top row to see its callers. The function that allocates is often a utility (map, JSON.parse, a formatter); the function you should change is the caller that invokes it thousands of times. Verification: you can name the component or module responsible and the approximate call count per interaction.
  5. Confirm with Tree (Top Down) and Chart. Switch to Tree (Top Down) to see Total Size per entry point, and to Chart to see when in the flow the allocation bursts happen. Verification: the burst in the chart lines up with the user action you suspect, for example the moment a table re-renders.
  6. Fix, then re-record the same flow. Apply the change (reuse a buffer, memoise a derived array, stream instead of clone) and record the identical flow again. Verification: the offending function’s Total Size drops by at least 50%, and the Performance panel shows fewer Minor GC events during the same flow.
Three views of one sampling profile Three panels compare the views. Heavy bottom up lists functions by self size and answers which function allocates. Tree top down lists entry points by total size and answers which user action drives allocation. Chart plots allocation over time and answers when the bursts happen. Heavy (Bottom Up) sorted by Self Size Which function body allocates the bytes? start here to find the hot allocator Tree (Top Down) sorted by Total Size Which entry point or event drives it? use to find the caller you can actually change Chart allocation over time When in the flow do the bursts land? match bursts to clicks, renders and fetches

Command and Code Reference

Use case: reproduce the profile headlessly with the Chrome DevTools Protocol. This Puppeteer script starts the same sampling profiler the Memory panel uses, drives a flow, and prints the top allocating functions — useful for attaching evidence to a bug report.

// sample-allocations.mjs — run with: node sample-allocations.mjs
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
const cdp = await page.createCDPSession();

await page.goto('http://localhost:5173/reports', { waitUntil: 'networkidle0' });
await cdp.send('HeapProfiler.enable');

// 32768 bytes matches the DevTools default sampling interval;
// the two flags keep objects that GC already reclaimed (churn)
await cdp.send('HeapProfiler.startSampling', {
  samplingInterval: 32768,
  includeObjectsCollectedByMajorGC: true,
  includeObjectsCollectedByMinorGC: true,
});

// Drive the suspect flow several times so the signal dominates noise
for (let i = 0; i < 5; i++) {
  await page.click('#refresh-report');
  await page.waitForSelector('.report-table[data-ready="true"]');
}

const { profile } = await cdp.send('HeapProfiler.stopSampling');

// Flatten the call tree: sum selfSize per function name + url:line
const totals = new Map();
(function walk(node) {
  const f = node.callFrame;
  const key = `${f.functionName || '(anonymous)'} ${f.url.split('/').pop()}:${f.lineNumber + 1}`;
  totals.set(key, (totals.get(key) || 0) + node.selfSize);
  node.children.forEach(walk);
})(profile.head);

// Print the ten heaviest allocators in KB
[...totals.entries()]
  .sort((a, b) => b[1] - a[1])
  .slice(0, 10)
  .forEach(([fn, bytes]) => console.log(`${(bytes / 1024).toFixed(0).padStart(8)} KB  ${fn}`));

await browser.close();

Use case: the typical fix for a top allocator that rebuilds derived data on every render. Memoising on the input reference removes the per-render allocation entirely when the input has not changed.

// Before: builds a new 5,000-element array of new objects on every call
function buildRows(records) {
  return records.map((r) => ({ id: r.id, label: `${r.first} ${r.last}` }));
}

// After: reuse the previous result while the input array is the same object
let lastInput = null;
let lastOutput = null;
function buildRowsMemo(records) {
  if (records === lastInput) return lastOutput; // zero allocation on repeat
  lastInput = records;
  lastOutput = records.map((r) => ({ id: r.id, label: `${r.first} ${r.last}` }));
  return lastOutput;
}
Sampled allocation per refresh, before and after memoising Grouped bars compare estimated kilobytes allocated per report refresh for buildRows, cloneDeep and formatCell before and after the fix. buildRows falls from about 2,400 kilobytes to about 60, cloneDeep from 1,500 to 1,500 unchanged, and formatCell from 700 to 700, showing the fix targeted only the top allocator. KB allocated per refresh (sampled estimate) buildRows() 2,400 KB 60 KB after memo cloneDeep() 1,500 KB unchanged formatCell() 700 KB unchanged before after

Verification and Regression Prevention

Treat the sampling profile as a before/after instrument, not a one-off. Record the same scripted flow three times before the fix and three times after, and compare the Total Size of the function you changed; sampling noise is typically within ±15%, so a real fix shows up as a drop well beyond that band. Cross-check in the Performance panel with the Memory checkbox enabled: the JS heap sawtooth during the flow should have visibly fewer, shallower teeth, and the count of Minor GC entries in the Bottom-Up tab should fall.

To stop the regression returning, keep the Puppeteer script above in the repository and run it in CI against a production build. Store the top-ten output as a JSON artifact and fail the job when the function you fixed re-enters the top three, or when total sampled allocation for the flow grows by more than 25% against the stored baseline. The same approach scales into a full heap size budget in your CI pipeline, where the sampling run explains why a budget was exceeded rather than only that it was.

Frequently Asked Questions

How is Allocation sampling different from Allocations on timeline?

Allocations on timeline instruments every allocation and keeps a record of each surviving object, which lets you click a blue bar and inspect the exact objects and their retainers — at a significant CPU and memory cost. Allocation sampling records a statistical sample of stacks, costs very little, and tells you which functions allocate the most bytes, but cannot show individual objects.

Why does my function not appear even though I know it allocates?

Sampling is proportional to bytes. A function that allocates a handful of small objects contributes too few bytes to be sampled reliably. Either run the flow many more times to accumulate samples, or lower the interval with the CDP samplingInterval parameter (for example 4096 bytes) at the cost of more overhead.

Should I enable the “discarded by GC” checkboxes?

Enable them when you are chasing garbage churn, GC pauses or jank, because the costly objects there are the ones that were already collected. Leave them off when you are chasing a leak, so the profile only shows objects that are still alive and the ranking points at retained memory.