Sooner or later, every JavaScript developer hits a bug that looks impossible: a setTimeout(fn, 0) that runs after a promise callback created later, a loading message that never appears, or a Node.js API that slows down for everyone whenever one endpoint gets busy.

None of this is random. It all follows from the JavaScript event loop, a short set of rules for what runs next. Learn them and you can predict every log line and spot code that will freeze a page or stall a server.

I'll build the model piece by piece (the call stack, tasks and microtasks, rendering, and Node.js), then use it to detect a blocked loop, yield without freezing the page, and fix the three event loop bugs I run into most.

The call stack and run-to-completion

JavaScript runs your code on a single thread with one call stack. Calling a function pushes a frame onto it; returning pops the frame off. The rule everything else rests on is run-to-completion: once code starts, nothing else on that thread runs until the stack is empty again. No timer or click handler can cut in, even an overdue one:

run-to-completion.jsJavaScript
setTimeout(() => console.log('timer fired'), 0);
 
const start = Date.now();
while (Date.now() - start < 1000) {
  // Busy for one second. The timer is due after about 1 ms, but it has to wait.
}
 
console.log('loop done');
OutputText
loop done
timer fired

That's the deal: you never need locks, because no other code can change your variables halfway through a function, but anything slow on that thread delays everything else.

Who actually runs timers and I/O

If there's only one thread, who counts down that timer? Not the engine: V8, SpiderMonkey, and JavaScriptCore only execute JavaScript. setTimeout, fetch, and fs.readFile come from the host, which waits elsewhere and queues your callback when the result is ready:

WorkBrowserNode.js
TimersBrowser internalslibuv
I/OBrowser internals: network, storage, filesSockets: OS async APIs, via libuv. Files, DNS lookups, some crypto and zlib: libuv's thread pool (4 threads by default)
Your callbacksOne at a time, on the main threadOne at a time, on the main thread

The waiting happens in parallel; your callbacks never do. Each waits in a queue until the stack is empty, and the event loop decides which runs next. Workers add threads, but each runs its own loop by the same rules.

Tasks and microtasks

When a timer fires or a click arrives, the host doesn't interrupt your code; it queues a task for the event loop. Running a script is a task, and so are timer callbacks, input events, network callbacks, and postMessage() messages (often called macrotasks, though the spec just says task). Browsers keep several task queues and can choose which to serve next, which is how clicks get priority over timers.

Microtasks live in a separate queue for work that should run as soon as the current code finishes:

  • Promise reactions: then, catch, and finally callbacks, plus the rest of an async function after await
  • Callbacks passed to queueMicrotask()
  • MutationObserver callbacks in the browser

Every turn of the loop follows the same steps:

Text
loop forever:
  1. task        take one task from a task queue and run it to completion
  2. microtasks  run every queued microtask, including ones queued meanwhile
  3. render      browsers only, if a frame is due:
                 requestAnimationFrame callbacks → style → layout → paint

Step 2 is where intuition breaks. The loop doesn't run one microtask per turn; it drains the queue until it's empty, including microtasks queued along the way. Strictly, the checkpoint runs whenever the call stack empties, so in a browser it also runs between a real click's listeners.

That's deliberate: a promise chain or a batch of DOM mutations finishes as one unit before a timer, an input event, or a render can see a half-updated state. The cost is that an endless microtask chain never lets the loop get back to tasks or rendering. MDN's in-depth guide to microtasks has more examples.

Ordering puzzles: setTimeout vs Promise.then vs await

Here's the puzzle I use to test my own understanding. Write down what you think it prints before reading on:

ordering.jsJavaScript
console.log('script start');
 
setTimeout(() => {
  console.log('timeout 1');
  Promise.resolve().then(() => console.log('microtask from timeout 1'));
}, 0);
 
setTimeout(() => console.log('timeout 2'), 0);
 
Promise.resolve()
  .then(() => console.log('then 1'))
  .then(() => console.log('then 2'));
 
queueMicrotask(() => console.log('queueMicrotask'));
 
async function main() {
  console.log('main start');
  await null;
  console.log('main after await');
}
 
main();
 
console.log('script end');

Node.js 24 and modern browsers print this every time, because the order comes from the specs, not from timing:

OutputText
script start
main start
script end
then 1
queueMicrotask
main after await
then 2
timeout 1
microtask from timeout 1
timeout 2

Walking through it step by step

  1. The script is the first task. It logs script start and hands both timers to the host. Promise.resolve() is already fulfilled, so then 1 goes straight into the microtask queue, while then 2 waits on the promise that then 1 returns.
  2. queueMicrotask() adds a second microtask.
  3. main() runs synchronously until await, so main start prints right away. await null queues the rest of main as a third microtask, and the script logs script end.
  4. The stack is empty, so the microtasks drain. then 1 fulfills its promise, which queues then 2 behind the other two microtasks.
  5. Only now does the loop take the next task, timeout 1. The microtask it queues runs before timeout 2, which is a separate task.

One rule covers it all: synchronous code, then every microtask, then one task, then every microtask again. The code after await is just a promise reaction in the same queue as then callbacks. Conflicting answers in older articles often predate a 2019 spec change that removed extra await ticks.

Where rendering fits in the browser

In a browser, the main thread that runs your JavaScript also calculates styles, lays out the page, and paints it, and it can only do that between tasks. Browsers aim to render once per display refresh, about every 16.7 ms at 60 Hz, and skip frames when nothing changed or the tab is hidden. When a frame is due, the loop runs requestAnimationFrame callbacks, then style, layout, and paint.

That explains the classic frozen page. Save this as an HTML file and click the button:

freeze.htmlHTML
<button id="run">Run a 3-second job</button>
<p id="status">Idle</p>
 
<script>
  const output = document.querySelector('#status');
 
  document.querySelector('#run').addEventListener('click', () => {
    output.textContent = 'Working...';
    const end = performance.now() + 3000;
    while (performance.now() < end) {
      // Busy-wait: nothing else can use the main thread.
    }
    output.textContent = 'Done';
  });
</script>

You never see Working.... The click handler is one task: it sets the text, blocks for three seconds, and sets it again, and rendering only happens after the task returns. Clicks pile up and hover effects stop, though scrolling and some CSS animations may keep moving on the browser's compositor thread.

requestAnimationFrame callbacks run right before style, layout, and paint, and browsers pause them in background tabs, so they're the right place for DOM writes and animations. They also let a status message paint before heavy work starts, because a task queued from a requestAnimationFrame callback runs after that frame is painted:

freeze.html (new click handler)JavaScript
document.querySelector('#run').addEventListener('click', () => {
  output.textContent = 'Working...';
  requestAnimationFrame(() => setTimeout(runJob, 0));
});
 
function runJob() {
  const end = performance.now() + 3000;
  while (performance.now() < end) {
    // Still blocking, but the browser painted 'Working...' first.
  }
  output.textContent = 'Done';
}

The page still freezes during runJob, but now the user sees why; the real fix, chunking, comes after a look at Node.

How the Node.js event loop differs

Node.js has no rendering step. Its loop comes from libuv and runs in phases, each with its own queue of callbacks:

Text
┌─> timers ............. due setTimeout() and setInterval() callbacks
│   pending callbacks .. some I/O callbacks deferred from the previous turn
│   poll ............... wait for I/O, then run its callbacks (sockets, files)
│   check .............. setImmediate() callbacks
└── close callbacks .... 'close' events, such as socket.on('close')

When nothing is due, the loop waits in the poll phase, which is why an idle server uses almost no CPU. Since Node.js 11, microtasks run after every single callback, timers and immediates included, just as in browsers.

process.nextTick vs promise microtasks

Node adds a queue that browsers don't have: process.nextTick(). When Node drains microtasks, it empties the next-tick queue first, then the promise queue, and repeats until both are empty. Note the .cjs extension:

next-tick.cjsJavaScript
Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('queueMicrotask'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
OutputText
sync
nextTick
promise
queueMicrotask

setImmediate vs setTimeout 0

setImmediate() runs in the check phase and setTimeout(fn, 0) in the timers phase, so the order depends on where you call them:

immediate-vs-timeout.cjsJavaScript
const fs = require('node:fs');
 
// Scheduled from the main module: the order is not guaranteed.
setTimeout(() => console.log('timeout (main)'), 0);
setImmediate(() => console.log('immediate (main)'));
 
// Scheduled from an I/O callback: setImmediate always runs first.
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout (I/O)'), 0);
  setImmediate(() => console.log('immediate (I/O)'));
});
Output (most runs)Text
timeout (main)
immediate (main)
immediate (I/O)
timeout (I/O)

The first two lines sometimes swap: in 100 runs on my machine, the timeout came first 85 times. Node turns a 0 ms delay into 1 ms, so whether the timer is due on the loop's first check depends on how fast the process got there. Inside an I/O callback there's no race, because poll is followed by check, and the loop reaches timers only after that. When you mean "right after this I/O, before any timer", use setImmediate().

Detecting and fixing a blocked event loop

It all comes down to one rule: keep every task short. Here's how I find the ones that aren't.

Long tasks in the browser

Any task that runs longer than 50 ms counts as a long task, and Chromium-based browsers report them to a PerformanceObserver:

long-tasks.jsJavaScript
if (PerformanceObserver.supportedEntryTypes.includes('longtask')) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      console.warn(`Long task: ${Math.round(entry.duration)} ms`, entry);
    }
  });
  observer.observe({ type: 'longtask', buffered: true });
}

Long task entries say when and for how long, not which code was responsible. In Chromium 123 and later, I prefer long-animation-frame entries, which name the scripts involved through sourceURL, sourceFunctionName, and an invoker such as TimerHandler:setTimeout. To see inside a task, record a trace in the DevTools Performance panel.

Event loop delay in Node.js

In Node.js, monitorEventLoopDelay() measures how late the loop is running. An internal timer fires every resolution milliseconds and records the real gap between ticks in nanoseconds, so an idle loop reports about the resolution itself, and anything above it is delay:

loop-delay.cjsJavaScript
const { monitorEventLoopDelay } = require('node:perf_hooks');
 
const RESOLUTION_MS = 20;
const histogram = monitorEventLoopDelay({ resolution: RESOLUTION_MS });
histogram.enable();
 
// Each sample is the gap between two ticks of a 20 ms timer, so subtract 20.
const delayMs = (ns) => Math.max(0, ns / 1e6 - RESOLUTION_MS).toFixed(0);
 
setInterval(() => {
  const p99 = delayMs(histogram.percentile(99));
  const max = delayMs(histogram.max);
  console.log(`event loop delay: p99=${p99}ms max=${max}ms`);
  histogram.reset();
}, 5000);
 
// Simulate a request handler that hogs the CPU for 200 ms every second.
setInterval(() => {
  const end = Date.now() + 200;
  while (Date.now() < end) {}
}, 1000);
Output from one run (yours will differ)Text
event loop delay: p99=180ms max=194ms
event loop delay: p99=195ms max=196ms
event loop delay: p99=195ms max=196ms

Every request that arrives during a stall waits it out. In production I export p99 and max as metrics and alert when they climb; performance.eventLoopUtilization() adds the share of time the loop spent busy rather than waiting.

Yielding: chunk the work, then give the loop a turn

The fix for a long task is to split it into chunks and give the loop a turn in between. That turn has to be a new task, not a microtask, and browsers offer three ways to get one:

  • setTimeout(resolve, 0) works everywhere, but once timers nest more than five levels deep, browsers clamp the delay to at least 4 ms. In a quick Chrome test, that cost about 4 ms per yield.
  • A MessageChannel message is also a new task, without the clamp, which is why schedulers such as React's use it.
  • scheduler.yield() lets the browser handle input, then resumes your work ahead of other queued tasks rather than behind them. The flip side: other scripts' timers wait until you finish, and in my Chrome test the page repainted less often than with a MessageChannel.

My helper prefers scheduler.yield() and falls back to a MessageChannel:

yield-to-main.jsJavaScript
// A MessageChannel message is a new task and, unlike setTimeout, never clamped.
const channel = new MessageChannel();
const pending = [];
channel.port1.onmessage = () => pending.shift()();
 
export function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return globalThis.scheduler.yield(); // Chrome/Edge 129+, Firefox 142+
  }
  return new Promise((resolve) => {
    pending.push(resolve);
    channel.port2.postMessage(null);
  });
}
 
export async function processInChunks(items, handleItem, budgetMs = 10) {
  let deadline = performance.now() + budgetMs;
  for (const item of items) {
    handleItem(item);
    if (performance.now() >= deadline) {
      await yieldToMain();
      deadline = performance.now() + budgetMs;
    }
  }
}

Checking the clock rather than counting items keeps each chunk near 10 ms on any device. In Node.js, yield with await setImmediate() from node:timers/promises, so pending I/O callbacks run between chunks. For work that takes seconds even when chunked, move it to a Web Worker or worker_threads.

Common event loop bugs

Microtask starvation

Because the loop drains microtasks completely, a microtask that keeps queueing microtasks blocks everything else as surely as a while loop:

starvation.jsJavaScript
let count = 0;
 
setTimeout(() => console.log(`timeout ran after ${count} microtasks`), 0);
 
function spin() {
  count += 1;
  if (count < 1_000_000) queueMicrotask(spin);
}
 
spin();
OutputText
timeout ran after 1000000 microtasks

The timer was due after 1 ms but waited for a million microtasks. In a browser, rendering and input would wait too. Real-world versions are subtler: a recursive process.nextTick(), or an async loop that awaits values that are already resolved, such as cache hits.

Unhandled promise rejections

This dashboard loader starts both requests in parallel, which looks like good practice:

dashboard.jsJavaScript
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 
async function fetchUser() {
  await sleep(100);
  return { id: 1, name: 'Ada' };
}
 
async function fetchPosts() {
  await sleep(10);
  throw new Error('posts service is down');
}
 
async function loadDashboard() {
  const userPromise = fetchUser();
  const postsPromise = fetchPosts();
  const user = await userPromise; // postsPromise rejects while we wait here
  const posts = await postsPromise;
  return { user, posts };
}
 
loadDashboard().catch((err) => console.error('handled:', err.message));

On Node.js 24, it crashes with Error: posts service is down and exit code 1, and handled: never prints. A rejection counts as unhandled if no handler is attached by the time the microtask queue drains, and since Node.js 15 that's fatal by default. Here, postsPromise rejects after 10 ms, but loadDashboard() is still awaiting userPromise and won't reach it for another 90 ms. Browsers fire an unhandledrejection event and log an error instead, then fire rejectionhandled once the handler arrives.

Attach handlers as soon as you create the promises; Promise.all does that for you:

dashboard.jsJavaScript
async function loadDashboard() {
  const userPromise = fetchUser(); 
  const postsPromise = fetchPosts(); 
  const user = await userPromise; 
  const posts = await postsPromise; 
  const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]); 
  return { user, posts };
}

Now the script prints handled: posts service is down and exits cleanly. Use Promise.allSettled() when you want partial results instead. Global rejection listeners are great for logging, but they're a safety net, not error handling.

Big JSON payloads on the main thread

JSON.parse() and JSON.stringify() are synchronous, and a single call can't be interrupted. Here's how I measured the cost:

parse-cost.jsJavaScript
const orders = Array.from({ length: 300_000 }, (_, i) => ({
  id: i,
  customer: `customer-${i % 5000}`,
  status: i % 20 === 0 ? 'cancelled' : 'delivered',
  items: [
    { sku: `SKU-${i}`, qty: 1 + (i % 3), price: 1999 },
    { sku: `SKU-${i + 1}`, qty: 1, price: 499 },
  ],
  createdAt: new Date(1_700_000_000_000 + i * 60_000).toISOString(),
}));
const body = JSON.stringify(orders);
 
const start = performance.now();
JSON.parse(body); // nothing else can run until this returns
const ms = performance.now() - start;
console.log(`parsed ${(body.length / 1e6).toFixed(0)} MB in ${ms.toFixed(0)} ms`);

On my machine, it parses 58 MB in roughly 400 to 480 ms, and nothing else runs meanwhile: no rendering in a browser, no other requests on a server. await res.json() doesn't help, because only the download is asynchronous; the parse is one synchronous step at the end. A Node.js body parser with a generous size limit has the same problem. What I reach for, in order:

  • Send less. Paginate, or return only the fields clients need.
  • Stream it. With newline-delimited JSON (NDJSON), you can parse one record at a time and yield between batches.
  • Move it. Parse in a Web Worker or worker_threads and post back only what the main thread needs, because posting the whole object back costs a structured clone there.

The Node.js guide Don't Block the Event Loop covers more server-side traps like this one.

Key takeaways

  • One thread runs your callbacks one at a time, each to completion, while the browser or libuv does the waiting.
  • After every task, the loop drains the entire microtask queue, await continuations included, before it runs another task or renders.
  • Browsers render between tasks, so a long task or an endless microtask chain freezes the page; do visual work in requestAnimationFrame.
  • In Node.js, process.nextTick() callbacks run before promise callbacks (except at the top level of an ES module), and setImmediate() beats setTimeout(fn, 0) only inside I/O callbacks.
  • Measure with long tasks in Chromium and monitorEventLoopDelay() in Node.js, then chunk and yield with a real task, or move the work to a worker.
  • Attach rejection handlers where you create promises, and keep huge JSON payloads off the main thread.

Next time your logs come out in a surprising order, ask which queue each callback is waiting in; the answer almost always falls out of the three-step loop. To go deeper, read the event loop processing model in the HTML spec and the Node.js guide to the event loop, timers, and process.nextTick().