This document explains how Bun's event loop works, including task draining, microtasks, process.nextTick, setTimeout ordering, and I/O polling integration.
Bun's event loop is built on top of uSockets (a cross-platform event loop based on epoll/kqueue) and integrates with JavaScriptCore's microtask queue and a custom process.nextTick queue. The event loop processes tasks in a specific order to ensure correct JavaScript semantics while maximizing performance.
A tagged pointer union containing various async task types (file I/O, network requests, timers, etc.). Tasks are queued by various subsystems and drained by the main event loop.
Two separate queues for setImmediate():
immediate_tasks: Tasks to run on the current ticknext_immediate_tasks: Tasks to run on the next tick
This prevents infinite loops when setImmediate is called within a setImmediate callback.
Thread-safe queue for tasks enqueued from worker threads or async operations. These are moved to the main task queue before processing.
For operations that should be batched and deferred until after microtasks drain (e.g., buffered HTTP response writes, file sink flushes). This avoids excessive system calls while maintaining responsiveness.
Node.js-compatible implementation of process.nextTick(), which runs before microtasks but after each task.
Built-in JSC microtask queue for promises and queueMicrotask.
┌─────────────────────────────────────┐
│ 1. Tick concurrent tasks │ ← Move tasks from concurrent queue
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 2. Process GC timer │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 3. Drain regular task queue │ ← tickQueueWithCount()
│ For each task: │
│ - Run task │
│ - Release weak refs │
│ - Drain microtasks │
│ (See detailed flow below) │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 4. Handle rejected promises │
└─────────────────────────────────────┘
This is called when the event loop is active and needs to wait for I/O:
┌─────────────────────────────────────┐
│ 1. Tick immediate tasks │ ← setImmediate() callbacks
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 2. Update date header timer │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 3. Process GC timer │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 4. Poll I/O via uSockets │ ← epoll_wait/kevent with timeout
│ (epoll_kqueue.c:251-320) │
│ - Dispatch ready polls │
│ - Each I/O event treated as task│
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 5. Drain timers (POSIX) │ ← setTimeout/setInterval callbacks
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 6. Call VM.onAfterEventLoop() │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 7. Handle rejected promises │
└─────────────────────────────────────┘
For each task dequeued from the task queue:
┌─────────────────────────────────────────────────────────────┐
│ FOR EACH TASK in task queue: │
│ │
│ 1. RUN THE TASK (Task.zig:135-506) │
│ └─> Execute task.runFromJSThread() or equivalent │
│ │
│ 2. DRAIN MICROTASKS (Task.zig:508) │
│ └─> drainMicrotasksWithGlobal() │
│ │ │
│ ├─> RELEASE WEAK REFS (event_loop.zig:129) │
│ │ └─> VM.releaseWeakRefs() │
│ │ │
│ ├─> CALL JSC__JSGlobalObject__drainMicrotasks() │
│ │ (ZigGlobalObject.cpp:2793-2840) │
│ │ │ │
│ │ ├─> IF nextTick queue exists and not empty: │
│ │ │ └─> Call processTicksAndRejections() │
│ │ │ (ProcessObjectInternals.ts:295-335) │
│ │ │ │ │
│ │ │ └─> DO-WHILE loop: │
│ │ │ ├─> Process ALL nextTick callbacks │
│ │ │ │ (with try/catch & async ctx) │
│ │ │ │ │
│ │ │ └─> drainMicrotasks() │
│ │ │ (promises, queueMicrotask) │
│ │ │ WHILE queue not empty │
│ │ │ │
│ │ └─> ALWAYS call vm.drainMicrotasks() again │
│ │ (safety net for any remaining microtasks) │
│ │ │
│ └─> RUN DEFERRED TASK QUEUE (event_loop.zig:136-138)│
│ └─> deferred_tasks.run() │
│ (buffered writes, file sink flushes, etc.) │
│ │
└─────────────────────────────────────────────────────────────┘
The process.nextTick queue is special:
- It runs before microtasks
- After processing all nextTick callbacks in the current batch, microtasks are drained
- This creates batched processing with interleaving between nextTick generations and promises:
Promise.resolve().then(() => console.log("promise 1"));
process.nextTick(() => {
console.log("nextTick 1");
Promise.resolve().then(() => console.log("promise 2"));
});
process.nextTick(() => console.log("nextTick 2"));
// Output:
// nextTick 1
// nextTick 2
// promise 1
// promise 2If a nextTick callback schedules another nextTick, it goes to the next batch:
process.nextTick(() => {
console.log("nextTick 1");
process.nextTick(() => console.log("nextTick 3"));
Promise.resolve().then(() => console.log("promise 2"));
});
process.nextTick(() => console.log("nextTick 2"));
Promise.resolve().then(() => console.log("promise 1"));
// Output:
// nextTick 1
// nextTick 2
// promise 1
// promise 2
// nextTick 3The implementation (ProcessObjectInternals.ts:295-335):
function processTicksAndRejections() {
var tock;
do {
while ((tock = queue.shift()) !== null) {
// Run the callback with async context
try {
callback(...args);
} catch (e) {
reportUncaughtException(e);
}
}
drainMicrotasks(); // ← Drain promises after each batch
} while (!queue.isEmpty());
}Runs after microtasks to batch operations:
- Used for buffered HTTP writes, file sink flushes
- Prevents re-entrancy issues
- Balances latency vs. throughput
The queue maintains a map of (pointer, task_fn) pairs and runs each task. If a task returns true, it remains in the queue for the next drain; if false, it's removed.
The I/O poll is integrated into the event loop via us_loop_run_bun_tick():
┌─────────────────────────────────────────────────────────────┐
│ us_loop_run_bun_tick(): │
│ │
│ 1. EMIT PRE-CALLBACK (us_internal_loop_pre) │
│ │
│ 2. CALL Bun__JSC_onBeforeWait(jsc_vm) │
│ └─> Notify VM we're about to block │
│ │
│ 3. POLL I/O │
│ ├─> epoll_pwait2() [Linux] │
│ └─> kevent64() [macOS/BSD] │
│ └─> Block with timeout until I/O ready │
│ │
│ 4. FOR EACH READY POLL: │
│ │ │
│ ├─> Check events & errors │
│ │ │
│ └─> us_internal_dispatch_ready_poll() │
│ │ │
│ └─> This enqueues tasks or callbacks that will: │
│ - Add tasks to the concurrent task queue │
│ - Eventually trigger drainMicrotasks │
│ │
│ 5. EMIT POST-CALLBACK (us_internal_loop_post) │
│ │
└─────────────────────────────────────────────────────────────┘
When I/O becomes ready (socket readable/writable, file descriptor ready):
- The poll is dispatched via
us_internal_dispatch_ready_poll()orBun__internal_dispatch_ready_poll() - This triggers the appropriate callback synchronously during the I/O poll phase
- The callback may:
- Directly execute JavaScript (must use
EventLoop.enter()/exit()) - Enqueue a task to the concurrent task queue for later processing
- Update internal state and return (e.g.,
FilePoll.onUpdate())
- Directly execute JavaScript (must use
- If JavaScript is called via
enter()/exit(), microtasks are drained whenentered_event_loop_countreaches 0
Important: I/O callbacks don't automatically get the microtask draining behavior - they must explicitly wrap JS calls in enter()/exit() or use runCallback() to ensure proper microtask handling. This is why some I/O operations enqueue tasks to the concurrent queue instead of running JavaScript directly.
Timers are handled differently based on platform:
ctx.timer.drainTimers(ctx);Timers are drained after I/O polling. Each timer callback:
- Is wrapped in
enter()/exit() - Triggers microtask draining after execution
- Can enqueue new tasks
Uses the uv_timer_t mechanism integrated into the uSockets loop.
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// Output is typically:
// immediate
// timeoutThis is because:
setImmediateruns intickImmediateTasks()before I/O pollingsetTimeoutfires after I/O polling (even with 0ms)- However, this can vary based on timing and event loop state
The event loop uses a counter to track when to drain microtasks:
pub fn enter(this: *EventLoop) void {
this.entered_event_loop_count += 1;
}
pub fn exit(this: *EventLoop) void {
const count = this.entered_event_loop_count;
if (count == 1 and !this.virtual_machine.is_inside_deferred_task_queue) {
this.drainMicrotasksWithGlobal(this.global, this.virtual_machine.jsc_vm) catch {};
}
this.entered_event_loop_count -= 1;
}This ensures microtasks are only drained once per top-level event loop task, even if JavaScript calls into native code which calls back into JavaScript multiple times.
The Bun event loop processes work in this order:
- Immediate tasks (setImmediate)
- I/O polling (epoll/kqueue)
- Timer callbacks (setTimeout/setInterval)
- Regular tasks from the task queue
- For each task:
- Run the task
- Release weak references
- Check for nextTick queue
- If active: Run nextTick callbacks, drain microtasks after each
- If not: Just drain microtasks
- Drain deferred task queue
- For each task:
- Handle rejected promises
This architecture ensures:
- ✅ Correct Node.js semantics for process.nextTick vs. promises
- ✅ Efficient batching of I/O operations
- ✅ Minimal microtask latency
- ✅ Prevention of infinite loops from self-enqueueing tasks
- ✅ Proper async context propagation