Skip to content
Software Atlas

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.

⏱ ~40 min read14 sections6 interactive widgetsJavaScript · Node · a little Go
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceJavaScript has one thread and a to-do list. The Event loop is the rule for what to do next: finish the current job, then every tiny follow-up job, then (in a browser) maybe redraw the screen, then pick the next job.

A Thread 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.

1

One thing at a time

Every piece of JavaScript runs to completion. Nothing interrupts it, not even a click.
2

Waiting is outsourced

Timers, network and disk are handled by the browser or Node. Your code is called back when they finish.
3

Two queues, strict order

After each task, all microtasks (promise callbacks, code after await) run before the next task (timers, clicks, I/O).

That third rule is why this classic puzzle surprises people:

order.js
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");
node order.js (Node 22)
1: script start
2: script end
3: promise
4: microtask
5: timeout

The 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.
Foundations§02

The call stack and run-to-completion

In one sentenceJavaScript keeps a stack of the functions that are running. Only the top one runs. When the stack is empty, the event loop may start something new, and not a moment before.

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 stack: a pile of frames, 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:

The call stack, one frame at a time
Step with ← →. Only the top frame runs.
Code
1function greet(name) {
2 return "Hi, " + shout(name);
3}
4
5function shout(s) {
6 return s.toUpperCase() + "!";
7}
8
9console.log(greet("ada"));
Call stacktop runs
script (main)
Console
(no output yet)
The engine starts running the file. The whole script is the first thing on the call stack: the list of functions that are running right now, newest on top.
1 / 8

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 completion.

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:

busy-click.js (browser)
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).

A click arrives while the stack is busy
JS thread
click handler #1 (2 s busy loop)
click #2
draw
user
clicked again… nothing happens
The second click happened at 0.5 s but its handler can't start until the first one returns at 2 s. Nothing can cut in.

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 Host (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.

Three downloads, one thread
JS thread
start
A done
C done
B done
download A
waiting (host)
download B
waiting (host)
download C
waiting (host)
The JS thread (top) only works in short bursts. The long waits (dashed) happen in the host, in parallel. To the user, all three downloads are in progress at once.

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 multitasking, and it only works if nobody hogs the cook.

Foundations§03

Two queues: tasks and microtasks

In one sentenceThe loop's whole algorithm: take one task → run every microtask → repeat. Timers, clicks and I/O are tasks. Promise callbacks are microtasks. So a promise callback always beats a timer, even a "0 ms" one.

When the Host (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:

T

Task queue

Bigger jobs: the script itself, timer callbacks (setTimeout, setInterval), click and key handlers, network and file callbacks. The loop takes one per turn. They're also called macrotasks.
μ

Microtask queue

Small follow-ups: promise .then/.catch/.finally callbacks, the code after an await, and queueMicrotask(fn). After every task, the loop runs them all.
S

Call stack

Whatever is running right now (§02). The loop only looks at the queues when the stack is empty.

(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:

the-event-loop.pseudo.js
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 Microtask 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:

Event loop visualizer
Pick a snippet, guess the output, then step with ← →.
Code
1console.log("A");
2setTimeout(() => console.log("B"), 0);
3Promise.resolve().then(() => console.log("C"));
4queueMicrotask(() => console.log("D"));
5console.log("E");
Call stack
script (main)
Hostbrowser / Node
nothing waiting
Microtasksall
empty
Tasks1 per turn
empty
Console
(no output yet)
The whole script is the first task. It goes on the call stack and runs to completion: start to finish, no interruptions.
1 / 13

The Nested snippet shows the two rules that trip people up. Here's that code with its real output:

nested.js
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");
node nested.js (Node 22; Chrome prints the same)
sync
P1
P2
T1
P-in-T1
T2
  1. 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.
  2. 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.
Practitioner§04

await, continuations and microtask starvation

In one sentenceawait splits a function in two. The first part runs now; the rest (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:

interleave.mjs
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");
node interleave.mjs (Node 22)
A: start
B: start
script end
A: after 1st await
B: after 1st await
A: after 2nd await
B: after 2nd await
timeout

Rule 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:

sleep0.mjs
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");
node sleep0.mjs (Node 22)
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 Starvation: 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:

starving the timer, then not
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 microtasks

Notice 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.

Practitioner§05

The browser loop: task, microtasks, maybe render

In one sentenceIn a browser, the same thread that runs your JavaScript also draws the page. It can only draw between tasks, so every task you run is time the screen can't update.

The browser's JavaScript thread is called the Main thread. Besides your code, it does most of the work of turning the page into pixels. A screen shows a new Frame 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 rate). 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:

One turn of the browser's loop, with a frame
main thread
task (e.g. click)
all microtasks
rAF callbacks
style + layout
paint
Simplified. Rendering only happens between tasks, and only when a frame is due: not after every task. Microtasks also run after each rAF callback.
  1. requestAnimationFrame callbacks. requestAnimationFrame (rAF for short) means "call me right before the next frame is drawn". It's the right place to change anything visual.
  2. Style and layout. The browser works out which CSS applies to each element, then the size and position of every box (layout).
  3. Paint. It turns the boxes into drawing instructions and pixels (paint), 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.textContent ten 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 budget: 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 Jank.

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:

raf-order.js (browser)
const log = console.log;
requestAnimationFrame(() => log("rAF"));
setTimeout(() => log("timeout"), 0);
Promise.resolve().then(() => log("promise"));
log("sync");
console, headless Chromium 153
sync
promise
timeout
rAF

Here 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:

animate.js (browser)
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);
Practitioner§06

Long tasks, jank, and breaking work up

In one sentenceYou can't make the browser draw during your code. So if work takes longer than a frame, cut it into pieces and give the thread back between them, or move it to another thread.

A Long task 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 INP 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:

Frame budget: one long task vs breaking it up
A 72 ms job, a click at 20 ms, a 60 Hz screen. Approximate model.
frames✓✗✗✗✓✓✓✓mainwork · 72 msworkernot usedinputclickpainted (+70 ms)020406080100120 ms
What the user sees
The ball only moves when a frame is drawn.
3 / 8
frames missed
70 ms
click → paint
75 ms
job finished
your jobrAF + style + layout + paintclick handlersomeone else's taskpostMessage
The 72 ms job runs as one task. Frames 1–3 can't be drawn (red ticks), the ball freezes, and the click at 20 ms waits until the job is done: about 70 ms before the user sees any response.

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:

chunk-timeout.js (browser)
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:

  1. 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 await needs 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
  2. The 4 ms clamp. Browsers apply Timer clamping: once timers are nested more than 5 levels deep, each setTimeout(fn, 0) waits at least 4 ms. Measured gaps between ten chained setTimeout(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.2

    With 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.yield 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:

chunk-yield.js (browser)
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 Worker: a separate thread with its own event loop. It can't touch the page (no DOM), and it talks to the page by messages:

a Web Worker
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
});
Practitioner§07

Node.js: the libuv phases

In one sentenceNode's task queue isn't one line: it's a wheel of stops called phases. Each stop has its own queue. The microtask rules from §03 still apply, after every single callback.

Node.js runs your JavaScript on one thread, just like a browser, but the "host" part is a C library called libuv. libuv's loop goes round a fixed cycle of phases. At each phase it runs the callbacks queued there, then moves on:

PhaseRunsYou get here with
timerscallbacks whose timer is duesetTimeout, setInterval
pending callbacksa few I/O callbacks deferred from last round (e.g. some network errors)rarely matters
idle, prepareNode internals—
pollcallbacks for finished I/O; waits here when there's nothing else to dofs.readFile, sockets, HTTP
checksetImmediate callbackssetImmediate
close callbacks'close' event handlerssocket.destroy() etc.

The poll phase is where a quiet server spends its life: libuv asks the OS (with epoll / kqueue / IOCP 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.

setImmediate 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:

Node's event loop: the phases wheel
Step through a program, or tap an API to see where it runs.
timerssetTimeoutpendingOS errorsidleinternalpollI/O callbackschecksetImmediateclose'close' eventsafter each callback:1 · nextTick queue2 · microtasks
1const fs = require("node:fs");
2
3fs.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});
Thread pool
read file
timers queue
empty
check queue
empty
1 · nextTick queue
empty
2 · microtasks
empty
Console
(nothing yet)
The main script runs first, before the loop even starts. fs.readFile hands the read to libuv's thread pool (§09) and returns immediately.
1 / 11

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:

in-io.cjs
const fs = require("node:fs");
fs.readFile(__filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});
first line of output, 30 runs, on my machine (Node 22, 4 cores)
     30 immediate

In 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:

in-main.cjs
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
first line of output, 30 runs, on my machine (Node 22, 4 cores)
     27 immediate
      3 timeout

Your 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:

between.cjs
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"));
node between.cjs (Node 22)
immediate A
  nextTick after A
  promise after A
immediate B

(process.nextTick gets its own section next.)

Practitioner§08

process.nextTick: Node's queue before the microtasks

In one sentenceNode has two "run right after this" queues. After each callback, it empties the nextTick queue first, then the promise microtask queue, and repeats until both are empty.

process.nextTick 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:

tick.cjs
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("sync");
node tick.cjs (Node 22)
sync
nextTick
promise

The 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:

node tick.mjs (Node 22)
sync
promise
nextTick

Why? Node loads an ES module 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:

emit-later.cjs
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 time
node emit-later.cjs (Node 22)
ready!
Staff-level§09

Node's hidden threads: the libuv thread pool

In one sentence"Node is single-threaded" means your JavaScript is. Under it, libuv keeps a small pool of threads for work the OS can't do in the background, and that pool can become a hidden bottleneck.

For network sockets, the OS can say "wake me when there's data" (epoll / kqueue / IOCP 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 pool, then queues your callback for the poll phase when a pool thread finishes:

Uses the thread poolDoesn'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 addressdns.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 (PBKDF2) started at once, on a 4-core machine:

pool.cjs
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`);
  });
}
node pool.cjs, on my machine (Node 22, 4 cores)
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 ms

Your 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:

pool-fs.cjs
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 pool
node pool-fs.cjs, on my machine (Node 22, 4 cores)
setTimeout 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 ms

Reading 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:

UV_THREADPOOL_SIZE=8 node pool.cjs, on my machine (Node 22, 4 cores)
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 ms

All 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.

Staff-level§10

Blocking the loop, and how to spot it

In one sentenceOn a server, a blocked loop doesn't freeze one user's page: it freezes every request in the process. Waiting is free; computing on the main thread is what costs you.

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:

BlockerWhy it blocksFix
A big loop or sort over a huge arrayPure CPU-bound JavaScriptChunk with setImmediate, move to a Worker thread, or do less
JSON.parse / JSON.stringify of large payloadsSynchronous, and grows with sizeSmaller payloads, pagination, streaming parsers, a worker
fs.readFileSync, execSync, crypto.pbkdf2Sync…*Sync APIs run on the main thread, not the poolThe callback or promise versions
A regular expression with catastrophic backtrackingOne regex.test() can take seconds on a crafted inputSafer patterns, length limits (Regular Expressions)
Huge numbers of tiny promise callbacksMicrotasks 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.

✕ Don't
// ✗ 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));
});
✓ Do
// ✓ 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 rejection 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:

lag.mjs
// 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);
node lag.mjs (Node 22, on my machine: your numbers will differ)
tick late by 1 ms
tick late by 1 ms
tick late by 249 ms
tick late by 1 ms
tick late by 1 ms

The tick due at 300 ms couldn't run until the busy loop ended at ~550 ms. This number has a name: Event-loop delay, also called event-loop lag.

Measure it properly: perf_hooks

Node has this built in, with a Histogram so you get percentiles rather than one reading. Here it catches a 33 MB JSON.parse, plus Event loop utilization:

monitor.mjs
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);
node monitor.mjs (Node 22, on my machine: your numbers will differ)
JSON.parse took 477 ms
loop delay: min 10 ms, median 10 ms, max 488 ms
loop busy: 50% of the last second

Event 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.

Practitioner§11

How Go does it differently

In one sentenceGo has no event loop in your code. You write blocking code in many goroutines, and the Go runtime runs them on all CPU cores, pausing any that run too long.

Under the hood Go does something event-loop-like: its Netpoller 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:

main.go
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())
	}
}
go run main.go, on my machine (Go 1.24, 4 cores)
tick at 100 ms
tick at 200 ms
tick at 301 ms
GOMAXPROCS=1 go run main.go (one thread for Go code), on my machine
tick at 116 ms
tick at 217 ms
tick at 319 ms

On 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 Preemption: 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 code1 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 looksawait, callbacks: you mark every pauseplain blocking calls: the runtime parks the goroutine
A long computationblocks everything on that threadruns on its own core; preempted after ~10 ms if needed
Two pieces of code touching one variable at the same instantimpossible (run-to-completion), though state can still change across an awaitpossible: use channels or mutexes, and run the race detector
Ordering puzzlesmicrotasks vs tasks vs phasesno 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.

Practitioner§12

Pitfalls

#1

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:

✕ Don't
// ✗ still blocks for the whole computation
function hashAll(files) {
  return new Promise((resolve) => {
    resolve(files.map(slowHash));   // runs NOW, on the main thread
  });
}
✓ Do
// ✓ 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:

hash-worker.js
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 this

Promises and async organize waiting. Only another thread (a worker) or chunking (§06) helps with computing.

#2

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.

#3

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.

#4

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).

#5

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.

#6

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 timers 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).

#7

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 rejection crashes the process (Node ≥ 15). Catch errors inside the callback itself, and always await or .catch() your promises.

Practitioner§13

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.

Predict the output
Tap the lines in the order you think they print, then check.
Chained .thenruns in: Node 22 or any browser
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");
Lines to place
Your predicted output
tap lines above…

Then a few questions about the why. Commit to an answer before you click:

QUESTION 1 / 6
Why does the promise win?

setTimeout(g, 0) is called first, then Promise.resolve().then(f). Why does f run before g?

QUESTION 2 / 6
The frozen button

A click handler runs a 2-second calculation and the page freezes. Which change actually stops the freeze?

QUESTION 3 / 6
Inside fs.readFile's callback

Inside an fs.readFile callback you call setTimeout(a, 0) and then setImmediate(b). Which runs first?

QUESTION 4 / 6
Reading the delay histogram

A Node API's monitorEventLoopDelay p99 jumps from ~10 ms to 900 ms while CPU sits at 100%. What's the most likely cause?

QUESTION 5 / 6
The microtask that never stops

function spin() { queueMicrotask(spin); } spin(); What happens to a setTimeout(log, 0) scheduled earlier?

QUESTION 6 / 6
ES modules and nextTick

In a file named app.mjs: process.nextTick(() => log("tick")); Promise.resolve().then(() => log("promise")); What prints first?

Answered 0 / 6
Foundations§14

Cheat sheet

🔁
The loopOne task → all microtasks (new ones too) → maybe render → next task.
🧱
Run to completionNothing interrupts running JavaScript. A long task freezes clicks, timers and drawing.
⏱️
0 ms ≠ nowsetTimeout(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.
⏸️
awaitRuns sync until the first await; the rest comes back as a microtask once the promise settles.
🖼️
Frames~16.7 ms per frame at 60 Hz, 8.3 ms at 120 Hz. Visual changes go in requestAnimationFrame.
✂️
Long workChunk it and yield (scheduler.yield() / setTimeout) if it needs the DOM; a Web Worker if it doesn't.
🎡
Node phasestimers → pending → poll (waits here) → check (setImmediate) → close → repeat.
📊
MeasuremonitorEventLoopDelay + eventLoopUtilization in Node; long tasks and INP in browsers.
Want to…UseRuns
Run something right after the current codequeueMicrotask(fn) / awaitmicrotask queue
Update the screenrequestAnimationFrame(fn)before the next paint (browser)
Let the browser draw and handle input, then continueawait scheduler.yield() (fallback: setTimeout promise)new task
Run after pending I/O in NodesetImmediate(fn)check phase
Defer an event until the caller attached listeners (Node libraries)process.nextTick(fn)nextTick queue, before promises
CPU-heavy workWeb Worker / worker_threadsanother thread
Background work in a browser when idlerequestIdleCallback(fn) (not in stable Safari as of Nov 2026; fall back to setTimeout)idle periods
Order, after any callback, in NodeOrder 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 phase3. if a frame is due: rAF → style → layout → paint
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Know someone who'd find this useful?Send it to a teammate, or link a single section with the 🔗 next to its heading.
Up next in Concurrency & AsyncGoroutines & the Go Scheduler
Read →