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.
Step-by-Step Fix
- 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.
- 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.
- 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.
- 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. - 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.
- 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.
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;
}
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.
Related
- Mastering the Chrome DevTools Memory Tab — the parent topic covering all three Memory panel profiling types
- Heap Snapshot vs Allocation Timeline: When to Use Which — choosing between retention and allocation tooling
- Allocation Timeline vs Allocation Sampling Profiler — a side-by-side comparison of the two allocation tools
- Browser DevTools & Performance Profiling Workflows — the section overview