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.
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.
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).
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.
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:
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.
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.
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.
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.
Further reading on event loops
Primary sources, in order.
- WHATWGHTML spec: event loopsThe specification itself. Section 8.1.5 defines every step. Dense.
- Node.js docsThe Node.js Event LoopThe phases, with examples. Worth reading before you reason about Node async ordering.
- Semicolony guideThread poolsThe other model: many threads, many queues.
- Semicolony guideGo channelsCSP-style concurrency, a different shape for the same problem of cooperating without locks.