Reducing Garbage Churn Found in Allocation Timelines
Your allocation timeline shows tall bars that turn almost entirely grey — nothing is leaking, yet the page allocates megabytes per second and the Performance panel is peppered with Minor GC slices. This guide from Using Allocation Timelines to Track Object Creation, part of Browser DevTools & Performance Profiling Workflows, covers the code patterns that create churn and how to remove them without making the code unreadable.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Tall grey bars during scroll, drag or animation | New arrays/objects created per event or frame | Reuse buffers and mutate in place in the hot path | Allocation rate drops by 70–95% in the handler |
| Minor GC every few hundred ms during interaction | Young generation fills quickly from temporaries | Remove spreads, map/filter chains and closures inside loops |
Minor GC frequency falls proportionally |
Numeric data as arrays of objects {x, y} |
Each point is a separate heap object | Store in Float64Array columns |
Memory per point falls from ~40 bytes to 16 |
| String building in loops | Intermediate strings from += and template literals |
Collect parts and join once, or write to a buffer |
Fewer temporaries and one final allocation |
| JSON round-trips for cloning | JSON.parse(JSON.stringify(x)) creates two full copies |
Use structuredClone only where needed, or avoid cloning |
Halves or eliminates copy allocation |
Root Cause: Short-Lived Objects Are Cheap Individually, Expensive in Bulk
V8’s young generation is designed for objects that die young. Allocation is a pointer bump, and a scavenge only touches surviving objects, so most temporary objects cost very little. The trouble is volume. A drag handler that runs 120 times per second and allocates 50 KB per call produces 6 MB/s of garbage; with a young generation of a few megabytes, that means a scavenge every half second or faster, each one stealing main-thread time and occasionally promoting objects that happened to be alive at the wrong moment into old space, where they cost more to collect. Scavenger vs major GC explains that trade-off in detail.
In the allocation timeline, churn looks unmistakable: bars appear continuously while the interaction runs and turn grey after collection, and the Summary list filtered to that interval shows large counts of Object, Array, (closure) and (concatenated string) with little surviving. The allocation stack traces or a sampling profile with GC’d objects included then name the functions responsible.
The patterns behind churn are remarkably consistent. Functional pipelines such as data.filter(...).map(...).slice(...) allocate an intermediate array at every step. Object spread ({...row, selected: true}) copies every property into a fresh object. Inline arrow functions passed to event registrations or array methods create a new closure each time the surrounding code runs. Points, vectors and colours stored as small objects multiply per element. JSON.parse(JSON.stringify(obj)) for cloning creates a full string and a full object graph. None of these are wrong in code that runs occasionally; they become a problem only in hot paths — per frame, per pointer event, per row in a large list. That is why the fix is always targeted: find the hot path with the profiler, change only that code, and measure again.
Step-by-Step Fix
- Confirm it is churn, not a leak. Record DevTools → Memory → Allocations on timeline during the interaction. Verification: bars are mostly grey after you stop, and a heap snapshot before and after the interaction shows no significant growth.
- Find the hot allocators. Record an Allocation sampling profile of the same interaction with both “discarded by GC” boxes ticked, and sort Heavy (Bottom Up) by Self Size. Verification: one or two functions in the interaction’s handler account for most bytes.
- Identify the churn pattern in each function. Look for spreads, chained array methods, inline closures, per-element objects, string building and JSON cloning inside loops or per-event code. Verification: you have listed which lines allocate per call.
- Apply the matching change. Mutate preallocated objects instead of spreading; fuse
filter/mapinto one loop; hoist closures out of hot code; switch numeric arrays of objects to typed-array columns; build strings with arrays andjoin. Verification: the function allocates little or nothing per call in steady state. - Re-measure the same interaction. Record the timeline and a Performance trace with Memory ticked. Verification: bar heights drop sharply and the number of Minor GC slices during the interaction falls.
- Check you did not create a leak. Reused buffers and pools are long-lived by design. Take a heap snapshot after closing the view that used them. Verification: pools are released with their owner, or are small and bounded.
Command and Code Reference
Use case: a pointer-move handler that churns, rewritten to allocate nothing per event. The rewrite keeps the same logic while mutating preallocated state.
// Before: every pointermove creates arrays, objects and closures
canvas.addEventListener('pointermove', (e) => {
const pts = points
.filter((p) => p.visible) // new array
.map((p) => ({ ...p, dx: p.x - e.offsetX, dy: p.y - e.offsetY })); // new objects
highlight(pts.find((p) => Math.hypot(p.dx, p.dy) < 8)); // closure per call
});
// After: typed-array columns and a single loop, no per-event allocation
const xs = new Float64Array(MAX_POINTS);
const ys = new Float64Array(MAX_POINTS);
const visible = new Uint8Array(MAX_POINTS);
let count = 0;
function onPointerMove(e) {
let hit = -1;
for (let i = 0; i < count; i++) {
if (!visible[i]) continue;
const dx = xs[i] - e.offsetX;
const dy = ys[i] - e.offsetY;
if (dx * dx + dy * dy < 64) { hit = i; break; } // squared distance, no Math.hypot
}
highlightIndex(hit);
}
canvas.addEventListener('pointermove', onPointerMove); // one closure, registered once
Use case: replace JSON cloning in a hot path. Cloning to “avoid mutation” often copies far more than needed; copy only what changes.
// Before: deep clone of the entire state on every keystroke
function updateDraft(state, text) {
const next = JSON.parse(JSON.stringify(state)); // full string + full graph
next.draft.text = text;
return next;
}
// After: structural sharing — copy only the objects on the changed path
function updateDraftShared(state, text) {
return { ...state, draft: { ...state.draft, text } }; // two small objects
}
Verification and Regression Prevention
Measure the interaction the same way before and after: allocation timeline bar height, sampled bytes for the handler, and the number of Minor GC slices in a Performance trace of equal length. A successful churn fix typically reduces allocated bytes in the handler by an order of magnitude and makes Minor GC slices during the interaction rare. Confirm on a throttled CPU, because devices where churn matters most are the ones with the slowest collectors.
Protect the hot paths with a micro-benchmark or a lab test that records sampled allocation for the interaction and fails when it exceeds a budget. Document which functions are “allocation-sensitive” in comments at their definitions, so future contributors do not reintroduce a spread or a map chain in a per-frame loop. Finally, keep pooling and reuse local to the component that needs them; global pools that outlive their users trade churn for retained memory, which is the problem you were not trying to create.
Edge Cases and Gotchas
Escape analysis can make allocations disappear
V8’s optimising compiler can eliminate some temporary objects entirely when it proves they never escape a function — a {x, y} created and destructured in the same hot function may cost nothing once optimised. That is why micro-benchmarks sometimes show no difference after a “fix”. Measure the real interaction on a real page rather than trusting a benchmark of the isolated function.
Framework re-renders are a hidden allocation source
In component frameworks, every re-render allocates new virtual DOM or template objects, new props objects and new inline callbacks. A handler that sets state 60 times per second causes 60 re-renders, each allocating. Throttling state updates to animation frames, or keeping fast-changing values outside reactive state, often reduces churn more than any micro-change inside the handler.
Typed arrays are not free to create
A Float64Array has a fixed setup cost and its backing store lives outside the young generation. Creating a new small typed array per event is worse than creating a small object. The benefit comes from allocating typed arrays once and reusing them; if you find new Float32Array(3) inside a loop, hoist it.
Strings are immutable
There is no in-place string mutation in JavaScript, so string-heavy hot paths always allocate. Minimise how many strings you build per event — format only what is displayed, cache formatted labels by value, and prefer textContent updates only when the displayed string actually changes.
Frequently Asked Questions
Is garbage churn a memory leak?
No. Churned objects are collected, so memory does not grow over time. The cost is CPU: frequent scavenges and occasional promotions interrupt the main thread. Churn causes jank and battery drain rather than crashes, and it is diagnosed with allocation tools rather than heap snapshot comparisons.
Should I avoid map, filter and spread everywhere?
No. They are clear and fast enough for code that runs occasionally. Remove them only in measured hot paths — per frame, per pointer event, per row of large lists — where the profiler shows they dominate allocation. Readability outside those paths is worth far more than a few kilobytes of garbage.
Do object pools always help?
Only when objects are created and discarded at high frequency and have a fixed shape. Pools add bookkeeping and make objects long-lived, which moves them to old space and can increase major GC work. Prefer mutating a small number of preallocated objects in the hot path; reach for a general pool only when profiling proves it pays off.
Related
- Using Allocation Timelines to Track Object Creation — the parent topic
- Spotting Garbage Collection Pauses in a Performance Trace — measuring the GC cost churn creates
- Map vs Object vs Array Memory Overhead in JavaScript — per-structure memory costs
- Browser DevTools & Performance Profiling Workflows — the section overview