# How Bun's Event Loop Differs from Node.js: JavaScriptCore Performance Explained

> Discover how Bun's event loop outperforms Node.js using JavaScriptCore and uSockets for faster JavaScript execution and reduced overhead. Learn the technical differences.

- Repository: [Bun/bun](https://github.com/oven-sh/bun)
- Tags: deep-dive
- Published: 2026-02-28

---

**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`](https://github.com/oven-sh/bun/blob/main/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`](https://github.com/oven-sh/bun/blob/main/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):

1.  **Process.nextTick** queue drains first.
2.  **JavaScriptCore micro-tasks** drain once per top-level task.
3.  **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 `Task` union'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 `enter` and `exit` functions in `EventLoopHandle.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:

```javascript
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`](https://github.com/oven-sh/bun/blob/main/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:

```javascript
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:

```javascript
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`](https://github.com/oven-sh/bun/blob/main/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`](https://github.com/oven-sh/bun/blob/main/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`](https://github.com/oven-sh/bun/blob/main/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`](https://github.com/oven-sh/bun/blob/main/src/bun.js/event_loop/README.md).