How an event loop runs async code on a single thread.
One thread. Two queues. A render step. Everything that feels like JavaScript concurrency happens inside this loop.
What is an event loop?
One thread, two queues, render.
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). There is no parallel execution of your code; there is only the illusion of it, made by alternating between many small pieces of work in a tight loop. That loop has a precise spec, and once you know its order, every "why did setTimeout(0) run after my Promise" question answers itself.
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.
Watch one tick of the loop, step by step
Microtasks drain, then render, then one macrotask.
Below: two queues, a render slot, and an execution log. Press Tick (or Auto) and watch the loop drain microtasks completely, render, take one macrotask, repeat.
Microtasks vs macrotasks: two queues, different rules
One macrotask per tick; microtasks drain fully.
Macrotasks are work that's "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" — 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).
Things that schedule.
setTimeout / setInterval, fetch resolution, click and other DOM events, postMessage, requestAnimationFrame (one per render). One per loop iteration; long ones starve the rest.
Things that just-after.
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.
A common trap. setTimeout(fn, 0) schedules a macrotask. Promise.resolve().then(fn) schedules a microtask. Both look "immediate," but they run in dramatically different orders.
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; if you depend on a different order, you depend 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); a single long task drops a dozen of them. This is what jank is.
Worse: a microtask loop can starve macrotasks indefinitely. queueMicrotask(fn) from inside fn drains forever; click handlers never fire, render never happens. Use setTimeout(0) when you want to yield, not 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 is even more aggressive than microtasks — it runs before the microtask queue, in its own queue. A poorly-coded recursive nextTick will starve everything else, just like the microtask trap above.
How to break up long work and yield the loop
Chunk the work so other tasks can run.
If you have a CPU-heavy job (parse a big JSON, run an algorithm over thousands of items, or burn cycles inside a GC pause), splitting it into chunks and yielding between them lets the loop service other work — keeping the page responsive. Three patterns:
Yield with setTimeout(0)
Schedule the next chunk as a macrotask. Lets render and other work interleave. Clamp at 4ms in browsers — chunks that small will be throttled.
Yield with scheduler.yield()
The Scheduler API. await scheduler.yield() gives the browser a chance to run higher-priority work; resumes you when ready. Replaces the setTimeout dance.
Run work while the page is idle
Run work only when the browser is idle. Good for analytics, prefetch, anything not on the critical path. Don't use for anything user-facing — there's no upper bound on when it fires.
Why a single 100-ms task ruins performance
The single-threaded constraint, in numbers.
An event loop processes one task to completion before picking the next. The maximum acceptable per-task duration is bounded by your latency budget — at 60fps animation (16ms frame budget), any task over a few ms drops a frame. At 1000 RPS service throughput, an 100ms task means 100 requests are queued behind it. Real numbers from production:
- 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 — borderline. Always measure.
- 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 already off the main loop; that's why fs.readFile doesn't block but a tight CPU loop does.
Event loops beyond JavaScript
The same idea, different sweet spots.
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 (millions cheap) are scheduled onto carrier threads (a small thread pool). The trade-off: simpler code than async/await, comparable performance, but only on the JVM.
Once you internalise the order — sync, microtasks, render, macrotask, repeat — every weird async ordering question answers itself. The loop is small. Its rules are tight. The bugs are loud when they happen because the order is so deterministic.
Further reading on event loops
Primary sources, in order.
- WHATWGHTML spec — event loopsThe actual specification. Section 8.1.5 defines every step. Dense but authoritative.
- Node.js docsThe Node.js Event LoopThe phases, with examples. Required reading before reasoning about Node async order.
- Semicolony guideThread poolsThe other model — many threads, many queues. The contrast is illuminating.
- Semicolony guideGo channelsCSP-style concurrency. Different shape entirely; same fundamental problem (cooperate without locks).