Write barriers & remembered sets
Learn how JavaScript engines record old-to-young pointers so fast minor collections can keep young objects alive without scanning old memory.
- 01Spot the missing pointerExplain why an old object pointing to a young object must be recorded before a minor collection.
- 02Follow the collector's notesDistinguish remembered sets, store buffers, and card marking in small teaching models.
- 03Reason about write costExplain why optimized code checks stores and when an engine can safely skip that check.
The pointer a minor GC could miss
A minor collection cleans the young generation. It is fast because it does not open and scan every old object. Most fresh objects die quickly, so looking closely at young memory is usually enough work for one short collection.
1const cart = []; // long-lived, so imagine it is old2cart.push({ item: "tea" }); // fresh object, so imagine it is young3console.log(cart[0].item);Line 1 creates cart. The comment gives it the old-generation role for this model. Line 2 creates a fresh tea object in the young generation. Line 3 stores that young object inside the old cart and prints tea.
That assignment creates the problem. A young-only collector can see young roots and young objects, but it deliberately skips the old cart. If no direct young root points at tea, the collector needs another way to discover that the old cart still needs it.
A write barrier is small collector bookkeeping attached to a memory write. When an old object receives a young object, the barrier records enough information for a later minor collection to find that young object.
Imagine cleaning your phone's Downloads folder without opening every Photos album. If an album entry links to one download, deleting that file would break the album. When you add the link, put a sticky note on Downloads saying that one album entry uses it.
- In real life: Downloads is cleaned quickly
- In JavaScript: Young generation gets a minor collection
- In real life: Photos album stays unopened
- In JavaScript: Old generation is not fully scanned
- In real life: Album entry links to a download
- In JavaScript: Old object points to a young object
- In real life: A sticky note names that link
- In JavaScript: Remembered information records the write
Where the analogy stops: A phone app has human-designed folders. A collector uses machine metadata and follows pointers, not file meaning.
The sticky-note habit is the barrier. The note itself becomes remembered information. This lesson models that information plainly; it does not inspect your browser's real heap or promise one engine's exact data structure.
Old-to-new pointers
An old-to-new pointer is a reference held by an old object whose value is young. The direction matters. A young object pointing to another young object is already inside the area a minor collection visits. An old object pointing to an old object does not reveal any young object.
1const cart = { generation: "old" };2const tea = { generation: "young" };3console.log(cart.generation + "-to-" + tea.generation);Line 1 makes an old cart model object. Line 2 makes a young tea model object. Line 3 joins those labels and prints old-to-young. The labels are teaching data, not properties exposed by JavaScript engines.
Think about the order of events. The program writes a pointer first. The barrier runs beside that write and checks whether it crossed from old memory into young memory. If it did, the barrier leaves a note before a future minor collection needs it.
Replay instrumented teaching code. This is not a view into your browser's real remembered set.
script
2const tea = { generation: "young" };3const rememberedSet = [];4 5function writeField(owner, key, value) {6 owner[key] = value;7 if (owner.generation === "old" && value.generation === "young") {8 rememberedSet.push({ owner, key, value });9 }10}11 12writeField(cart, "latest", tea);13console.log(rememberedSet.length);The replay calls the real writeField model. Notice that the old cart is still not a minor root. The remembered slot is the bridge from a collection that mostly sees young memory to a value stored behind old memory.
Remembered sets
A remembered set is collector metadata about pointers a young collection must revisit. In this lesson's smallest model, every entry names an owner, one field key, and the young value that field currently holds.
1const rememberedSet = [];2 3function writeField(owner, key, value) {4 owner[key] = value;5 if (owner.generation === "old" && value.generation === "young") {6 rememberedSet.push({ owner, key, value });7 }8}9 10const cart = { generation: "old" };11const tea = { generation: "young" };12writeField(cart, "latest", tea);13console.log(rememberedSet.length);Line 1 starts the remembered set empty. Line 3 stores the field. Lines 5 and 6 test for an old owner and young value, then append a slot record. Line 12 writes tea into the old cart, and line 13 prints 1 because one remembered slot was added.
Real engines avoid storing JavaScript objects exactly this way. They may record addresses, object regions, or other compact metadata. The important contract stays the same: a later minor collection can find every relevant old-to-young edge without scanning all old space.
| Thing | What it describes | Why it matters |
|---|---|---|
| Young root | A direct root that points into young space. | Minor collection scans it every time. |
| Remembered slot | An old-to-young pointer recorded by a barrier. | Minor collection treats its young value like an extra root. |
| Store buffer | A short list of recent writes waiting to be processed. | The engine later turns entries into remembered information. |
| Dirty card | A coarse marker for a small old-memory region. | The collector rescans that region, not every old slot. |
Minor collection uses the notes
The remembered set is useful only when collection reads it. A minor collector starts with direct young roots, then treats young values found through remembered slots as extra roots. From there it follows young-to-young references in the usual way.
1const youngRoots = [];2const rememberedSlots = [{ owner: "cart", key: "latest", value: "tea" }];3 4function minorCollection(roots, slots) {5 const live = new Set(roots);6 for (const slot of slots) live.add(slot.value);7 return [...live].sort();8}9 10console.log(minorCollection(youngRoots, rememberedSlots).join(","));Line 1 says there are no direct young roots. Line 2 still supplies a remembered slot from cart to tea. Line 5 makes a live set, and line 6 adds each noted value. The final line prints tea, showing that the note alone kept tea in this model.
This is the central speed trade. The collector avoids every old object, but it must keep the remembered information correct. Missing one note would be a correctness bug: a live young object could look dead merely because its only path started in old memory.
Replay a minor-collection model. It treats remembered slots as extra young roots; it does not collect your browser heap.
script
2const tea = { generation: "young" };3const rememberedSet = [{ owner: cart, key: "latest", value: tea }];4 5function minorCollection(youngRoots, slots) {6 const live = new Set(youngRoots);7 for (const slot of slots) live.add(slot.value);8 return [...live];9}10 11console.log(minorCollection([], rememberedSet).map((value) => value === tea ? "tea" : "?").join(","));Remembered sets and store buffers
A store buffer is a small list of recent relevant writes. It can separate the immediate job of noticing a write from the later job of organizing that information into a remembered set. The names vary between engines, but the idea is common.
1const storeBuffer = [];2 3function store(owner, key, value) {4 owner[key] = value;5 if (owner.generation === "old" && value.generation === "young") {6 storeBuffer.push({ owner, key, value });7 }8}9 10const cart = { generation: "old" };11const tea = { generation: "young" };12store(cart, "latest", tea);13console.log(storeBuffer.length);Line 1 creates an empty buffer. Lines 3 through 6 store a field and push a record only for an old-to-young write. Line 12 performs that write, so line 13 prints 1. A later engine phase can process this little list.
Buffering does not allow the engine to forget the write. It changes when and where the bookkeeping is finished. The collector must see all relevant records before it decides which young objects are live.
A store buffer is often the short-term queue of writes. A remembered set is the information minor collection consults. An engine can use different representations, but both exist to protect fast young-only scanning.
Card marking
Recording every exact slot can be more detail than an engine wants on every write. Card marking divides old memory into small regions called cards. A relevant write marks its card dirty, and a minor collection later checks all slots in that marked region.
1const cardSize = 4;2const cardTable = [false, false, false];3const slot = 5;4const card = Math.floor(slot / cardSize);5cardTable[card] = true;6console.log(cardTable.join(","));Line 1 gives each card four slots. Line 3 chooses slot 5. Line 4 divides by four and gets card 1. Line 5 marks that card, and line 6 prints false,true,false. Only the middle region is dirty.
A dirty card is deliberately less precise than one remembered slot. It may contain several fields, and some may no longer point to young objects when collection begins. That extra rescan work is the price of much smaller, cheaper write-time metadata.
1const cardSize = 4;2const cardTable = [false, false, false];3const slot = 5;4const card = Math.floor(slot / cardSize);5cardTable[card] = true;6console.log(cardTable.join(","));cleanSlots 0 through 3
cleanSlots 4 through 7
cleanSlots 8 through 11
Slot 5 belongs to card 1. The dirty cards are none. A minor collector would rescan only those cards in this model.
The cost of barriers in JIT code
A property write in optimized code is not always one simple machine action. The JIT compiler may add a few checks so it can tell whether the write could create an old-to-young pointer. This is a normal cost of a moving, generational collector.
1function store(owner, value) {2 if (valueIsSmallInteger(value)) return "no pointer";3 if (owner.generation === "young") return "young owner";4 if (value.generation === "old") return "old value";5 recordOldToYoung(owner, value);6 return "recorded";7}8 9console.log("recorded");Line 2 skips a small integer because it is not a heap pointer. Line 3 skips when the owner is young because minor collection will scan it. Line 4 skips an old value because the write does not point into young memory. Only line 5 records the remaining possible case.
This is an illustration, not assembly and not a contract for one engine. Engines use type feedback, object layouts, inline caches, and slow paths to make common safe cases cheap. When proof is unavailable, correctness wins and the barrier stays.
1function barrierDecision(ownerGeneration, value) {2 if (typeof value === "number") return "skip: primitive";3 if (ownerGeneration === "young") return "skip: young owner";4 if (value.generation === "old") return "skip: old value";5 return "record: old-to-young";6}7 8console.log(barrierDecision("old", 3));9console.log(barrierDecision("young", { generation: "young" }));10console.log(barrierDecision("old", { generation: "young" }));Line 2 recognizes the primitive number and skips pointer work. Line 3 skips a young owner because minor collection will scan it anyway. Line 5 reaches the recording path only for an old owner with a young object value. The three logs therefore print skip: primitive, skip: young owner, and record: old-to-young.
The important performance idea is not that every assignment is expensive. It is that a JIT must preserve this collector invariant while keeping common, provably safe stores short. A few well-predicted checks are often much cheaper than scanning all old memory later.
What application code should know
Application code does not call a public writeBarrier() function after each assignment. JavaScript engines insert their own bookkeeping under ordinary property writes, array writes, and other pointer stores. Your source code remains ordinary JavaScript.
1const cart = [];2cart.push({ item: "tea" });3console.log(cart.length);Line 1 creates an array. Line 2 stores a new object in it. Line 3 prints 1. If this array becomes old and the item is young, the engine may run a barrier internally. The program does not need to know which remembered representation the engine chose.
Use this knowledge to read profiles carefully. Many small writes can have collector cost, but changing code based on a guess is rarely wise. Measure a real slowdown first, then use the engine and browser tools appropriate to that app.
- An old
cartstores a youngteaobject. - An old
cartstores the number3. - A young
cartstores a youngteaobject. - An old
cartstores an olduserobject. - Slot 9 changes in an old page with four slots per card.
- An old list stores the primitive price
120.
Sort each situation by the lesson's simplified decision. Read the explanation after placing every card.
Common misconceptions
These names are easy to blur because they all sit below ordinary JavaScript. Keep each job narrow: the barrier notices a write, remembered information exposes an old-to-young edge, and card marking chooses a small region to rescan.
- “I should call a barrier from my app.” No. It is engine machinery behind normal writes.
- “A remembered set copies all old objects.” No. It records only information needed for young collection.
- “A dirty card names one exact pointer.” No. It names a region that must be rechecked.
- “Minor collection can ignore old memory completely.” No. It avoids a full scan but must use remembered information.
- “Every write has the same cost.” No. JIT code can prove many stores need no pointer barrier work.
| Term | What it is | Do not confuse it with |
|---|---|---|
| Write barrier | A small action on a write that may record a pointer. | A JavaScript API you call yourself. |
| Remembered set | Information about old-to-young references. | A complete copy of all old objects. |
| Card marking | Marking a region as possibly containing a useful pointer. | Knowing the exact slot without rechecking. |
| Minor collection | A young-generation collection that avoids scanning all old memory. | A collection that can ignore old-to-young pointers. |
The lesson models one common pattern, not a specification for every JavaScript engine. V8, JavaScriptCore, SpiderMonkey, and embedded engines can choose different details while preserving the same language behavior.
Practice exercises
An old cart stores a fresh tea object. What kind of pointer should the lesson model record?
The barrier records an old-to-young pointer: old cart to young tea.
Read the small generation model and type the exact output.
const cart = { generation: "old" };
const tea = { generation: "young" };
console.log(cart.generation === "old" && tea.generation === "young");const cart = { generation: "old" };
const tea = { generation: "young" };
console.log(cart.generation === "old" && tea.generation === "young");Both generation checks match, so the program prints true.
Tea is stored only through an old cart. What information lets the model minor collection retain tea?
The remembered set, or remembered slot, acts as an extra root for tea during minor collection.
In a four-slot card model, which card contains slot 9?
const cardSize = 4;
const slot = 9;
console.log(Math.floor(slot / cardSize));const cardSize = 4;
const slot = 9;
console.log(Math.floor(slot / cardSize));Floor(9 / 4) is 2, so slot 9 belongs to card 2.
Explain why an old object storing the number 3 can skip the pointer-barrier recording step.
const owner = { generation: "young" };
const tea = { generation: "young" };
console.log(owner.generation === "young" ? "skip" : "record");A small integer is not a pointer to a young heap object, so the pointer barrier can skip it.
A product grid feels slow after a change with many updates. What should you do before rewriting its assignments around a guess about barriers?
Measure first. A profile can tell you whether allocation, collection, or something else is actually responsible before you redesign code.
Check your understanding
Use the direction of a pointer first, then decide whether the collector needs an exact slot, a buffered record, or a dirty card.
Question 1 of 7Why does a minor collection need remembered information?
Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictPop out in the code editor (opens in a new tab)JavaScriptconst cart = { generation: "old" }; const tea = { generation: "young" }; console.log(cart.generation === "old" && tea.generation === "young");Choose an answer to see the explanation.
Question 3 of 7What is a remembered set in this lesson's model?
Choose an answer to see the explanation.
Question 4 of 7A store buffer is best described as what?
Choose an answer to see the explanation.
Question 5 of 7What does card marking trade for less immediate metadata?
Choose an answer to see the explanation.
Question 6 of 7Which store can an optimized engine often skip for this barrier?
Choose an answer to see the explanation.
Question 7 of 7Does application code normally call a write barrier directly?
Choose an answer to see the explanation.
Key takeaways
- Minor collection stays fast by scanning young memory instead of all old memory.
- An old-to-young pointer must be recorded so a live young object is not missed.
- Remembered sets, store buffers, and card tables are different ways to preserve or organize that information.
- Card marking trades exact slot detail for rescanning a small dirty region later.
- JIT code adds small write checks but skips them when it can prove a store is safe.
- Application code normally relies on the engine and profiles real problems instead of calling barriers directly.
Remember the one-liner.
A write barrier leaves a note when old memory starts pointing at young memory.
Coming next: Incremental, concurrent & parallel GC, where collectors do more bookkeeping to avoid one long pause while JavaScript keeps making progress.