How Bun's Event Loop Differs from Node.js: JavaScriptCore Performance Explained
Bun replaces Node.js's libuv with a custom event loop built on uSockets that integrates directly with JavaScriptCore's micro-task queue, eliminating thread-pool hops and reducing micro-task overhead through a unified Task queue and enter/exit counter mechanism.
Bun's event loop differs from Node.js by embedding the I/O poller inside the JavaScriptCore (JSC) VM rather than delegating to libuv, achieving lower latency and higher throughput for JavaScript applications. This architecture, implemented in the oven-sh/bun repository, uses a tagged-pointer task union and custom process.nextTick bindings to minimize context switches while maintaining Node.js compatibility.
uSockets vs. libuv: The Fundamental Architecture Difference
Node.js relies on libuv, which employs a generic poller and a separate thread pool for file I/O operations. This design requires data to hop between threads, introducing context-switch overhead and latency.
Bun eliminates this bottleneck by building its loop on top of uSockets, a thin wrapper around epoll (Linux) and kqueue (macOS). According to the source documentation in src/bun.js/event_loop/README.md (lines 33-60), uSockets drives I/O polling directly from the main thread that owns the JSC VM. This zero-copy hand-off removes the need to post work to a separate thread pool and callback into JavaScript through multiple abstraction layers.
The result is a tight coupling where ready file descriptors are converted directly into tasks without leaving the JavaScript thread, significantly reducing latency for I/O-bound operations.
The Unified Task Queue
All asynchronous work in Bun is represented as a single tagged-pointer union defined in src/bun.js/event_loop/Task.zig (lines 1-20). This Task union compresses every async operation—whether file I/O, timers, HTTP requests, or timers—into a single contiguous queue.
The loop drains this queue using tickQueueWithCount, which switches on the tag and invokes the appropriate runFromJSThread method. This approach provides two performance advantages:
- No heap allocation per task: Tasks live in a pre-allocated, cache-friendly structure.
- No virtual-function overhead: Direct tag-based dispatch eliminates the indirection costs found in libuv's handle-based callbacks.
As implemented in oven-sh/bun, this unified queue ensures that async operations execute with minimal indirection directly on the JavaScript thread.
Micro-Task and nextTick Integration
Bun preserves Node.js semantics for process.nextTick while optimizing the drainage pattern. The implementation stores the next-tick queue in a dedicated JSC binding located at src/bun.js/bindings/JSNextTickQueue.cpp.
The drainage order follows a strict sequence controlled by an enter/exit counter in src/bun.js/event_loop/EventLoopHandle.zig (lines 39-51):
- Process.nextTick queue drains first.
- JavaScriptCore micro-tasks drain once per top-level task.
- Rejected promises handle cleanup.
Unlike Node.js, which can trigger repeated micro-task drains when promises are scheduled inside process.nextTick, Bun uses the entered_event_loop_count counter to ensure JSC's drainMicrotasks executes only once after each top-level task. This prevents "micro-task explosion" and reduces overhead while maintaining correct execution order.
Performance Advantages Over Node.js
The architectural choices in Bun's event loop yield four measurable performance benefits:
- Zero-copy I/O hand-off: Because uSockets runs on the same thread as the JSC VM, ready descriptors become tasks without copying data between a thread pool and the JavaScript thread.
- Cache-friendly task dispatch: The
Taskunion's contiguous memory layout and tag-based dispatch minimize cache misses compared to libuv's handle structures. - Reduced micro-task churn: Draining micro-tasks once per top-level task (guarded by the enter/exit counter) avoids the repeated drainage cycles possible in Node.js.
- Direct JSC integration: The VM is aware of event-loop entry and exit points via
enterandexitfunctions inEventLoopHandle.zig, allowing it to pause GC and schedule deferred work instantly without traversing a C-API bridge.
Event Loop Flow in Practice
The following examples demonstrate Bun's event loop ordering and I/O handling. Run these with bun to observe the behavior.
nextTick Executes Before Promises
Bun guarantees that process.nextTick callbacks run before JavaScriptCore micro-tasks (Promises), matching Node.js semantics but with optimized drainage:
process.nextTick(() => console.log('nextTick 1'));
Promise.resolve().then(() => console.log('promise 1'));
process.nextTick(() => console.log('nextTick 2'));
Output:
nextTick 1
nextTick 2
promise 1
This ordering reflects the implementation in src/bun.js/event_loop/README.md (lines 59-66), where the next-tick queue drains before JSC's micro-task queue.
setImmediate Fires Before setTimeout
Callbacks scheduled with setImmediate enter the immediate-task queue, which drains at the start of the loop tick, while timers are processed after the I/O poll:
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);
Output:
immediate
timeout
This behavior corresponds to the flow chart in the README (lines 100-108), illustrating that immediate tasks take precedence over timer tasks in src/bun.js/event_loop/Task.zig.
File I/O Without Thread-Pool Hops
Bun handles file system operations directly on the event loop thread without posting to a separate thread pool:
import fs from 'node:fs/promises';
async function readMany() {
// Each read becomes a Task variant in the unified queue.
const files = await Promise.all([
fs.readFile('file1.txt', 'utf8'),
fs.readFile('file2.txt', 'utf8'),
fs.readFile('file3.txt', 'utf8')
]);
console.log('All done', files.length);
}
readMany();
Each fs.readFile call creates a ReadFileTask variant inside the Task union (Task.zig), allowing the loop to drain these operations via runFromJSThread without the context switches required by libuv's uv_queue_work.
Summary
- Bun's event loop differs from Node.js by using uSockets instead of libuv, running I/O polling directly on the main JavaScript thread.
- Async work is unified in a tagged-pointer Task union (
Task.zig) that enables cache-friendly, zero-allocation task dispatch. - Process.nextTick semantics are preserved through a custom JSC binding (
JSNextTickQueue.cpp) that drains before micro-tasks. - An enter/exit counter (
EventLoopHandle.zig) limits micro-task drainage to once per top-level task, reducing overhead. - File I/O executes without thread-pool hops, eliminating the latency introduced by libuv's multi-threaded architecture.
Frequently Asked Questions
Why is Bun's event loop faster than Node.js for I/O operations?
Bun achieves lower latency because it eliminates the thread-pool hops required by libuv. By integrating uSockets directly with JavaScriptCore on the main thread, I/O tasks convert to JavaScript callbacks without context switches or data copying between threads. The unified Task queue in Task.zig further reduces overhead by using contiguous memory and tag-based dispatch instead of heap-allocated handles.
Does Bun maintain compatibility with Node.js event loop semantics?
Yes. Bun preserves Node.js ordering guarantees: process.nextTick runs before Promises, and setImmediate executes before setTimeout(..., 0). The implementation in JSNextTickQueue.cpp and the enter/exit counter logic in EventLoopHandle.zig ensure that existing Node.js applications behave identically while benefiting from improved performance.
How does Bun handle micro-tasks differently than Node.js?
Node.js can trigger multiple micro-task drains per tick when promises are scheduled recursively inside process.nextTick. Bun uses an entered_event_loop_count counter to ensure JavaScriptCore's micro-task queue drains exactly once per top-level task. This prevents "micro-task explosion" while maintaining the required execution order, as documented in the event loop README (lines 42-68).
What files control Bun's event loop integration with JavaScriptCore?
The core integration logic resides in src/bun.js/event_loop/EventLoopHandle.zig (enter/exit counters and task enqueueing), src/bun.js/event_loop/Task.zig (unified task definition), and src/bun.js/bindings/JSNextTickQueue.cpp (next-tick queue implementation). The high-level flow and ordering rules are documented in src/bun.js/event_loop/README.md.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →