Recording Allocation Stacks to Find the Allocating Line
You know which objects are leaking and even what retains them, but the constructor is generic — thousands of plain Object or Array instances — and you cannot tell which line of code created them. This guide from Using Allocation Timelines to Track Object Creation, in the Browser DevTools & Performance Profiling Workflows section, shows how to record allocation stack traces so every surviving object carries the call stack that allocated it.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Leaked objects are plain Object/Array with no useful name |
Literal objects have no constructor to identify them | Record the allocation timeline with stack traces enabled | Each object shows the function and line that created it |
| Retainer path is known but creation site is not | Retainers show who holds, not who allocated | Open the Allocation stack tab for the selected object | Points straight at the allocating statement |
| Many call sites create the same shape | Shared helper functions allocate for many callers | Use the Allocation view grouped by function | Ranks call sites by surviving bytes |
| Recording with stacks slows the page heavily | Capturing a stack per allocation is expensive | Keep recordings short and targeted | Usable recordings of 10–30 seconds |
| Stacks show only minified names | No source maps for the bundle | Record against a build with source maps | Readable function names and original line numbers |
Root Cause: Retainers Tell You Who Holds, Not Who Created
A heap snapshot and its Retainers pane answer the ownership question: what keeps this object alive. For a named class that is often enough, because the class name points to its creation site. For object literals, arrays, closures and strings — which make up most of a real application’s heap — it is not. { id, label, meta } created by a normaliser and { id, label, meta } created by a cache look identical, and both appear under Object in the Summary view.
The allocation timeline solves this by instrumenting allocation. With Allocations on timeline selected in the Memory panel, V8 tracks objects as they are created and records which of them are still alive at the end of the recording, drawing blue bars for surviving allocations and grey for collected ones, as covered in reading allocation timelines to identify memory leaks. When you also tick Record stack traces of allocations, V8 captures the JavaScript call stack at each tracked allocation. DevTools then adds an Allocation stack tab next to Retainers in the bottom pane, and a fourth perspective — Allocation — in the view dropdown that groups surviving objects by the function that allocated them.
The two views complement each other. The Allocation stack tab is object-first: select a leaked object, see the stack that created it. The Allocation view is function-first: list functions by the bytes they allocated that are still alive, and drill into each to see its objects. Combined with the Retainers pane, you get the full story of a leak — who created it, and who is keeping it. The price is overhead: capturing stacks for every allocation can slow a busy page by several times, which is why this is a targeted tool, used after cheaper methods like the allocation sampling profiler have narrowed down the flow.
Step-by-Step Fix
- Narrow the flow first. Use a sampling profile or snapshot comparison to identify the user flow that leaks and, if possible, the constructor group. Verification: you can reproduce the growth with a flow that takes under 30 seconds.
- Enable stack capture. In DevTools → Memory, choose Allocations on timeline and tick Record stack traces of allocations (extra performance overhead). Make sure source maps are available so names are readable. Verification: the checkbox stays ticked when you click Start.
- Record only the flow. Click Start, perform the flow two or three times, and click Stop. Verification: blue bars appear at the times you performed the flow; grey bars are collected allocations.
- Select a surviving allocation. Drag across a blue bar in the timeline to filter the list to objects allocated in that interval and still alive. Expand the suspicious constructor and click an instance. Verification: the bottom pane shows both Retainers and Allocation stack tabs.
- Read the Allocation stack. Switch to Allocation stack. The top frame is the function that performed the allocation; below it are its callers. Click the source link to open the line in Sources. Verification: you are looking at the exact statement that created the object.
- Rank creation sites with the Allocation view. Switch the perspective dropdown from Summary to Allocation. It lists functions by live size and count of objects they allocated. Verification: the top function matches the stack you found, or reveals a second creation site you had not noticed.
Command and Code Reference
Use case: the leak these tools uncovered. Every websocket message is normalised into a fresh object and appended to an activity log that is never trimmed, so the log retains every normalised event forever.
// events.js — normaliseEvent creates a literal per message (shows as "Object")
export function normaliseEvent(raw) {
return {
id: raw.id,
type: raw.t,
at: new Date(raw.ts), // a Date per event adds to the retained size
payload: raw.p, // keeps the whole parsed payload alive
};
}
// activity-log.js — the retainer the Retainers tab pointed to
const entries = [];
export function record(event) {
entries.push(event); // unbounded: grows by one object per message
}
// Fix: bound the log and store only what the UI shows
const MAX = 500;
export function recordBounded(event) {
entries.push({ id: event.id, type: event.type, at: event.at.getTime() }); // no payload, no Date
if (entries.length > MAX) entries.splice(0, entries.length - MAX); // trim oldest
}
Use case: capture allocation stacks automatically via the DevTools Protocol. The same instrumentation is available to scripts, which is useful when the leak only reproduces under automation.
// track-allocations.mjs — node track-allocations.mjs (writes a timeline snapshot)
import puppeteer from 'puppeteer';
import { writeFileSync } from 'node:fs';
const browser = await puppeteer.launch();
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await page.goto('http://localhost:5173/activity', { waitUntil: 'networkidle0' });
await cdp.send('HeapProfiler.enable');
// trackAllocations mirrors "Allocations on timeline"; stack capture is
// enabled for the session by the same instrumentation
await cdp.send('HeapProfiler.startTrackingHeapObjects', { trackAllocations: true });
await page.evaluate(() => window.__simulateMessages?.(2000)); // drive the flow
const chunks = [];
cdp.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }) => chunks.push(chunk));
await cdp.send('HeapProfiler.stopTrackingHeapObjects', { reportProgress: false });
writeFileSync('activity.heaptimeline', chunks.join('')); // load via Memory → Load
await browser.close();
Verification and Regression Prevention
Re-record the same flow with the same number of repetitions after fixing. The blue bars corresponding to your flow should turn grey (collected) or shrink to the bounded size, the Allocation perspective should no longer rank the function you fixed at the top, and the retained size of the holder you trimmed should stay flat as you repeat the flow. Take care to compare recordings made with the same settings, because stack capture itself changes allocation timing and heap size slightly.
To prevent regressions, keep the two facts you learned — the creation site and the holder — in a test. A unit test can call record() ten thousand times and assert that the log’s length never exceeds its bound; an integration test using the three-snapshot approach can assert that the count of objects allocated by the flow and still alive afterwards stays under a small threshold. Name the functions involved explicitly so that future allocation stacks, and production attributions, remain readable.
Edge Cases and Gotchas
Inlined and optimised functions
When V8 inlines a small function into its caller, the allocation stack still shows the logical call chain in most cases, but occasionally a frame appears under its caller’s name. If the top frame looks wrong, check the caller’s source for an inlined helper — tiny constructors and factory functions are the usual suspects.
Allocations made by the engine on your behalf
Some objects are created by the runtime rather than by a line of your code: the result array of String.prototype.split, the entries array from Object.entries, the object returned by JSON.parse. Their stack’s top frame is the built-in’s caller, which is the line you want anyway, but the constructor is generic. Read one frame down to find the function that called the built-in in a loop.
Objects allocated before recording started
The timeline only tracks allocations made while it is recording. An object created at page load that later grows — an array that gains elements — shows its new backing stores in the timeline, not the original object. If the Allocation stack points into Array.prototype.push, the leak is the growing array; follow Retainers to find which array.
Stack capture changes timing
Because capturing stacks slows allocation, races between timers and user actions can play out differently while recording. If a leak depends on timing — a response arriving after a component unmounted — reproduce it deliberately with network throttling rather than relying on natural timing.
Frequently Asked Questions
Why is the Allocation stack tab missing?
It appears only for allocation timeline recordings made with Record stack traces of allocations ticked. Heap snapshots and recordings without the checkbox do not contain stacks. Enable it and record again; there is no way to add stacks to an existing recording.
How much does stack capture slow the page?
It depends on the allocation rate. Pages that allocate lightly barely notice; allocation-heavy flows can run several times slower while recording. Keep recordings short, avoid combining them with performance measurements, and use the sampling profiler when you only need to rank allocators.
Can I get allocation stacks for Node.js?
Yes. Connect DevTools to a Node.js process started with --inspect, open the Memory panel, and use Allocations on timeline with stack capture exactly as in the browser. For lower overhead on servers, the sampling heap profiler via --heap-prof gives statistical allocation stacks.
Related
- Using Allocation Timelines to Track Object Creation — the parent topic
- Allocation Timeline vs Allocation Sampling Profiler — choosing the right allocation tool
- Reading the Retainers Panel in a Heap Snapshot — the ownership half of the story
- Browser DevTools & Performance Profiling Workflows — the section overview