JavaScript developers often treat garbage collection and the event loop as black boxes—we know they exist, we know roughly how they behave, but the machinery underneath is subtle and deeply engineered. Over the past weeks I've worked to map that hidden sub-structure, and what I found is a story of elegant trade-offs: generational hypotheses, write barriers, concurrent marking, and the meticulously ordered phases of libuv. Let me share what I've learned.
## The Generational Hypothesis and V8's Heap Layout
The foundational insight behind V8's garbage collector is the *generational hypothesis*: most objects die young. Empirically, a large fraction of allocations become unreachable within a few milliseconds. V8 exploits this by partitioning the heap into a **young generation** (roughly 16 MiB in size) and an **old generation**. When a minor GC (scavenge) runs, it copies live objects from the young generation to the old generation, compacting them, and promotes objects that have survived a threshold number of collections. This is fast precisely because most objects are garbage—copying cost is paid only for the few survivors.
But the generational approach introduces a tricky bookkeeping problem: objects in the old generation can hold references to objects in the young generation. If we only collect the young generation, we must know which old-generation objects point into young space to avoid missing live references. Scanning the entire old generation on every minor GC would defeat the purpose. V8 solves this with a **remembered set**—a data structure that records all old-to-young pointers. This remembered set is maintained through a *write barrier*: every time a pointer field in an old-generation object is modified to point to a young object, the barrier logs the address. The barrier is a small piece of code injected at every object field write, and it's highly optimized to keep overhead minimal. During a young GC, the collector traverses only the remembered set (plus stack roots) rather than the whole heap.
## Mark-Sweep-Compact and the Orinoco Project
In the old generation, objects are longer-lived and often large, so copying them would be expensive. Instead, V8 uses a **mark-sweep-compact** algorithm. The collector starts from root pointers (stack, globals, etc.) and traces the object graph to mark reachable objects. Then it sweeps the heap, reclaiming dead blocks, and optionally compacts to eliminate fragmentation. But this collector has historically been a source of jank—stopping the world to mark and compact could take tens or hundreds of milliseconds.
V8's **Orinoco project** aims to replace the old stop-the-world collector with a mostly concurrent and parallel one. The key pieces in its design are:
- **Concurrent marking**: The mark phase runs concurrently with JavaScript execution. It uses a worklist to track grey objects, and a write barrier ensures that newly referenced objects are still tracked correctly under mutation.
- **Parallel scavenge**: The young generation scavenge is also parallelized, with multiple threads copying survivors concurrently, then synchronizing to update references.
- **Incremental sweeping**: Sweeping is interleaved with JavaScript execution, so it doesn't block the main thread for a single long stretch.
As part of Orinoco, concurrent marking is the final puzzle piece. The result is that garbage collection pauses are dramatically reduced compared to the old stop-the-world approach, though the exact pause times depend on heap size and application behavior. The write barrier cost remains, but it's a small constant per field write.
## The Event Loop: More Than a Queue
Moving from memory to execution, the **event loop** is the runtime's central orchestrator. It's not just a queue of callbacks; it's a carefully nested structure with priority lanes. In the browser, the loop follows this sequence each iteration:
1. Pick one macrotask (e.g., a `setTimeout` callback, an I/O event) from the macrotask queue and run it to completion.
2. Run **all** microtasks (Promise resolutions, `queueMicrotask`, mutation observers) until the microtask queue is empty.
3. Run `requestAnimationFrame` callbacks.
4. Perform layout and paint.
This cycle repeats from step 1. Microtasks are drained *after* each macrotask, not during. This is why a Promise `.then()` can starve the event loop if it continuously queues new microtasks—the loop never gets to step 1 again until the microtask queue is empty.
## Node.js and libuv: Platform Phases
Node.js uses libuv to implement the event loop across different operating systems. libuv's loop has six phases that repeat in a fixed order:
- **timers**: Execute callbacks scheduled by `setTimeout` and `setInterval` that have expired.
- **pending callbacks**: I/O callbacks deferred to the next iteration (e.g., some types of errors).
- **poll**: Wait for new I/O events. This blocks the event loop if no timers or pending callbacks are due. I/O callbacks are queued here.
- **check**: `setImmediate` callbacks run here.
- **close callbacks**: Handle cleanup, like socket `close` events.
These phases cycle continuously, processing callbacks from each queue. The microtask queue is drained in much the same way as in browsers—after each macrotask completes and before the next phase begins—though the exact timing can vary across implementations.
Understanding these mechanisms—the generational GC's write barrier, Orinoco's concurrent marking, and the event loop's priority lanes—sheds light on real-world performance. Profiling tools like the Performance API can reveal which parts of your application trigger GC pauses or long tasks, and knowing the sub-structure helps you write code that works *with* the runtime, not against it.
Comments
No comments yet — be the first.