open atlas
↑ Back to track
JavaScript Engine internals JSE · 06 · 04

Write barriers: the price of incremental and generational GC

Every pointer store into a heap object runs a write barrier. It serves two masters: a generational barrier records old→young pointers into a remembered set so minor GC need not scan old space

JSE Senior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

obj.next = node looks like a single store — write a pointer, done. But the GC has two open questions that store could invalidate. If obj lives in old space and node is young, a minor GC that scans only young space would miss this brand-new reference and free a live object. If a concurrent mark is in flight and obj is already black, this store could sneak a white object past the marker. So that one assignment is not one instruction — V8 quietly wraps it in a check called the write barrier, and it runs on nearly every pointer store in your program.

Why a store is not free

The previous two lessons left two correctness debts, both created by the same thing — a pointer mutated between or during collections:

  1. Generational debt. A minor GC scans only young space (plus roots), because scanning gigabytes of old space on every Scavenge would destroy the whole point of generational GC. But old objects can hold references to young objects (oldArray.push(youngObj)). If V8 ignored those, it would free a young object that old space still references.
  2. Marking debt. Concurrent/incremental marking lets JS mutate the graph mid-trace. A store can make a black (scanned) object point to a white (unreached) object, violating the tri-color invariant, so the marker finishes without visiting a live object and the sweep collects it.

A write barrier is a small piece of code the compiler emits around (nearly) every store of a pointer into a heap object. It runs at obj.field = ptr time and does whatever bookkeeping is needed to keep both questions answered. One barrier, two jobs.

Job 1: the generational barrier and the remembered set

To avoid scanning old space on a minor GC, V8 maintains a remembered set (implemented via a store buffer that is later processed into per-page slot sets): a record of every slot in old space that holds a pointer into young space. When you write oldObj.field = youngObj, the generational write barrier notices the store crosses old→young and records that slot.

At the next Scavenge, V8 treats the remembered set as extra roots: it scans those recorded slots to find young objects kept alive by old-space references, without walking old space at all. After the Scavenge updates the moved young objects’ addresses, the remembered set is what lets the old-space pointers be fixed up.

const cache = [];          // promoted to old space over time
function handle(req) {
  const entry = { req };   // young
  cache.push(entry);       // OLD array now references YOUNG entry
  //                          ^ generational write barrier records this slot
}

The barrier only fires for the old→young direction. Young→young and young→old stores are uninteresting to a minor GC (young is fully scanned anyway; old→? is the major GC’s problem), so V8 elides the barrier in those cases.

Job 2: the incremental/marking barrier (Dijkstra-style)

During concurrent marking, the same store hook protects the tri-color invariant. V8 uses a Dijkstra-style insertion barrier: when the mutator stores a pointer to a white object into any object during marking, the barrier greys the white target (pushes it onto the marking worklist). This guarantees the marker will eventually scan it, so it cannot be left white and wrongly collected. (The classic alternative is a Yuasa snapshot-at-the-beginning barrier, which greys the overwritten old referent instead; V8’s design uses Dijkstra-style insertion for its concurrent marker. Either way the goal is identical: no live object escapes the trace.)

Because both jobs trigger on the same event — a pointer store into a heap slot — V8 fuses them into a single barrier path that checks the relevant conditions (is marking active? does this store cross old→young?) and does the minimum work.

Why Smi stores skip the barrier

The barrier exists only to track pointers between heap objects. A Smi (small integer — a 31-bit integer on 64-bit V8) is not a pointer: it is encoded directly inside the tagged value, with its low tag bit zero, so it cannot reference a heap object at all. Storing a Smi into a field — obj.count = 42 — creates no inter-object reference, so V8 emits no write barrier for it. The same is true for storing other immediates. This is one more reason the Smi/HeapNumber distinction from unit 02 matters for performance: integer-heavy field writes are barrier-free, while writes that store object or boxed-double pointers pay the barrier check.

The cost, and where it bites

Each barrier is cheap individually — a few instructions: load the target’s page metadata or check a marking flag, branch, and in the slow case push to a worklist or store buffer. Call it a handful of cycles per pointer store. That is invisible in ordinary code. It becomes measurable in allocation- and mutation-heavy hot paths: tight loops that build large object graphs, repeatedly repoint fields, or push millions of object references into long-lived containers. There, barrier overhead is real, and it is one of the hidden costs of “just push objects into an array” versus working with typed arrays or Smi-encoded data.

Write barrier facts
Runs on
~every heap pointer store
Generational job
record old→young slot
Marking job (V8)
Dijkstra insertion: grey target
Remembered set via
store buffer → slot sets
Smi / immediate store
no barrier
Typical cost
a few cycles / store
Quiz

Why does a minor GC (Scavenge) need the remembered set at all?

Quiz

`obj.count = 42` versus `obj.child = someObject`. Which store(s) emit a write barrier, and why?

Order the steps

Order what happens when JS executes `oldObj.field = youngWhiteObj` during an active concurrent mark.

  1. 1 The store reaches the inlined write barrier instead of writing directly
  2. 2 Barrier checks: marking is active, so grey the white target onto the marking worklist
  3. 3 Barrier checks: store crosses old→young, so record the slot in the remembered set
  4. 4 The actual pointer is written into the field
  5. 5 Later, the marker scans the now-grey target so it is not wrongly collected
Why this works

Why insert a barrier on writes rather than reads? Because writes are far rarer than reads in typical code and are the only operations that can change reachability or break the tri-color invariant — reading a pointer cannot create a new edge in the object graph. A read barrier (used by some collectors, e.g. for relocation in ZGC/Shenandoah) is more expensive precisely because reads dominate. V8 chooses write barriers as the cheaper place to pay, which is why the cost concentrates on mutation-heavy code.

Recall before you leave
  1. 01
    What two correctness problems does the write barrier solve, and how?
  2. 02
    Why do Smi stores skip the write barrier, and why does that matter for performance?
  3. 03
    Where does write-barrier overhead actually become measurable, and why insert barriers on writes rather than reads?
Recap

A write barrier is code V8 emits around nearly every pointer store into a heap object, and it pays off two correctness debts from the previous lessons. Generationally, because a minor GC scans only young space, old→young pointers would be invisible to it; the barrier records each such store’s slot into a remembered set (backed by a store buffer), and the Scavenger uses those slots as extra roots to find young survivors without scanning old space. For marking, because concurrent/incremental marking lets JS mutate the graph mid-trace, a store could make a black object reference a white one and break the tri-color invariant; V8’s Dijkstra-style insertion barrier greys the white target onto the worklist so the marker still visits it and never collects a live object. The two jobs share one trigger — a pointer store — so V8 fuses them. Storing a Smi or other immediate creates no inter-object pointer, so it skips the barrier, which is why barrier overhead concentrates in allocation- and mutation-heavy hot paths and is a real, if small, hidden cost of object-pointer churn. Now when you spot an unexpected barrier cost in a flamegraph — tight loops pushing objects into a long-lived array — you know why: switch to typed arrays or Smi-encoded data and the barrier disappears.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done
Connected lessons
appears again in208

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.