8 min read · Guide · Concurrency
How it works · Concurrency

How an event loop runs async code on a single thread.

A JavaScript runtime has one thread, two task queues and a render step. All of its apparent concurrency comes from how this loop schedules work.

Parts01–07 InteractiveLive queue PrereqJS callbacks · Promises

What is an event loop?

One thread, two queues and a render step.

An event loop is a single-threaded concurrency primitive: one thread repeatedly takes the next task from a queue and runs it to completion before picking up the next. JavaScript engines (V8, JavaScriptCore, SpiderMonkey) and Node.js's libuv are the two most-used implementations. Browsers add render and animation phases; libuv has six explicit phases per tick.

JavaScript runs on a single thread (the contrast is a thread pool). Your code never executes in parallel. The runtime alternates between many small pieces of work in a tight loop, and that loop has a precise spec. Once you know its order, questions like "why did setTimeout(0) run after my Promise" are easy to answer.

The loop holds two queues (a macrotask queue and a microtask queue (often backed by a ring buffer in the engine)) plus a render checkpoint. Each iteration: run one macrotask, drain the entire microtask queue, run a render frame if one is pending, repeat.


Following one tick of the loop

Microtasks drain, then render, then one macrotask.

The simulator below has two queues, a render slot and an execution log. Press Tick (or Auto) to see the loop drain microtasks completely, render, take one macrotask, and repeat.

Macrotask queue · 0
empty
Microtask queue · 0
empty
Render
up to date
Now executing
idle
Recent log
nothing yet

Microtasks vs macrotasks: two queues, different rules

One macrotask per tick; microtasks drain fully.

Macrotasks are work "scheduled to run later": setTimeout, setInterval, I/O callbacks, message events (including WebSocket frames), click handlers. Each iteration of the loop runs at most one macrotask.

Microtasks are work that "should run as soon as the current task is done", such as Promise.then, queueMicrotask, MutationObserver. The loop drains the entire microtask queue before doing anything else, including before rendering, before the next macrotask, before setTimeout(0).

Macrotask sources

Work scheduled for later

setTimeout / setInterval, fetch resolution, click and other DOM events, postMessage, requestAnimationFrame (one per render). One per loop iteration; long ones starve the rest.

Microtask sources

Work that runs right after the current task

Promise.then / .catch / .finally, queueMicrotask, MutationObserver. Drained completely between macrotasks, and microtasks can schedule more microtasks, which run in the same drain.


Why setTimeout(0) does not run immediately

It is a macrotask, so microtasks run first.

setTimeout(fn, 0) schedules a macrotask, while Promise.resolve().then(fn) schedules a microtask. Both look "immediate" but they run in different orders, which catches many people out.

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

// output:
// 1
// 4 ← rest of synchronous code
// 3 ← microtask drained
// 2 ← then the macrotask

The synchronous body runs to completion (1, 4). Then the microtask queue drains (3). Only after that does the loop pick up the macrotask (2). This ordering is specified, so code that expects a different order is relying on a bug.


A long task blocks everything else

One slow callback freezes render and input.

One macrotask running for 200 ms blocks the next 200 ms of macrotasks, microtasks and rendering. Browser frames target 16.7 ms (60 fps), so a single long task drops about a dozen of them, which users see as jank.

A microtask loop can starve macrotasks indefinitely. Calling queueMicrotask(fn) from inside fn never lets the queue empty, so click handlers never fire and nothing renders. To yield, use setTimeout(0) rather than Promise.resolve().then.


Node's event loop and its six phases

libuv runs more queues than the browser does.

Browsers have one macrotask queue. Node.js (via libuv) has six phases per loop iteration: timers, pending callbacks, idle / prepare, poll (where most I/O lives), check (setImmediate), close. Each phase has its own queue; microtasks drain after every callback.

process.nextTick runs before the microtask queue, in its own queue. A recursive nextTick will starve everything else, just like the microtask case above.


How to break up long work and yield the loop

Chunk the work so other tasks can run.

A CPU-heavy job (parsing a big JSON, running an algorithm over thousands of items, or time spent in a GC pause) can be split into chunks, with a yield between them so the loop can service other work and the page stays responsive. Three patterns:

setTimeout(0)

Yield with setTimeout(0)

Schedule the next chunk as a macrotask. This lets render and other work interleave. Browsers clamp nested timers to 4 ms, so very small chunks get throttled.

scheduler.yield()

Yield with scheduler.yield()

The Scheduler API. await scheduler.yield() gives the browser a chance to run higher-priority work and resumes your code afterwards. It replaces the setTimeout workaround.

requestIdleCallback

Run work while the page is idle

Runs work only when the browser is idle, which suits analytics, prefetching and anything off the critical path. Avoid it for user-facing work, since nothing bounds when it fires.

Why a single 100-ms task ruins performance

What the single-threaded constraint costs, in numbers.

An event loop processes one task to completion before picking the next. The longest acceptable task is set by your latency budget. At 60 fps (a 16 ms frame budget), any task over a few ms drops a frame, and at 1000 RPS a 100 ms task leaves about 100 requests queued behind it. Some typical numbers:

JSON.parse on a 10-MB string
~80-150 ms in V8. Blocks the event loop completely. Stream-parsers (clarinet, JSONStream) avoid this.
Synchronous bcrypt hash, cost factor 12
~250 ms. The reason every Node bcrypt library wraps it in a worker thread.
Math-heavy loops in JavaScript
~10 ns per iteration in V8 hot code. A 1M-iteration loop is ~10 ms, which is borderline, so measure it.
Promise resolution overhead
~1 µs per .then. Fine for normal use; a hot loop creating millions of Promises drowns in microtask scheduling.

Worker threads (Node.js since 12) move CPU-bound work to a separate thread, letting the main loop stay responsive. libuv's threadpool (default 4 threads) handles file I/O and DNS lookups off the main loop, which is why fs.readFile doesn't block but a tight CPU loop does.


Event loops beyond JavaScript

The same idea in other runtimes.

Python asyncio brought async/await to Python in 3.4 (2014). The event loop runs coroutines via send/yield protocol. uvloop (a libuv binding) speeds it up ~2-4× over the default selector-based loop. Production: FastAPI, aiohttp, Starlette.

Rust Tokio is a multi-threaded event loop with work-stealing across N worker threads. Each worker has a per-thread queue plus a shared injection queue. Tokio benchmarks routinely hit ~1 million HTTP/s on a single 16-core box. Production: AWS Lambda Rust runtime, Linkerd 2, Discord's voice servers.

Bun (Zig + JavaScriptCore, 2022) is a Node.js-compatible runtime with a faster event loop and bundled tooling. Benchmarks show ~3-4× faster HTTP throughput than Node for trivial workloads, less of an edge for I/O-heavy real apps.

Deno (Rust + V8, 2018) is the secure-by-default Node alternative. Same V8 event loop, with permissions baked in. Production: Slack's edge functions, Netlify's edge runtime.

The Java virtual-thread alternative. Java 21's Project Loom lets you write blocking code per request without an event loop. Virtual threads (cheap enough to run millions) are scheduled onto carrier threads (a small thread pool). The trade-off is simpler code than async/await with comparable performance, but only on the JVM.



A closing note

The loop's order is synchronous code, microtasks, render, then one macrotask, repeated. Most unexpected async orderings follow from that sequence, and because it is deterministic, ordering bugs are easy to reproduce.

Found this useful?

Published Updated