Incremental, Concurrent, and Parallel GC
The Stop-the-World Problem
Here's the nightmare: your user is scrolling through a feed, animations are running at 60fps (16.6ms per frame), and the garbage collector decides it needs to mark every live object in a 200MB heap. If the GC stops JavaScript execution while it works, that pause might take 50-200ms — visible jank, dropped frames, and a user who thinks your app is broken.
This is stop-the-world (STW) collection: pause all JavaScript execution, run the GC to completion, then resume. Early garbage collectors worked this way. It's simple and correct, but the pause times are unacceptable for interactive applications.
V8 uses three strategies to minimize STW pauses: incremental marking, concurrent marking, and parallel collection. Together, they keep major GC pauses under a few milliseconds in most cases.
Think of GC like cleaning a warehouse. Stop-the-world is closing the warehouse to all workers, cleaning everything, then reopening. Incremental is cleaning for 5 minutes, letting workers resume, cleaning another 5 minutes, and so on. Concurrent is hiring a cleaning crew that works alongside the warehouse workers simultaneously. Parallel is having multiple cleaners work on different sections at the same time.
Tri-Color Marking: The Foundation
Before understanding incremental and concurrent GC, you need tri-color marking — the abstraction that makes interruptible marking possible.
Every object is in one of three states during a GC cycle:
| Color | Meaning | State |
|---|---|---|
| White | Not yet visited | Potentially garbage (or just not reached yet) |
| Gray | Visited, but its children haven't all been scanned | In the processing queue |
| Black | Fully scanned — this object and all its direct children have been visited | Definitely alive |
The marking algorithm:
- Start: All objects are white. Root objects are colored gray and added to the worklist.
- Process: Pick a gray object from the worklist. Scan its references. Any white object it references becomes gray (added to worklist). Once all references are scanned, the gray object becomes black.
- Done: When the worklist is empty (no more gray objects), every black object is alive. Every white object is garbage.
The key property of tri-color marking: the invariant. At no point can a black object directly reference a white object. If it could, we'd miss scanning that white object (black objects are never revisited), and we'd incorrectly collect it. Gray objects act as the barrier between black and white.
Incremental Marking
Instead of marking the entire heap in one STW pause, V8 breaks marking into small chunks interleaved with JavaScript execution:
[JS code] → [mark 1ms] → [JS code] → [mark 1ms] → [JS code] → [mark 1ms] → [sweep]
Each marking step processes some gray objects from the worklist, then yields back to JavaScript. The tri-color state persists between steps — V8 remembers which objects are white, gray, and black.
The Problem: Mutator Interference
While JavaScript runs between marking steps, it can modify the object graph. This is dangerous:
// Between marking steps, JS runs:
const black_obj = alreadyMarkedObject; // GC already colored this black
const white_obj = newlyCreatedObject; // GC hasn't seen this yet (white)
black_obj.ref = white_obj; // Black now references white — invariant violated!
If the GC doesn't know about this change, it will never visit white_obj (because black_obj is already black and won't be re-scanned). white_obj would be incorrectly collected.
Write Barriers to the Rescue
V8 inserts write barriers — tiny code snippets that execute every time a pointer field is written. When a write creates a reference from a black (or any marked) object to a white object, the barrier re-colors the target gray (or marks the source for re-scanning):
// Pseudo-code for a write barrier
function writeBarrier(object, field, newValue) {
object[field] = newValue; // the actual write
if (gcIsMarking && isBlack(object) && isWhite(newValue)) {
markGray(newValue); // add to worklist for scanning
}
}
Write barrier overhead
Write barriers add overhead to every pointer write during a GC cycle — typically a conditional check and sometimes a store to the remembered set. V8 uses several optimizations to minimize this cost:
- Generational write barriers run all the time (not just during marking) to track Old→Young references for the Scavenger's remembered set.
- Marking write barriers only run during an active incremental marking cycle. V8 uses a global flag check that compiles to a single branch instruction.
- Store buffer approach: instead of immediately processing the barrier, V8 records the write in a buffer and processes it during the next marking step. This amortizes the cost.
In practice, write barrier overhead is 1-5% of total execution time — a small price for avoiding 50-200ms pauses.
Concurrent Marking
Incremental marking breaks the pause into smaller pieces, but the total marking work is still done on the main thread (just in chunks). Concurrent marking moves most of the marking work to a background thread that runs simultaneously with JavaScript:
Main thread: [==== JS code running ============================]
[short pause: finalize]
Background: [============== marking objects ==================]
The background thread traverses the object graph and marks reachable objects. Meanwhile, JavaScript continues executing on the main thread. When the background thread finishes, a short STW pause finalizes: re-scan objects modified during concurrent marking (tracked by write barriers), handle edge cases, and begin sweeping.
Challenges of Concurrent Marking
The background thread reads the object graph while the main thread modifies it. This is a classic concurrent data structure problem:
- New references: Main thread creates A→B after background already marked A as black. Write barrier handles this.
- Deleted references: Main thread removes the only reference to an object the background hasn't reached yet. Safe — the object was already unreachable; collecting it is correct.
- New objects: Objects allocated during concurrent marking. V8 allocates them as black (already marked alive) to avoid the background thread needing to discover them.
- Object movement: The Scavenger might move Young Generation objects during concurrent marking. V8 coordinates this carefully with forwarding pointers.
These terms are often confused. Concurrent means the GC runs at the same time as JavaScript on different threads. Parallel means multiple GC threads work together on the same GC task. V8 uses both: concurrent marking (background thread + main thread) and parallel scavenging (multiple threads cooperate on the Young Generation collection).
Parallel Scavenging
The Young Generation's Scavenger can also be parallelized. Instead of one thread copying survivors from From-space to To-space, multiple threads divide the work:
- Main thread and helper threads each take a portion of the root set
- Each thread traces from its roots, copying live objects to To-space
- Threads use atomic operations to claim regions of To-space and avoid conflicts
- Forwarding pointers (stored in From-space) ensure objects aren't copied twice
Parallel scavenging typically reduces minor GC pause times by 30-50% compared to single-threaded scavenging.
V8's Orinoco GC: The Complete Picture
V8's garbage collector is called Orinoco. Here's how all the pieces fit together:
Minor GC (Young Generation)
- Parallel Scavenger runs on multiple threads
- Semi-space copying: live objects move From-space → To-space (or promote to Old Space)
- Typical pause: 1-3ms
Major GC (Old Generation)
- Concurrent marking starts on a background thread while JS runs
- Incremental marking steps happen on the main thread when the background thread needs help or during idle time
- Short STW pause to finalize marking (re-scan write barrier records)
- Concurrent sweeping frees memory on background threads while JS runs
- Parallel compaction (if needed) runs on multiple threads during a pause
- Typical finalization pause: 2-5ms (down from 50-200ms without these techniques)
Concurrent GC means pauses are shorter, not that GC is free. The background thread still uses CPU time, which can affect throughput on CPU-bound workloads. On a single-core device (rare today but possible in constrained environments), "concurrent" GC actually alternates with JS execution and provides no benefit. V8 adapts its strategy based on available cores.
Measuring GC Impact
You can observe GC activity in several ways:
Chrome DevTools Performance Panel
Record a trace and look for:
- Minor GC entries (Young Generation collections)
- Major GC entries (Old Generation collections)
- Yellow sections in the flame chart labeled "GC"
- Total GC time as a percentage of the recording
V8 Flags (Node.js)
# Print GC events with timing
node --trace-gc your-script.js
# Output:
# [12345:0x...] 42 ms: Scavenge 2.1 (4.0) -> 1.8 (4.0) MB, 1.2 / 0.0 ms
# [12345:0x...] 1337 ms: Mark-sweep 8.2 (16.0) -> 6.1 (16.0) MB, 3.5 / 0.0 ms
# More verbose GC statistics
node --trace-gc --trace-gc-verbose your-script.js
The format: [pid] time: Algorithm before (capacity) -> after (capacity), pause / concurrent
| What developers do | What they should do |
|---|---|
| Assuming GC pauses are always 50-200ms Most marking work happens on a background thread concurrent with JS execution | V8's concurrent and incremental marking keeps major GC finalization pauses to 2-5ms typically |
| Ignoring write barrier cost Every pointer write checks if it needs to re-color a target object to maintain the tri-color invariant | Write barriers add 1-5% overhead to pointer writes during marking. This is the price of avoiding STW pauses. |
| Thinking 'concurrent GC' means zero pause The main thread must re-scan objects modified during concurrent marking and finalize mark bits | Concurrent GC still has a short STW finalization pause. The background thread can't handle everything. |
| Not measuring GC impact on real workloads GC behavior depends heavily on allocation patterns. Only measurement reveals real impact. | Use --trace-gc in Node.js or Chrome DevTools Performance panel to measure actual GC frequency and pause times |
- 1Stop-the-world pauses are the enemy of smooth user experience. V8 minimizes them with incremental, concurrent, and parallel GC techniques.
- 2Tri-color marking (white/gray/black) enables interruptible marking. Gray objects are the frontier — marking can pause and resume from them.
- 3Write barriers protect the tri-color invariant during incremental and concurrent marking. They add ~1-5% overhead but prevent incorrect collection.
- 4Concurrent marking runs on a background thread alongside JavaScript. Only a short finalization pause (2-5ms) is needed on the main thread.
- 5Parallel scavenging uses multiple threads for Young Generation collection, reducing minor GC pauses by 30-50%.
- 6Measure real GC impact with --trace-gc or Chrome DevTools. Don't guess — allocation patterns determine GC behavior.