Write Barriers and the Remembered Set in V8

Scavenges that should take a millisecond take ten, and the difference appears only when a large, long-lived array is being filled with freshly created objects. The explanation lies in two pieces of V8 machinery most developers never see: write barriers and the remembered set. This guide from How Mark-and-Sweep Garbage Collection Works, part of JavaScript Memory Fundamentals & Runtime Mechanics, explains how they work, why they are necessary, and which data patterns make them costly.

Symptom Root Cause Immediate Action Measurable Impact
Scavenge times grow while a big old array is being filled Many old-to-new pointers recorded in the remembered set Build new objects in short-lived structures; attach to old ones in batches Scavenge time returns to baseline
Hot loop that stores objects into old structures is slower than expected Write barrier executed on every pointer store Store primitives or reuse objects; avoid storing fresh objects into old containers per iteration Lower per-store overhead
Promotion spikes after bulk inserts into caches Young objects referenced from old space survive scavenges Insert fewer, larger batches; bound caches Fewer premature promotions
Throughput drops during major GC marking Marking barrier active on every store Reduce allocation and mutation during heavy phases Shorter marking windows
Typed-array numeric code shows no GC overhead Numbers in typed arrays are not pointers; no barrier needed Prefer typed arrays for numeric bulk data Barrier cost eliminated

Root Cause: Generational GC Needs to Know About Old-to-New Pointers

V8’s young-generation collector, the scavenger, collects only new space. It starts from the roots, finds every reachable young object, and copies the survivors. The trick that makes scavenging fast is that it does not scan the old generation, which may be hundreds of megabytes. But objects in old space can point to young objects — an old array that just had a new object pushed into it, an old cache that just stored a fresh value. If the scavenger ignored those pointers, it would free young objects that are still reachable.

The solution is the remembered set: a per-page record of slots in old space that contain pointers into new space. The scavenger treats those slots as extra roots, so it needs to visit only the recorded slots rather than the whole old generation. Keeping the remembered set accurate is the job of the write barrier — a few instructions that V8 emits after every store of a heap pointer into an object field or array element. The barrier checks whether the target object is in old space and the stored value is in new space; if so, it records the slot. Most stores hit a fast path and cost almost nothing, but a store that creates an old-to-new pointer does real work.

Write barriers serve a second purpose during major GC. While incremental and concurrent marking is running, JavaScript keeps mutating the heap. If code stores a pointer to an unmarked object into an object the marker has already scanned, the marker could miss it. The marking barrier prevents that by marking or recording the newly referenced object. So during marking, every pointer store is slightly more expensive.

The practical consequences follow directly. Code that stores fresh objects into long-lived containers creates many old-to-new pointers: the remembered set grows, scavenges must process more slots, and those young objects survive because they are reachable — getting promoted into old space, which increases future major GC work. Pointer-free data avoids all of it: numbers in typed arrays and Smis stored inline are not heap pointers, so no barrier work and no remembered-set entries are needed. That is one more reason typed arrays reduce GC cost, alongside their compactness discussed in reducing garbage churn.

How the remembered set lets scavenges skip old space An old-space cache array contains three slots pointing to young objects in new space. When each pointer was stored, the write barrier recorded the slot in the remembered set of that old page. During a scavenge, the collector starts from the real roots plus the recorded slots, so it finds the three young objects without scanning the rest of old space. Those objects survive and are eventually promoted. Old space (not scanned) cache[] slots 7, 8, 9 → young remembered set (per page) slot 7 · slot 8 · slot 9 write barrier New space (scavenged) obj A obj B obj C scavenger roots = stack + globals + remembered slots A, B, C survive → promoted later

Step-by-Step Fix

  1. Confirm scavenges are the slow part. Run the Node.js workload with --trace-gc and compare Scavenge durations during the suspect phase with an idle baseline. Verification: scavenge times grow specifically while the suspect code runs.
  2. Look for fresh objects stored into long-lived containers. Find code that, in a loop or per event, creates objects and stores them into module-level arrays, caches, maps or class fields of long-lived instances. Verification: you can point to the store that creates old-to-new pointers.
  3. Batch the attachment. Build new objects in a local (young) array and attach them to the old structure once, or attach them after they are complete rather than one by one. Verification: the number of individual old-to-new stores per operation falls.
  4. Store pointer-free data where possible. For numeric data, write into preallocated typed arrays instead of storing boxed objects; for flags and small integers, store Smis rather than wrapper objects. Verification: allocation profiles show fewer objects and scavenge times drop.
  5. Bound long-lived containers. Caches that receive a steady stream of fresh objects keep them all alive and promote them. Apply a size bound or TTL. Verification: promotion rate (old-space growth per minute) falls.
  6. Re-trace under the same load. Repeat step 1. Verification: scavenge durations during the suspect phase are close to the idle baseline.
Scavenge cost by loading pattern Loading one hundred thousand records by pushing each fresh object into a long-lived array one at a time averages 9.8 milliseconds per scavenge. Building them in a local array and attaching the batch once averages 3.1 milliseconds. Writing numeric fields into preallocated typed arrays averages 0.9 milliseconds. Average scavenge time while loading 100k records push each into old array 9.8 ms build locally, attach once 3.1 ms typed-array columns 0.9 ms illustrative figures from one workload; measure your own with --trace-gc

Command and Code Reference

Use case: the costly pattern and its batched alternative.

// Long-lived store: lives in old space after a few GCs
const store = { records: [] };

// Costly: each iteration creates a young object and stores it into an old array.
// Every push runs the write barrier and adds a remembered-set entry.
function loadOneByOne(rows) {
  for (const r of rows) store.records.push({ id: r.id, total: r.total });
}

// Cheaper: build in a local (young) array, then attach once.
// Only the final assignment creates an old-to-new pointer.
function loadBatched(rows) {
  const batch = new Array(rows.length);
  for (let i = 0; i < rows.length; i++) batch[i] = { id: rows[i].id, total: rows[i].total };
  store.records = store.records.concat(batch); // one attach per batch
}

Use case: pointer-free storage for numeric fields. No heap pointers means no barrier work and nothing for the scavenger to trace.

// Columnar numeric store: ids and totals as typed arrays
class NumericStore {
  constructor(capacity) {
    this.ids = new Int32Array(capacity);
    this.totals = new Float64Array(capacity);
    this.length = 0;
  }
  add(id, total) {
    this.ids[this.length] = id;       // raw 32-bit integer write: no write barrier
    this.totals[this.length] = total; // raw 64-bit float write: no write barrier
    this.length++;
  }
}

Verification and Regression Prevention

A fix is verified when scavenge durations during the loading phase return to near their idle baseline, promotion (old-space growth per minute under steady load) falls, and throughput of the loading code improves. Use the same input size and the same --trace-gc capture for before and after; scavenge times vary with the number of surviving objects, so an unfair comparison is easy to make.

Protect the improvement with a benchmark in CI that loads a fixed data set and records the GC time reported by perf_hooks GC entries, failing when it exceeds a budget. Document in code comments that long-lived stores should be loaded in batches or use typed columns, so that the next contributor does not reintroduce the per-item push. The same batching idea helps in the browser, where scavenge pauses show up in performance traces.

Scavenge duration during the loading phase With the same input size and the same --trace-gc capture, scavenge durations during loading rose before the fix as more old objects pointed at young ones and the remembered set grew. After the fix they stay close to the idle baseline and old-space growth per minute falls. scavenge ms loading phase (same input, --trace-gc) before: remembered set grows after: near idle baseline

Edge Cases and Gotchas

Barriers are usually cheap

Most stores hit the barrier’s fast path — the target is young, or the value is a Smi — and cost a couple of instructions. Only change code where profiling shows scavenges or marking are a real cost; restructuring every store is not worth it.

concat and spread copy

Replacing an array with concat allocates a new array of the combined size. For very large stores, prefer chunked storage (an array of fixed-size batch arrays) so each attach is small and no giant copy happens.

Maps and Sets use internal tables

Map.prototype.set stores into the map’s backing table, which for a long-lived map is in old space; each fresh value creates an old-to-new pointer just like an array push. Batch inserts or bound the map in the same way.

Promotion is the lasting cost

Even when the barrier itself is cheap, the young objects it records are reachable and therefore survive. Two survivals promote them into old space, where they are only reclaimed by major GC. Unbounded long-lived stores turn young garbage into old retention.

Frequently Asked Questions

What is a write barrier in JavaScript engines?

A small piece of code the engine runs after storing a heap pointer into an object. It records information the garbage collector needs: old-to-new pointers for the young-generation collector and, during concurrent marking, newly referenced objects the marker must not miss.

What is the remembered set?

A record, kept per memory page, of slots in old space that point into new space. The scavenger treats those slots as additional roots, so it can collect the young generation without scanning the entire old generation.

Do typed arrays avoid write barriers?

Yes, for their element stores. Typed array elements are raw numbers in an external buffer, not heap pointers, so writing them needs no barrier and creates nothing for the garbage collector to trace.

Can I see the remembered set’s size?

Not directly from JavaScript. Its effect shows up in scavenge durations in --trace-gc output (and more detailed breakdowns with --trace-gc-verbose). Rising scavenge times while old containers receive many fresh objects are the practical signal.