The Event Loop
How one JavaScript thread juggles timers, clicks, promises and I/O: the call stack, task and microtask queues, the browser's render step, Node's libuv phases, and how to spot and fix a blocked loop.
The 30-second version
A ThreadThreadOne worker that runs your code line by line. A program can have several threads, and the operating system decides which one runs on which CPU core. is one worker that runs your code line by line. Your browser tab gives your JavaScript exactly one of them, and so does a Node.js process. Yet a web page can animate, react to clicks and download data all at once, and one Node server can handle thousands of users. This topic explains how.
One thing at a time
Waiting is outsourced
Two queues, strict order
await) run before the next task (timers, clicks, I/O).That third rule is why this classic puzzle surprises people:
console.log("1: script start");
setTimeout(() => console.log("5: timeout"), 0); // a task: waits for the next turn
Promise.resolve().then(() => console.log("3: promise")); // a microtask: runs as soon as the script ends
queueMicrotask(() => console.log("4: microtask")); // puts this function straight in the microtask queue
console.log("2: script end");1: script start
2: script end
3: promise
4: microtask
5: timeoutThe timer said "0 ms", yet it ran last. By the end of §03 you'll be able to predict that without thinking. Along the way:
- §02–§04 cover the call stack, the two queues and
await, all with a step-by-step visualizer. - §05–§06 cover the browser: drawing frames, why long code makes a page stutter, and how to fix it.
- §07–§10 cover Node.js: its loop phases,
process.nextTick, its hidden thread pool, and how to spot a blocked loop. - §11 compares Go's approach. §12–§14 are pitfalls, a quiz and a cheat sheet.
The call stack and run-to-completion
The call stack
When a function calls another function, the first one has to wait for the answer. JavaScript keeps track of who is waiting for whom on the Call stackCall stackThe list of functions that are running right now — each one sits on top of the one that called it. When a function returns, its entry (and its local variables) is thrown away.: a pile of framesStack frameOne function call's entry on the call stack (one “plate”), holding that call's local variables., one per running function call, each holding that call's local variables and where to continue. A new call goes on top; a return takes it off. Like a stack of plates, you only ever touch the top one.
Step through it:
1function greet(name) {2 return "Hi, " + shout(name);3}45function shout(s) {6 return s.toUpperCase() + "!";7}89console.log(greet("ada"));
Run-to-completion
The last step is the important one. Once the engine starts running a piece of JavaScript (the script, a timer callback, a click handler), it keeps going until the stack is empty again. This is called Run to completionRun to completionOnce a piece of JavaScript starts (a script, a callback, a microtask), it runs until it returns. Nothing else can run in between, not even a click or a timer that is already due..
It's a big gift: two pieces of your JavaScript never run at the same time, so a function never sees a variable change halfway through a line because "someone else" ran. It's also the big danger. Here's what a user experiences if a click handler takes a while:
button.addEventListener("click", () => {
const end = Date.now() + 2000;
while (Date.now() < end) {} // ← 2 seconds of pure CPU work: nothing else can run
label.textContent = "done";
});For those 2 seconds the tab is frozen. Other clicks wait. Animations stop. Even label.textContent = "done" doesn't appear until the handler returns, because the browser only redraws between pieces of JavaScript (§05).
So how can one thread feel concurrent?
Because most of what programs do is wait: for a server to answer, a file to load, a timer to run out. JavaScript doesn't do that waiting itself. It asks the HostHostThe program that embeds the JavaScript engine and does the real waiting and I/O for it — the browser, or Node.js. Node uses a C library called libuv for this. (the browser, or Node.js) to do it, and goes back to other work. When the wait is over, the host puts a note in a queue, and the event loop runs your callback when the stack is free.
This is concurrency without parallelism. Concurrency means many jobs are in progress at once; parallelism means several are running at the same instant, on different cores. Here many jobs are in progress, but only one runs at any instant. (Async / Await has a whole section on that difference.) It works because jobs take turns politely: each one runs briefly, then returns or awaits. That style is called Cooperative multitaskingCooperative multitaskingSharing one worker by taking turns, where each job decides when to hand over (by returning or awaiting). Nobody forces a switch, so one greedy job can hog the worker., and it only works if nobody hogs the cook.
Two queues: tasks and microtasks
When the HostHostThe program that embeds the JavaScript engine and does the real waiting and I/O for it — the browser, or Node.js. Node uses a C library called libuv for this. (the browser or Node) finishes waiting for something, it doesn't run your callback directly. It puts it in a queue. Timers, clicks and I/O are queued by the host; promise callbacks are queued by the JavaScript engine itself. There are two queues, and they're treated very differently:
Task queue
setTimeout, setInterval), click and key handlers, network and file callbacks. The loop takes one per turn. They're also called macrotasksMacrotask (task)A bigger unit of work: a timer callback, a click handler, an I/O callback, or the initial script. The event loop runs one per turn. The spec just calls it a “task”..Microtask queue
.then/.catch/.finally callbacks, the code after an await, and queueMicrotask(fn). After every task, the loop runs them all.Call stack
(The browser actually keeps several task queues, one per kind of event, and picks between them. That's why "which task goes next" is a bit flexible, as §06 shows. The microtask queue is just one queue, run in strict order.)
Here it is as code. This is a sketch, not a real API, but every rule in it is real:
while (true) {
const task = pickTask(); // 1. ONE task: a timer, a click… (the script was the first)
if (task) run(task); // runs to completion; nothing interrupts it
while (microtasks.length > 0) { // 2. then ALL microtasks, including ones
run(microtasks.shift()); // queued while this loop is running (shift = take the first item off the front)
}
if (isBrowser && frameIsDue()) { // 3. browsers may draw a frame here (§05)
render();
}
}Why "0 ms" doesn't mean "now"
setTimeout(fn, 0) asks the host for a timer. When it runs out, fn becomes a task. A task has to wait for the current task to finish and for every microtask after it. Promise.resolve().then(fn) skips all that: an already-fulfilled promise (one that already has its value) queues fn as a MicrotaskMicrotaskA tiny job — like a promise's .then callback or the code after an await — that JS runs right after the current code finishes, before timers, clicks or I/O. straight away, and microtasks run as soon as the stack is empty.
So 0 means "no sooner than 0 ms from now". In Node it's actually at least 1 ms, and browsers make nested timers wait at least 4 ms (§06).
Watch it run
Pick a snippet, guess the output first, then step through with ← → or press Play. Start with Basics, then try Nested:
1console.log("A");2setTimeout(() => console.log("B"), 0);3Promise.resolve().then(() => console.log("C"));4queueMicrotask(() => console.log("D"));5console.log("E");
The Nested snippet shows the two rules that trip people up. Here's that code with its real output:
setTimeout(() => {
console.log("T1");
Promise.resolve().then(() => console.log("P-in-T1"));
}, 0);
setTimeout(() => console.log("T2"), 0);
Promise.resolve().then(() => {
console.log("P1");
Promise.resolve().then(() => console.log("P2"));
});
console.log("sync");sync
P1
P2
T1
P-in-T1
T2- A microtask that queues a microtask (P1 → P2): the new one runs in the same drain (drain = run the queue until it's empty), before any task. The loop keeps going until the queue is truly empty.
- A task that queues a microtask (T1 → P-in-T1): it runs right after T1, before T2, even though T2 was waiting first. Microtasks are drained after every task, not only after the script.
await, continuations and microtask starvation
await splits a function in two. The first part runs now; the rest (the ContinuationContinuation“The rest of the function” after a pause point, saved so it can run later. With await, the code after the await line is the continuation.) comes back later as a microtask, once the awaited promise has settled.The visualizer in §03 has two snippets for this: Two awaits and await a timer. If you haven't yet, step through them. Here are the rules spelled out, with the real output. (How the engine actually cuts the function up is in Async / Await §08.)
Rule 1: an async function runs synchronously until its first await
Calling an async function is just a function call. Its body runs immediately, line by line, until it reaches an await. Then it returns a pending promise to the caller and the rest waits.
Rule 2: the rest comes back as a microtask
When the awaited promise settles (becomes fulfilled or rejected), the continuation is queued as a microtask. If the promise was already settled (or isn't a promise at all, like await null), that happens right away. So two async functions take turns, one piece at a time:
async function task(name) {
console.log(`${name}: start`);
await null; // pause; the rest is queued as a microtask
console.log(`${name}: after 1st await`);
await null;
console.log(`${name}: after 2nd await`);
}
setTimeout(() => console.log("timeout"), 0);
task("A");
task("B");
console.log("script end");A: start
B: start
script end
A: after 1st await
B: after 1st await
A: after 2nd await
B: after 2nd await
timeoutRule 3: awaiting a pending promise waits for whatever settles it
If the promise isn't settled yet, nothing is queued until it is. await sleep(0) waits for a timer, which is a task. So the continuation only comes back after a full trip through the task queue:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
console.log("before");
await sleep(0); // resumes after a TASK (the timer), not right away
console.log("after sleep(0)");
}
main();
Promise.resolve().then(() => console.log("promise"));
console.log("end of script");before
end of script
promise
after sleep(0)That difference matters a lot in §06. await null (or awaiting anything already done) lets no task in: no timer, no click, no repaint. await sleep(0) lets the loop do a full turn.
Microtask starvation
"Run every microtask, including new ones" has a nasty edge. If each microtask queues another one, the queue never empties, and the loop never gets back to the task queue. Timers don't fire. Clicks aren't handled. The page never redraws. This is StarvationStarvationA task that is ready to run never gets its turn, because others keep getting the resource first. The system as a whole makes progress; that one task doesn't.: the tasks are ready, but never get a turn.
Here's a version that gives up after 1 second so we can see what happened:
const start = Date.now();
setTimeout(() => console.log(`timer fired after ${Date.now() - start} ms`), 0);
let n = 0;
function again() {
n++;
if (Date.now() - start < 1000) queueMicrotask(again); // keep the queue non-empty for 1 s
else console.log(`microtasks ran: ${n.toLocaleString("en-US")}`);
}
queueMicrotask(again);
// Output on my machine (Node 22; your count will differ):
// microtasks ran: 5,466,940
// timer fired after 1025 ms ← a "0 ms" timer, stuck behind 5 million microtasksNotice there's no "Maximum call stack size exceeded" error. Each again returns before the next one starts, so the stack never grows. That's what makes starvation sneaky: nothing crashes, it just hangs.
The browser loop: task, microtasks, maybe render
The browser's JavaScript thread is called the Main threadMain threadThe single browser thread that runs your JavaScript and also does style, layout and most painting. While it's busy with a long task, the page can't respond to clicks or update.. Besides your code, it does most of the work of turning the page into pixels. A screen shows a new FrameFrameOne complete picture shown on the screen. Smooth motion is many frames per second, each slightly different, like the pages of a flip book. at a fixed rate, usually 60 times a second (60 Hz), and many phones and laptops now run at 90 or 120 Hz (see Frame rate (fps) / refresh rateFrame rate (fps) / refresh rateFrame rate: how many new pictures the page produces per second. Refresh rate: how many times per second the screen can show a new one (60 Hz is common; many phones and laptops do 120 Hz).). At 60 Hz that gives the browser one chance to show a new frame every 16.7 ms (1000 ms ÷ 60).
Where rendering fits
When a frame is due and the main thread is free (between tasks, after the microtasks), the browser runs its rendering steps:
requestAnimationFramecallbacks. requestAnimationFramerequestAnimationFrameA browser function that runs your callback just before the next frame is drawn — the right moment to update anything visual. (rAFfor short) means "call me right before the next frame is drawn". It's the right place to change anything visual.- Style and layout. The browser works out which CSS applies to each element, then the size and position of every box (layoutLayout (reflow)The browser step that works out the size and position of every box on the page. Changing a width or adding an element can force it to run again, which is expensive on big pages.).
- Paint. It turns the boxes into drawing instructions and pixels (paintPaintThe browser step that turns positioned boxes into pixels: text, colours, borders, shadows, images.), and hands the result to another thread that puts it on screen.
How Browsers Render explains each of those steps in depth. For the event loop, two consequences matter:
- Nothing you change shows up mid-task. Set
el.textContentten times in one handler and the user sees only the last value, after the handler returns. - One frame's work must fit in the gap. Your task, its microtasks, rAF callbacks, style, layout and paint all share the Frame budgetFrame budgetAt 60 frames per second each frame gets about 16.7 ms for JavaScript, style, layout, paint and compositing. Go over it and animations stutter (“jank”).: about 16.7 ms at 60 Hz, 8.3 ms at 120 Hz. Miss it and the frame isn't ready in time; the screen shows the old one again. That visible stutter is called JankJankVisible stutter: scrolling, typing or animation that skips or freezes because the browser couldn't produce frames in time..
rAF is not a task, and not a microtask
requestAnimationFrame callbacks run in the rendering step, which comes between tasks and only when a frame is due. So compared with a timer, there's no fixed order. I ran this in headless Chromium 153:
const log = console.log;
requestAnimationFrame(() => log("rAF"));
setTimeout(() => log("timeout"), 0);
Promise.resolve().then(() => log("promise"));
log("sync");sync
promise
timeout
rAFHere the timer won, because the next frame wasn't due yet when the timer task was picked. On another run, or another browser, rAF can come before timeout. The only fixed part is sync, then promise. Never use setTimeout(fn, 0) or setTimeout(fn, 16) to time visual updates. Timers aren't lined up with the screen, so you'll update twice in one frame and zero times in the next. Use rAF:
function step(now) { // now = this frame's timestamp, in ms
box.style.transform = `translateX(${(now / 4) % 300}px)`;
requestAnimationFrame(step); // ask again for the NEXT frame
}
requestAnimationFrame(step);Long tasks, jank, and breaking work up
A Long taskLong taskAny single piece of main-thread work that runs longer than 50 ms. While it runs, the page can't draw new frames or respond to clicks. is any single task over 50 ms. While it runs, the page can't draw frames and can't react to clicks or key presses. Google's INPINP (Interaction to Next Paint)A Core Web Vital: roughly, the slowest delay on a page between a user's tap, click or key press and the next frame that shows the result. Under 200 ms counts as good. metric measures exactly what the user feels: the time from a click, tap or key press until the next frame that shows a response. It reports roughly your page's slowest interaction.
Here's a 72 ms job, a click that arrives 20 ms in, and a 60 Hz screen. Try each strategy, then press Play slowly:
The numbers come from a simple model (fixed 3 ms render, input handled before other tasks), so read them as approximate. The shape is what real browsers do.
Strategy 1: chunk it with setTimeout
Process a slice, then schedule the rest as a new task. Between tasks the browser can draw and handle input:
function processAll(items, done) {
let i = 0;
function chunk() {
const stop = performance.now() + 8; // work for ~8 ms at most
while (i < items.length && performance.now() < stop) {
handle(items[i++]);
}
if (i < items.length) setTimeout(chunk, 0); // ← the rest is a NEW task
else done();
}
chunk();
}It works everywhere, but has two costs you saw in the widget:
-
Other tasks cut in line. Your next chunk goes to the back of the task queue. Here's the real order in headless Chromium 153 (top-level
awaitneeds an ES module or the DevTools console):const log = console.log; setTimeout(() => log("other task (queued first)"), 0); log("work part 1"); await new Promise((r) => setTimeout(r, 0)); // yield with a timer log("work part 2"); // → work part 1 | other task (queued first) | work part 2 -
The 4 ms clamp. Browsers apply Timer clampingTimer clampingThe browser rule that a setTimeout nested more than 5 levels deep (a timer set from a timer callback, again and again) waits at least 4 ms, even if you asked for 0.: once timers are nested more than 5 levels deep, each
setTimeout(fn, 0)waits at least 4 ms. Measured gaps between ten chainedsetTimeout(tick, 0)calls, in ms (Chromium 153):0.0 0.0 0.0 0.0 0.1 0.0 4.2 4.1 4.1 4.2With 8 ms chunks, that's a third of your time spent waiting. Do bigger chunks (time-boxed, as above), not one item per timer.
Strategy 2: yield with scheduler.yield()
scheduler.yieldscheduler.yield()A browser function that returns a promise you can await to give the main thread back for a moment. Your code then continues ahead of other waiting tasks. Supported in Chromium 129+ and Firefox 142+, not in Safari (as of November 2026). is made for exactly this. await scheduler.yield() gives the browser a gap to draw and handle input, but your continuation goes ahead of other queued tasks, so a chunked job finishes about as fast as an unchunked one:
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield(); // Chromium 129+, Firefox 142+ (Nov 2026)
return new Promise((resolve) => setTimeout(resolve, 0)); // everywhere else (Safari, as of Nov 2026)
}
async function processAll(items) {
let stop = performance.now() + 8;
for (const item of items) {
handle(item);
if (performance.now() > stop) { // used up ~8 ms? let the browser breathe
await yieldToMain();
stop = performance.now() + 8;
}
}
}The same ordering test, with scheduler.yield() instead of a timer (Chromium 153):
work part 1 | work part 2 (after scheduler.yield) | other task (queued first)Support, honestly (as of November 2026): scheduler.yield() shipped in Chrome and Edge 129 (September 2024) and Firefox 142 (August 2025). Safari doesn't support it yet. Always feature-detect as above, and check MDN for the current state.
Whichever you use, notice that yielding with await alone doesn't work: await null or awaiting an already-resolved promise queues a microtask, and microtasks run before rendering (§04). You must await something that settles in a new task.
Strategy 3: move it to a Web Worker
Chunking still uses the main thread's time. If the work is pure computation (parsing a big file, image processing, search indexing, crypto), run it on a Web WorkerWeb WorkerA background JavaScript thread in the browser. It can't touch the DOM but can do heavy work without freezing the main thread, talking to the page by messages.: a separate thread with its own event loop. It can't touch the page (no DOM), and it talks to the page by messages:
const worker = new Worker(new URL("./search-worker.js", import.meta.url), { type: "module" });
worker.onmessage = (e) => renderResults(e.data); // runs as a normal task later
input.addEventListener("input", () => {
worker.postMessage({ query: input.value }); // returns immediately
});Node.js: the libuv phases
Node.js runs your JavaScript on one thread, just like a browser, but the "host" part is a C library called libuvlibuvThe C library underneath Node.js that talks to the operating system: it runs the event loop, waits for network and file I/O, and keeps track of timers.. libuv's loop goes round a fixed cycle of phasesPhase (Node event loop)One stop on Node's loop: timers, pending callbacks, idle/prepare, poll, check, close. Each phase has its own queue of callbacks, and the loop visits them in that fixed order, round and round.. At each phase it runs the callbacks queued there, then moves on:
| Phase | Runs | You get here with |
|---|---|---|
| timers | callbacks whose timer is due | setTimeout, setInterval |
| pending callbacks | a few I/O callbacks deferred from last round (e.g. some network errors) | rarely matters |
| idle, prepare | Node internals | — |
| poll | callbacks for finished I/O; waits here when there's nothing else to do | fs.readFile, sockets, HTTP |
| check | setImmediate callbacks | setImmediate |
| close callbacks | 'close' event handlers | socket.destroy() etc. |
The poll phase is where a quiet server spends its life: libuv asks the OS (with epoll / kqueue / IOCPepoll / kqueue / IOCPOS features that let one thread say “here are 10,000 connections — wake me when any of them has data”. epoll is Linux, kqueue is macOS/BSD, IOCP is Windows. on Linux) "wake me when any of these connections has data, or when the next timer is due", and the thread sleeps without using any CPU.
setImmediatesetImmediateA Node function that runs a callback in the check phase, right after the loop has handled finished I/O. Think “as soon as this round of I/O is done”. is the one new API here. It means "run this right after the current poll phase". Despite the name, it's no more immediate than a promise; it's a task in the check phase.
Step through both programs. You can also tap an API under the wheel to see where it runs:
1const fs = require("node:fs");23fs.readFile(__filename, () => {4 console.log("1 readFile callback");5 setTimeout(() => console.log("6 timeout"), 0);6 setImmediate(() => console.log("5 immediate"));7 Promise.resolve().then(() => console.log("4 promise"));8 process.nextTick(() => console.log("3 nextTick"));9 console.log("2 still in callback");10});
fs.readFile hands the read to libuv's thread pool (§09) and returns immediately.When is setTimeout vs setImmediate order guaranteed?
Inside an I/O callback: always setImmediate first. The callback runs in poll; the next stop is check; timers only come round after close. I ran this 30 times:
const fs = require("node:fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
}); 30 immediateIn the main script: not guaranteed. setTimeout(fn, 0) is really 1 ms in Node. When the loop first reaches the timers phase, that 1 ms may or may not have passed, depending on how quickly the process started:
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate")); 27 immediate
3 timeoutYour split will differ (a faster or busier machine changes it), but you'll likely see both answers if you run it enough. Add 5 ms of busy work at the end of the script and the timer is due by the time the loop starts: on my machine timeout won 20 runs out of 20. Don't build logic on this order. If B must run after A, schedule B from inside A.
Microtasks between every callback
Since Node 11, after each timer or immediate callback (not after the whole phase), Node empties the nextTick queue and the microtask queue:
setImmediate(() => {
console.log("immediate A");
process.nextTick(() => console.log(" nextTick after A"));
Promise.resolve().then(() => console.log(" promise after A"));
});
setImmediate(() => console.log("immediate B"));immediate A
nextTick after A
promise after A
immediate B(process.nextTick gets its own section next.)
process.nextTick: Node's queue before the microtasks
process.nextTickprocess.nextTickA Node-only function that queues a callback in a special queue Node empties right after the current callback finishes, even before promise callbacks. is older than promises in Node. It was built so library code could say "call the user back after the current function returns, but before any I/O happens". Today it sits just in front of the promise queue:
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("sync");sync
nextTick
promiseThe name is misleading on two counts: it's not the next tick of the loop (it runs before the loop moves on at all), and it runs before setImmediate, whose name sounds sooner. Node's own docs admit the names are swapped and can't be fixed now.
The ES module surprise
Rename that file to tick.mjs, change nothing else, and the output flips:
sync
promise
nextTickWhy? Node loads an ES moduleES module (ESM)JavaScript's standard module system: import and export statements, resolved before the code runs. Works natively in browsers and modern Node. asynchronously, so its top-level code runs inside promise machinery. When that code finishes, V8 is already in the middle of draining promise microtasks, and the promise callback you queued joins that drain. Node only gets back to its nextTick queue afterwards. In a CommonJS (.cjs) file, the top-level code is a plain function call, so Node's usual order applies.
You don't need to remember the mechanism, only the lesson: code that depends on nextTick-vs-promise order is fragile. It can change with something as small as a file extension.
nextTick can starve the loop too
Recursive nextTick calls block I/O exactly like recursive microtasks (§04): Node won't leave the nextTick queue until it's empty. The danger is the same, it's just a different queue.
The one pattern where nextTick is still the right tool: an object that emits an event from its constructor. Without deferring, the event fires before the caller had a chance to attach a listener:
const { EventEmitter } = require("node:events");
class Job extends EventEmitter {
constructor() {
super();
// this.emit("ready"); // ← too early: nobody is listening yet
process.nextTick(() => this.emit("ready")); // ← runs after the caller's line finishes
}
}
const job = new Job();
job.on("ready", () => console.log("ready!")); // attached in timeready!Node's hidden threads: the libuv thread pool
For network sockets, the OS can say "wake me when there's data" (epoll / kqueue / IOCPepoll / kqueue / IOCPOS features that let one thread say “here are 10,000 connections — wake me when any of them has data”. epoll is Linux, kqueue is macOS/BSD, IOCP is Windows. and friends), so libuv needs no extra threads: one thread can watch 10,000 connections. But some operations have no such "tell me later" version, or are just slow CPU work. libuv runs those on the libuv thread poollibuv thread poolA small pool of background threads (4 by default) inside every Node process. Node uses it for work the OS can't do asynchronously: file system calls, DNS lookups, some crypto and zlib compression., then queues your callback for the poll phase when a pool thread finishes:
| Uses the thread pool | Doesn't |
|---|---|
Most fs calls (readFile, stat, …) | Sending and receiving on sockets (TCP, UDP, HTTP, fetch traffic) |
dns.lookup, which http, net and fetch call to turn a hostname into an IP address | dns.resolve* (talks to DNS servers over the network) |
crypto.pbkdf2, scrypt, randomBytes (async versions) | Timers |
zlib compression (async versions) | Your JavaScript |
Threads & Processes covers this pool next to worker_threads and child_process. Here's what it means for the event loop.
Four threads, eight jobs
Eight slow password hashes (PBKDF2PBKDF2An old, standardised way to make a slow password hash: run HMAC hundreds of thousands of times. Not memory-hard, so GPUs crack it far faster than Argon2id; use it when compliance (FIPS) requires it.) started at once, on a 4-core machine:
const { pbkdf2 } = require("node:crypto");
const start = Date.now();
for (let i = 1; i <= 8; i++) {
pbkdf2("secret", "salt", 200_000, 64, "sha512", () => {
console.log(`hash ${i} done at ${Date.now() - start} ms`);
});
}hash 1 done at 116 ms
hash 2 done at 120 ms
hash 4 done at 131 ms
hash 3 done at 149 ms
hash 5 done at 233 ms
hash 6 done at 242 ms
hash 7 done at 251 ms
hash 8 done at 272 msYour times will differ, and the finishing order shuffles from run to run. Look for the jump after the 4th hash: the first four finish together, and hashes 5–8 finish roughly one hash-time later, because they waited for a free pool thread. (On a busy machine the gap can blur.) The event loop itself was never blocked: the main thread just sat in the poll phase waiting for callbacks.
The pool is shared by everything that uses it. Watch a tiny file read get stuck behind four hashes, while a timer is unaffected:
const { pbkdf2 } = require("node:crypto");
const { readFile } = require("node:fs");
const start = Date.now();
const t = () => `${Date.now() - start} ms`;
for (let i = 1; i <= 4; i++) {
pbkdf2("secret", "salt", 400_000, 64, "sha512", () => console.log(`hash ${i} done at ${t()}`));
}
readFile(__filename, () => console.log(`tiny readFile done at ${t()}`)); // ← waits for a free pool thread
setTimeout(() => console.log(`setTimeout 10 ms fired at ${t()}`), 10); // ← timers don't use the poolsetTimeout 10 ms fired at 13 ms
hash 4 done at 233 ms
tiny readFile done at 233 ms
hash 3 done at 256 ms
hash 1 done at 281 ms
hash 2 done at 287 msReading a few hundred bytes took 233 ms. In production this looks like "the disk is slow" or "DNS is slow" when really the pool is busy hashing passwords.
Raising the pool size
Set UV_THREADPOOL_SIZE before Node starts (default 4, max 1024). With 8 threads, the first script's hashes no longer queue, but on 4 cores they don't finish sooner either:
hash 1 done at 170 ms
hash 8 done at 256 ms
hash 6 done at 261 ms
hash 3 done at 263 ms
hash 5 done at 274 ms
hash 7 done at 278 ms
hash 2 done at 294 ms
hash 4 done at 300 msAll eight are in progress at once, sharing 4 cores, so each takes longer. A bigger pool helps when pool jobs mostly wait (slow disks, network file systems, slow DNS). It doesn't add CPU.
Blocking the loop, and how to spot it
Every request a Node server handles shares the one JavaScript thread. If one request's handler computes for 500 ms, every other request (health checks included) waits 500 ms before its next callback can run. The usual suspects:
| Blocker | Why it blocks | Fix |
|---|---|---|
| A big loop or sort over a huge array | Pure CPU-boundCPU-boundWork where the CPU itself is the bottleneck — calculating, compressing, parsing. Only more or faster cores help. JavaScript | Chunk with setImmediate, move to a Worker threadWorker threadAn extra JavaScript thread you create on purpose (Web Workers in browsers, worker_threads in Node) to run CPU-heavy code without freezing the main thread., or do less |
JSON.parse / JSON.stringify of large payloads | Synchronous, and grows with size | Smaller payloads, pagination, streaming parsers, a worker |
fs.readFileSync, execSync, crypto.pbkdf2Sync… | *Sync APIs run on the main thread, not the pool | The callback or promise versions |
| A regular expression with catastrophic backtracking | One regex.test() can take seconds on a crafted input | Safer patterns, length limits (Regular Expressions) |
| Huge numbers of tiny promise callbacks | Microtasks run before any I/O (§04) | Batch work; yield with setImmediate |
Here's the same route written both ways. app is an Express web server: app.get(path, handler) calls handler(req, res) for each matching request.
// ✗ blocks every request while the file is read
import express from "express";
import fs from "node:fs";
const app = express();
app.get("/config", (req, res) => {
const text = fs.readFileSync("config.json", "utf8");
res.json(JSON.parse(text));
});// ✓ the read happens on the pool; the loop keeps serving
import express from "express";
import { readFile } from "node:fs/promises";
const app = express();
app.get("/config", async (req, res) => {
try {
const text = await readFile("config.json", "utf8");
res.json(JSON.parse(text)); // still sync: fine for small files
} catch (err) { // missing file or bad JSON
res.status(500).json({ error: "config unavailable" });
}
});Why the try/catch? If the file is missing, await readFile throws. Express 5 passes an async handler's error to its error handler, but Express 4 doesn't, and an Unhandled rejectionUnhandled rejectionA promise failed and no code ever caught the error (no await in try/catch, no .catch). Node ≥ 15 crashes the process when this happens. crashes the whole process (§12, pitfall #7). Catching it yourself works in both.
Measure it: how late are the timers?
The simplest lag meter is a timer that checks how late it is. If the loop is free, a 100 ms interval fires every ~100 ms. If something blocks the loop, it fires late by about as long as the block:
// A tiny lag meter: ask for a tick every 100 ms and see how late it is.
let last = performance.now();
const timer = setInterval(() => {
const now = performance.now();
const late = Math.max(0, now - last - 100); // how much later than planned
console.log(`tick late by ${late.toFixed(0)} ms`);
last = now;
}, 100);
setTimeout(() => { // at ~250 ms, block for 300 ms
const end = performance.now() + 300;
while (performance.now() < end) {} // busy loop: nothing else can run
}, 250);
setTimeout(() => clearInterval(timer), 800);tick late by 1 ms
tick late by 1 ms
tick late by 249 ms
tick late by 1 ms
tick late by 1 msThe tick due at 300 ms couldn't run until the busy loop ended at ~550 ms. This number has a name: Event-loop delayEvent-loop delayHow late Node runs timers and callbacks because the one main thread is busy with other code. A large delay means the process is CPU-overloaded., also called event-loop lag.
Measure it properly: perf_hooks
Node has this built in, with a HistogramHistogram (metric)A metric that records how many measurements fell into each range (0–10 ms, 10–50 ms, …), so you can later compute percentiles like p50 and p99. so you get percentilesPercentileThe value below which a given share of measurements fall: p50 is the median; p99 is the value that 99% of requests are at or under. Used for latency because averages hide slow outliers. rather than one reading. Here it catches a 33 MB JSON.parse, plus Event loop utilizationEvent loop utilization (ELU)The share of time the Node event loop spent running code rather than sitting idle waiting for I/O. 0.9 means busy 90% of the time. Read it with performance.eventLoopUtilization().:
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
// A ~33 MB JSON string, like a big API response.
const rows = Array.from({ length: 600_000 }, (_, i) => ({ id: i, name: `user ${i}`, tags: ["a", "b", "c"] }));
const big = JSON.stringify(rows);
const h = monitorEventLoopDelay({ resolution: 10 }); // check the loop every 10 ms
h.enable();
const elu0 = performance.eventLoopUtilization(); // starting point for "how busy"
setTimeout(() => {
const t0 = performance.now();
JSON.parse(big); // ← synchronous: blocks the loop
console.log(`JSON.parse took ${(performance.now() - t0).toFixed(0)} ms`);
}, 100);
setTimeout(() => {
h.disable();
const ms = (ns) => (ns / 1e6).toFixed(0); // the histogram stores nanoseconds
console.log(`loop delay: min ${ms(h.min)} ms, median ${ms(h.percentile(50))} ms, max ${ms(h.max)} ms`);
const { utilization } = performance.eventLoopUtilization(elu0);
console.log(`loop busy: ${(utilization * 100).toFixed(0)}% of the last second`);
}, 1000);JSON.parse took 477 ms
loop delay: min 10 ms, median 10 ms, max 488 ms
loop busy: 50% of the last secondEvent loop utilization is the other half: the share of time the loop spent running code rather than waiting in the poll phase. A slow API with low utilization is waiting on something (a database, another service). A slow API with utilization near 100% is computing on the main thread, and adding more awaits won't help.
How Go does it differently
Under the hood Go does something event-loop-like: its NetpollerNetpollerThe part of the Go runtime that uses epoll/kqueue to wait for network data, so goroutines can “block” on the network without blocking an OS thread. uses the same epoll trick as libuv to wait on thousands of sockets. The difference is what your code sees, and what happens when something computes for a long time. (New to Go syntax? See the Go primer.)
Here's the lag test from §10 in Go: a goroutine burns CPU for 500 ms while the main goroutine waits for a ticker:
package main
import (
"fmt"
"time"
)
func busy(d time.Duration) { // burn CPU, like the while loop in JS
end := time.Now().Add(d)
for time.Now().Before(end) {
}
}
func main() {
start := time.Now()
go busy(500 * time.Millisecond) // CPU-heavy work in its own goroutine
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for range 3 { // Go 1.22+: loop 3 times
<-ticker.C // wait for the next tick
fmt.Printf("tick at %d ms\n", time.Since(start).Milliseconds())
}
}tick at 100 ms
tick at 200 ms
tick at 301 mstick at 116 ms
tick at 217 ms
tick at 319 msOn 4 cores, the busy goroutine simply runs on another core: the ticks are on time. The interesting run is the second one, GOMAXPROCS=1, which gives Go one thread for Go code, like JavaScript. The ticks are still only about 5–20 ms late (it varies from run to run), not 500 ms, even though the busy goroutine runs the whole time. Go's scheduler uses PreemptionPreemptionForcibly pausing a running task so others get a turn. The OS does this to threads when their time slice ends; Go's runtime does the same to goroutines that hog a CPU for more than ~10 ms.: it interrupts a goroutine that has run for about 10 ms so others get a turn (Go 1.14+). JavaScript never does that; a busy loop keeps the thread until it returns (§02).
| JavaScript (browser / Node) | Go | |
|---|---|---|
| Threads running your code | 1 per tab / process (plus workers you create) | all cores (GOMAXPROCS, default = number of cores, or the container's CPU limit in Go 1.25+) |
| How waiting looks | await, callbacks: you mark every pause | plain blocking calls: the runtime parks the goroutine |
| A long computation | blocks everything on that thread | runs on its own core; preempted after ~10 ms if needed |
| Two pieces of code touching one variable at the same instant | impossible (run-to-completion), though state can still change across an await | possible: use channels or mutexes, and run the race detector |
| Ordering puzzles | microtasks vs tasks vs phases | no queues to reason about; use channels to order things |
The fourth row is the trade-off. JavaScript's single thread gives you safety: no two callbacks ever touch the same variable at the same instant. Go gives you all your cores, and in exchange you have to think about two goroutines touching the same data.
For the full picture, see Async / Await §09: Go's model and Goroutines & the Go Scheduler.
Pitfalls
Thinking a Promise moves work off the thread
Wrapping CPU work in a promise or an async function doesn't run it "in the background". The new Promise setup function runs synchronously, right now, on the same thread:
// ✗ still blocks for the whole computation
function hashAll(files) {
return new Promise((resolve) => {
resolve(files.map(slowHash)); // runs NOW, on the main thread
});
}// ✓ actually off the main thread (Node shown; Web Worker in browsers)
import { Worker } from "node:worker_threads";
function hashAll(files) {
return new Promise((resolve, reject) => {
const w = new Worker(new URL("./hash-worker.js", import.meta.url), { workerData: files });
w.once("message", resolve);
w.once("error", reject);
w.once("exit", (code) => reject(new Error(`worker exited (${code})`))); // no result? (ignored once resolved)
});
}The worker file itself is short. It gets files as workerData, does the slow part on its own thread, and posts the result back:
import { parentPort, workerData } from "node:worker_threads";
import { slowHash } from "./slow-hash.js";
parentPort.postMessage(workerData.map(slowHash)); // the main thread's "message" handler gets thisPromises and async organize waiting. Only another thread (a worker) or chunking (§06) helps with computing.
Expecting setTimeout to be on time
A timer is a minimum delay. It runs late if the loop is busy (§10), if timers are nested in a browser (≥ 4 ms clamp, §06), or if the tab is in the background (browsers slow background timers to about once per second, sometimes less). setInterval(fn, 1000) drifts and can bunch up after a block. For a clock or countdown, compute from the real time on each tick (Date.now() - startedAt) instead of counting ticks.
Animating with timers
setTimeout(draw, 16) isn't lined up with the screen's refresh, so some frames get two updates and some get none: visible stutter. Use requestAnimationFrame (§05), and better still, animate with CSS transform/opacity, which the browser can run without the main thread.
A loop with await that still freezes
// ✗ "it has an await, so it yields"… it doesn't
async function processAll(items) {
for (const item of items) {
await handle(item); // handle() is async but does no real I/O → resolves at once
}
}Each await on an already-settled promise is a microtask, and microtasks run before rendering, timers and I/O (§04). For 100,000 items, the page freezes just as hard as with a plain loop. Yield with something that waits for a task: scheduler.yield() or a setTimeout promise in browsers, setImmediate in Node (§06, §07).
Using a *Sync API in a server
readFileSync, execSync, pbkdf2Sync and friends are fine in a CLI script or at startup. In a request handler, each call blocks every user of the process for its whole duration (§10). A linter rule like n/no-sync catches them.
Tests that depend on tick order
A test that passes because "the promise resolves before the timer" breaks when someone adds one more await somewhere, or when the code moves from .cjs to .mjs (§08). Await the thing you're waiting for, or use Fake timersFake timersA test-controlled replacement for setTimeout, setInterval and Date. Time only moves when the test says so (e.g. vi.advanceTimersByTime(300)), so a 5-minute timeout can be tested in a millisecond. to control time explicitly. With fake timers, remember that promise callbacks still need to run: use the async versions (await vi.advanceTimersByTimeAsync(100) in Vitest, jest.advanceTimersByTimeAsync in Jest).
An unhandled error in a callback
An error thrown inside a setTimeout callback or an event handler can't be caught by a try/catch around the code that scheduled it: by the time it runs, that code has long finished and the stack is different. In Node, an uncaught exception or an Unhandled rejectionUnhandled rejectionA promise failed and no code ever caught the error (no await in try/catch, no .catch). Node ≥ 15 crashes the process when this happens. crashes the process (Node ≥ 15). Catch errors inside the callback itself, and always await or .catch() your promises.
Check yourself
First, the classic interview format: put the lines in the order they print. Every answer was checked by running it (Node 22). Puzzles 1–2 use §03–§04; puzzles 3–4 use §07–§08.
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve()
.then(() => console.log("C"))
.then(() => console.log("D"));
queueMicrotask(() => console.log("E"));
console.log("F");Then a few questions about the why. Commit to an answer before you click:
setTimeout(g, 0) is called first, then Promise.resolve().then(f). Why does f run before g?
A click handler runs a 2-second calculation and the page freezes. Which change actually stops the freeze?
Inside an fs.readFile callback you call setTimeout(a, 0) and then setImmediate(b). Which runs first?
A Node API's monitorEventLoopDelay p99 jumps from ~10 ms to 900 ms while CPU sits at 100%. What's the most likely cause?
function spin() { queueMicrotask(spin); } spin(); What happens to a setTimeout(log, 0) scheduled earlier?
In a file named app.mjs: process.nextTick(() => log("tick")); Promise.resolve().then(() => log("promise")); What prints first?
Cheat sheet
setTimeout(fn, 0) is a task: after the current code and every microtask. ≥ 1 ms in Node, ≥ 4 ms in browsers when nested more than 5 deep.await; the rest comes back as a microtask once the promise settles.requestAnimationFrame.scheduler.yield() / setTimeout) if it needs the DOM; a Web Worker if it doesn't.setImmediate) → close → repeat.monitorEventLoopDelay + eventLoopUtilization in Node; long tasks and INP in browsers.| Want to… | Use | Runs |
|---|---|---|
| Run something right after the current code | queueMicrotask(fn) / await | microtask queue |
| Update the screen | requestAnimationFrame(fn) | before the next paint (browser) |
| Let the browser draw and handle input, then continue | await scheduler.yield() (fallback: setTimeout promise) | new task |
| Run after pending I/O in Node | setImmediate(fn) | check phase |
| Defer an event until the caller attached listeners (Node libraries) | process.nextTick(fn) | nextTick queue, before promises |
| CPU-heavy work | Web Worker / worker_threads | another thread |
| Background work in a browser when idle | requestIdleCallback(fn) (not in stable Safari as of Nov 2026; fall back to setTimeout) | idle periods |
| Order, after any callback, in Node | Order in a browser turn |
|---|---|
| 1. nextTick queue (until empty) | 1. one task |
| 2. promise microtasks (until empty) | 2. all microtasks |
| 3. repeat 1–2 until both are empty, then the next callback or phase | 3. if a frame is due: rAF → style → layout → paint |