When you write async JavaScript or allocate objects in a tight loop, two invisible systems are orchestrating beneath your code: the garbage collector and the event loop. They run on the same thread, share the same heap, and can block each other. Understanding how they interact β and how modern engines like V8 minimize that interference β transforms how you reason about performance. Let's trace both systems, then watch how they collide and cooperate.
## The Generational Bet
V8's garbage collector is built on a bet: most objects die young. This is the generational hypothesis, and decades of real-world programs prove it holds. So V8 splits the heap into a young generation (roughly 16 MiB, collected frequently) and an old generation (everything that survives). The young generation uses a fast scavenge (minor GC): it copies survivors into a clean semi-space, compacting them as it goes. Dead objects are simply never copied β the cost is proportional to survivors, not dead objects. That's the genius: collecting the nursery is almost free when almost everything is garbage.
Objects that survive a few minor GCs get promoted to the old generation. Here, the cost is higher, so major GC runs less often. It uses mark-sweep-compact: first, mark all reachable objects starting from roots (global objects, the call stack, etc.); second, sweep dead objects into free lists; third, compact only the most fragmented pages to reduce memory overhead and improve cache locality. Compaction costs time because it moves objects, so V8 compacts selectively β only pages where fragmentation is severe.
But a problem emerges: an old-generation object might hold a reference to a young-generation object. When the scavenge runs, it needs to know about those pointers or it might miss a live object. If it scanned the entire old generation each time, the minor GC would be anything but minor. Enter the write barrier: every time JavaScript code writes a pointer from an old object to a young object, V8 intercepts that store and records the old object in a store buffer. The scavenge then scans only the buffer, not the whole old heap. That tiny cost per write buys enormous savings at collection time.
## Orinoco: Making GC Less Intrusive
V8's Orinoco project incrementally replaced the old collector with a mostly concurrent and parallel architecture. The key pieces:
- **Concurrent marking**: The major GC's marking phase now runs while your JavaScript executes. A helper thread walks the object graph, marking live objects. The main thread occasionally assists via incremental marking steps, but JavaScript keeps running. Any object the main thread modifies gets its mark state tracked carefully to avoid missing live data. Concurrent marking is the final major piece of Orinoco.
- **Parallel scavenge**: The minor GC, already fast, became parallel: multiple threads copy survivors simultaneously.
- **Incremental sweeping**: Sweeping β adding dead pages to free lists β is interleaved with JavaScript execution, not done in one burst.
The result: pause times are dramatically reduced. But they're not zero. A major GC still needs occasional stop-the-world phases (e.g., to scan the call stack or to finish marking roots). The event loop must wait for those pauses.
## The Event Loop's Dance
While GC manages memory, the event loop orchestrates execution. In both Node.js and browsers, the loop is single-threaded and built around a call stack and two queues: task queue (macrotasks) and microtask queue. The processing order is strict:
1. Run one macrotask from the task queue (e.g., a setTimeout callback, a DOM event handler, an I/O completion in Node).
2. Drain the entire microtask queue (e.g., resolved promise callbacks, queueMicrotask callbacks).
3. In browsers, run requestAnimationFrame callbacks, then perform layout and paint.
4. Check for pending timers, I/O, etc., and repeat.
Microtasks have priority over macrotasks and are processed until the queue is empty, even if a microtask adds more microtasks. That means a single promise chain that keeps enqueuing microtasks can starve the browser's rendering β a common source of jank.
When you `await` a promise, the function suspends and yields the call stack back to the event loop. The continuation after `await` is scheduled to run later, ensuring the thread is not blocked.
In Node, libuv manages the task queue with specific phases: timers (callbacks from setTimeout/setInterval), pending callbacks (I/O callbacks deferred from the previous poll), poll (wait for new I/O events), check (setImmediate callbacks), and close callbacks (e.g., socket.close). Each phase has its own list of callbacks, and between phases, libuv processes microtasks (in recent Node versions, promise callbacks).
## Where GC Meets the Loop
The GC shares the main thread. When a minor or major GC needs to stop the world β even briefly β the event loop stalls. With Orinoco's concurrent and parallel design, those stops are relatively short. But if JavaScript is in the middle of a long macrotask (say, a 100 ms synchronous computation), that task delays both GC and event loop processing. Worse, when that task finally finishes, it may trigger a full drain of microtasks, which could allocate objects and trigger a GC β compounding the pause.
A subtle insight: objects referenced by pending microtasks remain alive, preventing GC from reclaiming them. If the microtask queue grows deep, it can increase allocation pressure and cause more frequent GC. Conversely, concurrent marking means most GC work happens while your event loop is running tasks, so the total time spent in GC per event loop cycle is spread out rather than concentrated.
## Practical Takeaways
- Avoid creating long-lived objects in hot paths if possible β the generational hypothesis is on your side if you keep allocation local and ephemeral.
- Be mindful of microtask storms: a cascade of promise resolutions can starve the event loop of rendering time. Use `requestAnimationFrame` for visual updates and break up work with task splitting (`setTimeout(fn, 0)` or `scheduler.postTask`).
- Long synchronous tasks (over 50 ms) are the enemy of both responsiveness and GC. They delay the event loop, delay microtask processing, and can cause visible jank when the finally yielded-to browser tries to paint.
- In Node, heavy GC can cause latency spikes; profiling with tools like the Performance API or Lighthouse helps identify GC-heavy allocations.
Understanding the interplay between GC and the event loop gives you a mental model for debugging performance issues. The next time you see a janky animation or a spike in server latency, you'll know where to look: the invisible dance between memory and execution.
Comments
No comments yet β be the first.