Mark-sweep & mark-compact
Learn how JavaScript collectors mark live objects, sweep free slots, handle fragmentation, compact memory, and update moved references.
- 01Follow the markingStart at the roots, follow each link, and say why a marked object is kept.
- 02Spot fragmentationRead a free list and tell whether a big object can fit in it.
- 03Explain compactionShow how a forwarding table keeps every reference correct after objects move.
Find the objects in use, free the rest
Your program makes objects all the time. Each object takes up some memory. When the program can no longer reach an object, its memory can be used again. The part of the JavaScript engine that frees this memory is the garbage collector, or collector for short.
This lesson shows two ways a collector can work:
- Mark-sweep: find the objects still in use, then free the rest.
- Mark-compact: do the same, then move the objects still in use close together.
A collector works in steps. Each step is called a phase:
- Marking: start at the roots and follow every link. Mark each object you reach.
- Sweeping: free the memory of every object that was not marked.
- Compaction: move the marked objects next to each other, so the free memory is in one piece.
A root is a value the program can always reach, such as a variable in the code that is running now. A reference is a link from one value to an object. For example, cart.items links the cart to its items.
This lesson follows Reference counting & cycles. There, the collector counted the links to each object. Two objects that linked to each other were hard to free. Marking asks a different question: can the program still reach this object, starting from a root?
Marking from roots
Marking starts at the roots. From each root, the collector follows every reference. It marks each object it reaches as live, which means still in use.
const root = { cart: { items: ["tea"] } };console.log(root.cart.items[0]);Line 1 makes a root object. Inside it is a cart object, and inside that is an items array. Line 2 follows the path root.cart.items[0] and prints tea. Each step along that path follows a reference. The collector follows the same links that your code does.
The name root is not what makes it a root. A value is a root because the running program, or the browser or Node around it, still holds it.
The next code is a small model of marking. Each name stands for an object. Each list holds the names that object links to.
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(","));In plain words, line by line:
- Line 1 makes
graph.cartlinks toitems.user,items, andoldBannerlink to nothing. - Line 2 says the roots are
cartanduser. - Line 3 marks the roots by putting them in
marked. ASetis a list that keeps each value only once. - Lines 4 to 6 go through each root and mark every name it links to. So
itemsgets marked too. - Line 7 prints the marked names, joined with commas:
cart,user,items.
oldBanner is not printed. Nothing links to it, so the program can no longer reach it.
cart and user, and follows each arrow. It reaches items through cart. No arrow reaches oldBanner.This short loop only follows links one step away from the roots. Real objects can link many steps deep. The next model uses a worklist: a to-do list of names still to visit. It takes one name off the list, marks it, and adds that name's links to the list. It stops when the list is empty.
const graph = { cart: ["items"], user: [], items: [], oldBanner: [],};const roots = ["cart", "user"];console.log(markGraph(graph, roots).join(","));Lines 1 to 6 write the same graph over several lines. Line 7 picks cart and user as roots. Line 8 calls markGraph, which the hidden setup code defines. It sorts the marked names before it returns them, so this prints cart,items,user.
These steps were recorded from the lesson's model code. They don't show the real engine.
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(","));You clean your room in two rounds. First, you put a sticker on everything you still use. Then you throw away everything without a sticker.
When you put a sticker on a box, you also look inside it. The things inside get stickers too.
- In real life: Put a sticker on things you still use
- In JavaScript: Mark the objects the roots reach
- In real life: Put stickers on the things inside a box
- In JavaScript: Follow a reference to another object
- In real life: Throw away things without a sticker
- In JavaScript: Sweep: free the objects that are not marked
Sweeping and free lists
After marking, the collector sweeps. Memory is split into slots. In this lesson, one slot holds one object. Sweeping walks through the slots one by one. It keeps the marked objects and empties every other slot.
const heap = ["cart", "old-banner", "user"];const marked = new Set(["cart", "user"]);console.log(heap.map((name) => marked.has(name) ? name : null));Line 1 makes a heap: a list of slots. The heap is the part of memory where objects live. Line 2 marks cart and user. Line 3 goes through each slot with map. It keeps a marked name and puts null in place of any other name. It prints [ 'cart', null, 'user' ].
A free list is a list of the slot numbers that are now empty. When the program makes a new object later, the engine can put it in one of those slots.
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);In plain words, line by line:
- Lines 1 to 5 make a function,
sweep. It takes the heap and the marked names. - Line 2 puts the marked names in a
Set, so checking a name is quick. - Line 3 keeps each marked name and puts
nullin every other slot. - Line 4 returns the new heap and the free list: the numbers of the slots that hold
null. - Line 6 makes six slots. Slots 1 and 4 are already empty. Slot 3 holds
old-banner, which was not marked. - Line 7 sweeps, with
cart,user, anditemsas the marked names. - Lines 8 and 9 print the new heap and the free list,
[ 1, 3, 4 ].
old-banner into null, an empty slot. The empty slots, 1, 3, and 4, make the free list.This array is a small model. A real engine keeps more facts about each object, such as its size. The main idea is the same: sweeping frees memory without moving any object that is still in use.
Fragmentation
Fragmentation means the free memory is split into small gaps between objects that are in use. There can be enough free slots in total, but not enough of them side by side. A big object needs its slots side by side.
const freeSlots = [1, 3, 4];const needs = 3;console.log(hasRun(freeSlots, needs));Line 1 lists the free slots: 1, 3, and 4. Line 2 says the new object needs three slots. Line 3 calls hasRun, which the hidden setup code defines. It checks whether three free slots sit in a row. It prints false, because slot 2 is not free.
user sits in slot 2 between them. The longest row of free slots is only 2, so a three-slot object does not fit.So there are two different questions:
- How many slots are free in total? Here, three.
- What is the longest row of free slots? Here, only two: slots 3 and 4.
A big object needs the second answer to be large enough.
Try it below. The playground sweeps the same six slots as before. Press the button to stop using items, and watch the longest row of free slots.
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
Longest row of free slots: 2
Free slots: 1, 3, 4. The longest row of free slots is 2, so a three-slot object does not fit without moving anything. After compaction, the slots would be: cart, user, items, empty, empty, empty.
Some cars leave a parking lot. Now there are three empty spaces, but they are spread out. A bus needs three spaces side by side, so it cannot park.
The attendant moves the parked cars close together. Now the empty spaces form one long row, and the bus fits. The attendant also tells each driver where their car is now.
- In real life: Empty spaces are spread out
- In JavaScript: Free slots have objects between them
- In real life: A bus needs three spaces side by side
- In JavaScript: A big object needs free slots side by side
- In real life: Move the parked cars close together
- In JavaScript: Compaction: move the objects in use together
- In real life: Tell each driver the car's new space
- In JavaScript: Fix every reference to a moved object
Compaction: moving objects together
Compaction slides the objects in use toward one end of the heap. Then all the free slots sit together at the other end, in one long row.
Moving has a cost. For each object it moves, the collector must:
- copy the object to its new slot,
- write down where the object went,
- fix every reference that still points at the old slot.
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);In plain words, line by line:
- Line 1 makes a heap with empty slots between
cart,user, anditems. - Line 2 calls
compactWithSteps. It slides the objects to the front. It also writes each move into a forwarding table: a list of "old slot to new slot" pairs. - Line 3 has two pointers. Here, a pointer is a slot number that says where an object is.
cartItemssays slot 4 andactiveUsersays slot 2.updatePointerslooks up each one in the forwarding table. - Line 4 prints the packed heap:
[ 'cart', 'user', 'items', null, null, null ]. - Line 5 prints the new pointers:
{ cartItems: 2, activeUser: 1 }.
user from slot 2 to slot 1 and items from slot 4 to slot 2. The forwarding table on the right records each move, so the pointers can be updated.These steps were recorded from the lesson's compact-and-fix model. They don't show the browser's real memory.
script
const pointers = { cartItems: 4, activeUser: 2 };const moved = compact(heap);const updated = updatePointers(pointers, moved.forwarding);console.log(moved.heap);console.log(updated); 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]));}V8 is the JavaScript engine inside Chrome and Node. It does not compact the whole heap every time. The heap is split into blocks called pages, and V8 compacts only the pages with the most gaps. On the other pages, a sweep leaves enough usable space, so V8 skips the cost of moving.
Updating pointers after a move
A forwarding table pairs each old slot with its new slot. When an object moves, the collector uses this table to fix every reference to it.
const forwarding = { 4: 2 };const pointer = 4;console.log(forwarding[pointer]);Line 1 says the object in slot 4 moved to slot 2. Line 2 is a pointer that still says slot 4. Line 3 looks up slot 4 in the table and prints 2.
References to one object can sit in many places:
- variables in functions that are running now,
- properties of other objects,
- the engine's own lists.
The collector fixes all of them. The forwarding table is a small model of that work. The next code puts the whole move together.
const heap = ["cart", null, "user", null, "items", null];
const pointers = { cartItems: 4, activeUser: 2 };
const moved = compact(heap);
const updated = updatePointers(pointers, moved.forwarding);
console.log(moved.heap);
console.log(updated);
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]));
}In plain words, line by line:
- Line 1 makes a heap with gaps.
- Line 2 makes two pointers:
cartItemssays slot 4 andactiveUsersays slot 2. - Line 3 compacts the heap and builds the forwarding table.
- Line 4 fixes both pointers with the forwarding table.
- Lines 5 and 6 print the packed heap and the fixed pointers,
{ cartItems: 2, activeUser: 1 }. - Lines 8 to 14 make the two functions,
compactandupdatePointers.
Your JavaScript code can never see a slot number or a memory address. So you never notice a move. Your variables keep working because the collector fixed every reference.
JavaScript lets you compare two objects with === and read their properties. It never tells you where an object sits in memory, so your code should never depend on that.
A small V8 trace
Node can print one line each time V8 collects garbage. This printout is called a trace. The code below makes many objects and then asks for a collection.
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);In plain words, line by line:
- Line 1 makes an empty array,
keep. - Line 2 adds 4000 small objects to it.
- Line 3 asks V8 to collect garbage now.
globalThis.gconly exists when Node starts with the--expose-gcflag. It is for tests, not for real apps. - Line 4 prints
app output 4000.
The lesson test runs this code with node --trace-gc --expose-gc. The --trace-gc flag tells V8 to print a line for each collection. The test checks two things:
- V8 printed
Mark-CompactorMark-sweep, the name of the collector it used. - The program still printed
app output 4000. The collection did not change the program's own result.
Practical use
Your code can't choose which collector runs, or when. What your code can do is stop holding on to objects it no longer needs.
const cache = new Map();cache.set("cart", { items: ["tea"] });cache.delete("cart");console.log(cache.size);Line 1 makes a cache: a Map that stores values under keys. Line 2 stores a cart under the key "cart". Line 3 deletes that entry when the cart is done. Line 4 prints 0, the number of entries left.
Deleting does not start a collection right away. It removes one path from a root to the cart. At the next collection, marking won't reach the cart, so its memory can be freed.
In a real site, first find out what keeps a big object alive. Then change the code. Common causes are:
- an event listener that is never removed,
- a timer that keeps running,
- a cache that keeps growing,
- a global variable.
The browser's memory tools can show the path from a root to an object. This path is called a retaining path.
- Start from
cartanduser. - Follow the link from
carttoitems. - Write the empty slot numbers into a free list.
- Put
nullin place ofold-banner, which was not marked. - Slide
itemsfrom slot 4 to slot 2. - Change a pointer from slot 4 to slot 2, using
4 -> 2.
Sort the six cards by the phase they belong to: mark, sweep, or compact.
Common misconceptions
Mark-sweep and mark-compact both start the same way: they mark the objects in use. The difference is what comes next:
- Mark-sweep frees the gaps and leaves every object where it is.
- Mark-compact moves the objects together, so the gaps join into one big free area.
Neither one fixes a memory leak on its own. A memory leak is memory your program keeps but no longer needs. If a cache, listener, timer, or global variable still reaches an object, marking will correctly keep it. First remove that link. Then the next collection can free the object.
- “Sweeping always leaves one big empty area.” It can leave many small gaps between objects.
- “Compaction changes my variable values.” No. The collector moves objects and fixes every reference, so your variables still point at the same objects.
- “Three free slots mean a three-slot object fits.” Only if the three slots are side by side.
The table compares the two collectors. Read each row as one collector, from left to right.
| Collector | What does it do? | What does it cost? | What does free memory look like? | What happens to references? |
|---|---|---|---|---|
| Mark-sweep | It marks the objects in use, then lists the other slots as free. | It is quick, because nothing moves. | Free slots can be spread out, with objects between them. | No object moves, so no reference needs fixing. |
| Mark-compact | It marks the objects in use, then slides them together. | It takes longer, because objects are copied. | All free slots sit together in one long row. | Every reference to a moved object must be fixed. |
Practice exercises
Read or run the code. Type what it prints.
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. Nothing links to oldBanner, so it is not marked.
Look at the "Sweep slots into a free list" code. Which slot numbers are free after the sweep?
Slots 1, 3, and 4 hold null, so the free list is 1, 3, 4.
Slots 1, 3, and 4 are free. Can a three-slot object fit? Type yes or no.
No. Slot 2 is in use, so slots 1, 3, and 4 are not three in a row.
The forwarding table says 4 -> 2. The items pointer said slot 4. Which slot does it say now?
The forwarding table says 4 -> 2, so the items pointer now says slot 2.
You build a shopping app. The collector compacts memory while it runs. What should you assume about your variables?
Assume moving is invisible. The collector fixes every reference, so your variables still work. To find out why memory stays in use, look at a heap snapshot in the browser's memory tools.
Check your understanding
Use the same steps for every question:
- Find the roots.
- Mark every name the roots reach.
- Sweep: the rest become free slots.
- Check whether the free slots sit side by side.
Three free slots do not mean a three-slot object fits. Always check where the free slots are.
For questions about moving, remember two facts. Compaction puts the free slots in one row. The forwarding table lets the collector fix every reference. Your code sees the same object before and after the move, because JavaScript never shows you where an object sits in memory.
Question 1 of 8What does marking find out?
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 8Three slots are free. Why might a three-slot object still not fit?
Choose an answer to see the explanation.
Question 5 of 8What does compaction do?
Choose an answer to see the explanation.
Question 6 of 8After compaction, what keeps references correct?
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 it worth the time to move objects with mark-compact?
Choose an answer to see the explanation.
Key takeaways
- Marking starts at the roots and follows every reference. Each object it reaches is in use.
- Sweeping frees every slot that was not marked. The free slot numbers form a free list.
- Fragmentation means the free slots are split up by objects in use.
- Compaction moves the objects in use together, so the free slots form one long row.
- A forwarding table pairs old and new slots, so the collector can fix every reference to a moved object.
Remember the one-liner.
Mark finds what is in use, sweep frees the rest, and compact closes the gaps.
Coming next: Generational collection & the Scavenger. It uses the fact that most objects are only needed for a short time.