Mark-sweep & mark-compact
Learn how JavaScript collectors mark live objects, sweep free slots, handle fragmentation, compact memory, and update moved references.
- 01Trace markingStart from roots and explain why a marked object survives.
- 02Spot fragmentationRead a free list and tell whether one large object can fit.
- 03Explain compactionShow how forwarding slots keep references correct after objects move.
Find live objects, free the rest
A collector needs a clear question: which objects can the running program still reach? Mark-sweep marks those live objects, then frees the unmarked ones. Mark-compact also groups live objects together after finding them.
Marking follows references from roots. Sweeping reclaims unmarked slots. Compaction moves live objects together to remove holes.
This follows Reference counting & cycles. Counting had trouble with cycles; tracing answers a different question: can a root reach this object?
Marking from roots
Start with roots such as a current variable or host-held reference. Follow each reference. Every reached object is marked live.
const root = { cart: { items: ["tea"] } };console.log(root.cart.items[0]);Line 1 creates a root object, then connects it to a cart and an items array. Line 2 reads the first item through that whole path. It prints tea.
Marking is the collector phase that starts at roots and records every object it can reach. A root is not special because of its name. It is special because the running program or host still holds it.
const graph = { cart: ["items"], user: [], items: [], oldBanner: [] };const roots = ["cart", "user"];const marked = new Set(roots);for (const name of roots) { for (const child of graph[name]) marked.add(child);}console.log([...marked].join(","));Line 1 creates four names and one reference from cart to items. Line 2 names the roots. Line 3 marks them. Lines 4 to 6 follow their children. Line 7 prints cart,user,items; oldBanner is unreachable.
const graph = { cart: ["items"], user: [], items: [], oldBanner: [],};const roots = ["cart", "user"];console.log(markGraph(graph, roots).join(","));Line 1 writes a graph where each name has a list of references. Line 7 chooses cart and user as roots. Line 8 calls the lesson model, which repeatedly takes one name from a worklist and adds its children. It also prints cart,items,user.
Replay of instrumented teaching code, not an engine debugger.
script
const roots = ["cart", "user"];const marked = new Set(roots);for (const name of roots) { for (const child of graph[name]) marked.add(child);}console.log([...marked].join(","));Put a sticker on everything you still use. Then throw away everything without a sticker. The sticker pass is marking, and the clearing pass is sweeping.
A sticker on one box can lead you to another box inside it. In the same way, marking follows a reference from a live object to another live object.
- In real life: Put a sticker on things you still use
- In JavaScript: Mark objects reachable from roots
- In real life: Follow a sticker to the matching box
- In JavaScript: Follow a reference to another object
- In real life: Throw away things without a sticker
- In JavaScript: Sweep unmarked objects
Where the analogy stops: A room cleaner knows what is useful to you. A collector only follows references.
Sweeping and free lists
A free list is a list of slots that sweeping made available. It lets later allocations reuse freed space.
const heap = ["cart", "old-banner", "user"];const marked = new Set(["cart", "user"]);console.log(heap.map((name) => marked.has(name) ? name : null));Line 1 has a live cart, an old banner, and a live user. Line 2 marks only the cart and user. Line 3 replaces the unmarked banner with null, so it prints an array holding cart, null, and user.
Sweeping walks the heap representation after marking. It keeps marked entries, clears unmarked entries, and remembers the cleared positions for a later allocation.
function sweep(heap, marked) { const live = new Set(marked); const nextHeap = heap.map((name) => live.has(name) ? name : null); return { heap: nextHeap, freeList: nextHeap.flatMap((name, slot) => name === null ? [slot] : []) };}const heap = ["cart", null, "user", "old-banner", null, "items"];const swept = sweep(heap, ["cart", "user", "items"]);console.log(swept.heap);console.log(swept.freeList);Line 1 makes six labeled slots. Line 2 lists the live names from marking. Line 3 sweeps. Lines 4 and 5 print the new heap and free slot numbers. The model frees slots 1,3,4.
There is an important limit: this array is a teaching model. Real engines store headers, object sizes, and allocator metadata. The useful idea remains the same: sweep can reuse dead space without moving live objects.
Fragmentation
Fragmentation means free space is split into small holes. The total can be large enough, while no single adjacent run is large enough.
const freeSlots = [1, 3, 4];const needs = 3;console.log(hasRun(freeSlots, needs));Line 1 lists three free positions. Line 2 says the new object needs three slots. Line 3 asks the lesson helper whether positions 1, 2, and 3 all exist. It prints false, because slot 2 is occupied.
Total free space answers “how much space exists?” The largest free run answers “what is the biggest thing that can fit right now?” Those are different questions.
function sweep(heap, marked) {
const live = new Set(marked);
const nextHeap = heap.map((name) => live.has(name) ? name : null);
return { heap: nextHeap, freeList: nextHeap.flatMap((name, slot) => name === null ? [slot] : []) };
}
const heap = ["cart", null, "user", "old-banner", null, "items"];
const swept = sweep(heap, ["cart", "user", "items"]);
console.log(swept.heap);
console.log(swept.freeList);[cart, empty, user, empty, empty, items]
Free list: 1, 3, 4
Free slots: 1, 3, 4. A three-slot object does not fit before compaction. After compaction, cart, user, items, empty, empty, empty.
After cars leave, three free spaces may be scattered. A bus needing three spaces in a row cannot park. Moving parked cars together creates one long free area.
The lot has enough empty spaces in total, but not the run the bus needs. This is why a free list alone does not remove fragmentation.
- In real life: Empty spaces are spread out
- In JavaScript: Free slots are fragmented
- In real life: A bus needs three neighboring spaces
- In JavaScript: A large object needs an adjacent run
- In real life: Move cars together
- In JavaScript: Compact live objects
- In real life: Update each parking slip
- In JavaScript: Update references after moving
Where the analogy stops: Drivers can read and update paper slips. JavaScript cannot see its internal addresses.
Compaction and moving objects
Compaction slides live objects toward one end of the heap. The reward is one long free region. The cost is copying live objects and fixing every reference that used an old location.
const heap = ["cart", null, "user", null, "items", null];const moved = compactWithSteps(heap);const pointers = updatePointers({ cartItems: 4, activeUser: 2 }, moved.forwarding);console.log(moved.heap);console.log(pointers);Line 1 starts with holes between three live names. Line 2 calls the compacting model. Line 3 translates two model pointers with the forwarding table. Lines 4 and 5 print the packed heap and the translated pointers.
Moving a live object is not free. The collector must copy its data, record where it went, and update references before the program observes the heap again.
Replay of the lesson's compact-and-update model, not browser heap inspection.
script
const live = heap.flatMap((name, oldSlot) => name === null ? [] : [{ name, oldSlot }]); return { heap: [...live.map(({ name }) => name), ...Array(heap.length - live.length).fill(null)], forwarding: Object.fromEntries(live.map(({ oldSlot }, newSlot) => [oldSlot, newSlot])) };}function updatePointers(pointers, forwarding) { return Object.fromEntries(Object.entries(pointers).map(([name, slot]) => [name, forwarding[slot] ?? slot]));}const moved = compact(["cart", null, "user", null, "items", null]);const updated = updatePointers({ cartItems: 4, activeUser: 2 }, moved.forwarding);console.log(moved.heap);console.log(updated);V8 compacts only the pages that are most fragmented. That is one simple way to avoid paying movement cost when ordinary sweeping leaves usable space.
Updating pointers after a move
A forwarding table records an old slot and its new slot. The collector uses it to update references while moving objects.
const forwarding = { 4: 2 };const pointer = 4;console.log(forwarding[pointer]);Line 1 says that old slot 4 moved to new slot 2. Line 2 holds a pointer to the old slot. Line 3 looks up the new slot and prints 2.
A collector must update references held in stack frames, heap objects, and runtime structures. The forwarding table is a short model of that bookkeeping.
function compact(heap) {
const live = heap.flatMap((name, oldSlot) => name === null ? [] : [{ name, oldSlot }]);
return { heap: [...live.map(({ name }) => name), ...Array(heap.length - live.length).fill(null)], forwarding: Object.fromEntries(live.map(({ oldSlot }, newSlot) => [oldSlot, newSlot])) };
}
function updatePointers(pointers, forwarding) {
return Object.fromEntries(Object.entries(pointers).map(([name, slot]) => [name, forwarding[slot] ?? slot]));
}
const moved = compact(["cart", null, "user", null, "items", null]);
const updated = updatePointers({ cartItems: 4, activeUser: 2 }, moved.forwarding);
console.log(moved.heap);
console.log(updated);Line 1 makes holes. Line 2 models two references by slot number. Line 3 compacts the heap. Line 4 updates references. Lines 5 and 6 print the compact heap and correct new slots. JavaScript code can never see addresses, so this move is invisible: your variables still work because the collector updated every reference.
This is also why application code should not try to reason from an object address. JavaScript gives you object identity and properties, not an internal slot number.
A small V8 trace
Node can expose a test-only collection hook. This Node-only probe keeps application output separate from V8 trace text.
const keep = [];
for (let index = 0; index < 4000; index += 1) keep.push({ index, text: "tea".repeat(20) });
globalThis.gc();
console.log("app output", keep.length);The test runs Node with --trace-gc --expose-gc. It checks for Mark-Compact or Mark-sweep, then confirms the program still prints app output 4000.
Practical use
You cannot choose a collector from application code. You can avoid retaining data accidentally and use memory tools when a real problem appears.
const cache = new Map();cache.set("cart", { items: ["tea"] });cache.delete("cart");console.log(cache.size);Line 1 creates a cache. Line 2 stores a cart. Line 3 removes that entry when it is no longer needed. Line 4 prints 0. The deletion does not force collection, but it removes one path that could keep the cart reachable.
In a real site, investigate a retaining path before changing code. A long-lived listener, timer, cache, or global can be the path that keeps a large object alive.
- Start from
cartanduser. - Follow
carttoitems. - Write empty slot numbers into a free list.
- Replace unmarked
old-bannerwithnull. - Slide
itemsfrom slot 4 to slot 2. - Change a pointer using
4 -> 2.
Sort the six cards by the job they describe.
Common misconceptions
Mark-sweep and mark-compact are not competing answers to the marking question. Both first need to know which objects are live. The difference comes after that decision: sweeping reuses holes where they are, while compaction pays to move survivors and joins the holes into one larger area.
Neither phase makes a logical memory leak disappear by magic. If a cache, listener, timer, or global still reaches an object, marking will correctly keep it. First remove the accidental retaining path; then the next collection can treat the object as unmarked.
- “Sweep always gives one big empty area.” It can leave scattered holes.
- “Compaction changes my variable values.” The collector updates internal references.
- “A free list proves a large object fits.” The slots must be adjacent.
| Collector | Work | Cost | Fragmentation | Pointers |
|---|---|---|---|---|
| Mark-sweep | Find live objects, then add dead slots to a free list. | Fast to reclaim | Can leave scattered holes | No live object moves |
| Mark-compact | Find live objects, then slide them together. | Costs time to move objects | Leaves one long free area | All moved references must update |
Practice exercises
Type the printed names.
const graph = { cart: ["items"], user: [], items: [], oldBanner: [] };
const roots = ["cart", "user"];
const marked = new Set(roots);
for (const name of roots) {
for (const child of graph[name]) marked.add(child);
}
console.log([...marked].join(","));It prints cart,user,items. oldBanner has no root path.
Which free slots did sweeping make available?
Slots 1, 3, and 4 are empty.
Can a three-slot object fit in free slots 1, 3, and 4?
No. Scattered slots do not form one three-slot run.
After forwarding 4 -> 2, what is the new items slot?
The forwarding table sends the items pointer to slot 2.
What should a shopping app developer assume when the collector compacts memory?
Assume moving is invisible. The collector updates references; use a heap snapshot when investigating retention.
Check your understanding
Use the same order for every question: find the roots, mark the reachable names, sweep what remains, and then ask whether the free slots form one run. Do not jump from “three empty slots” to “a three-slot object fits” without checking their positions.
For moving questions, keep two facts together. Compaction improves the shape of free space, and forwarding information keeps every internal reference correct. JavaScript sees the same object before and after the move because its address was never part of the language contract.
Question 1 of 8What does marking decide?
Choose an answer to see the explanation.
Question 2 of 8What does this print?
Read the code, then predictconst marked = new Set(["cart", "user"]); marked.add("items"); console.log([...marked].join(","));Choose an answer to see the explanation.
Question 3 of 8What is a free list?
Choose an answer to see the explanation.
Question 4 of 8Why can three free slots still reject a three-slot object?
Choose an answer to see the explanation.
Question 5 of 8What does compaction do?
Choose an answer to see the explanation.
Question 6 of 8What keeps references correct after compaction?
Choose an answer to see the explanation.
Question 7 of 8What does this fragmentation check print?
Read the code, then predictconst freeSlots = [1, 3, 4]; console.log(freeSlots.includes(2) ? "fits" : "does not fit");Choose an answer to see the explanation.
Question 8 of 8When is mark-compact worth its moving cost?
Choose an answer to see the explanation.
Key takeaways
- Marking follows references from roots to find live objects.
- Sweeping frees unmarked slots and can build a free list.
- Fragmentation scatters free space into holes.
- Compaction makes one long free area by moving live objects.
- Forwarding information lets collectors update every moved reference.
Remember the one-liner.
Mark finds what lives; sweep frees what does not; compact removes the holes.
Coming next: Generational collection & the Scavenger.