01/11Blocking

LEARN TO PROGRAM · CHAPTER 09 · ADVANCED

Async and the event loop

A program spends most of its life waiting: for a click, a file, a server on the other side of the world. If it froze while it waited, every web page would lock up each time it loaded an image.

JavaScript solves this with one thread, a call stack, some queues and a loop. Learn the handful of rules that loop follows and you can predict, exactly, the order any async program runs in. Scroll, and the machine runs each program as you read it.

Every code block runs in your browser. Edit it, break it, press Run.

01 · BLOCKING

Blocking: when the thread is stuck

JavaScript in a web page runs on one thread: one call stack, doing one thing at a time. The same thread also handles your clicks and draws the page. So if your code is busy, nothing else can happen. That is blocking.

Run this and watch the timer. It asked for 0 ms. It gets about 1000, because the thread was stuck in a loop and could not even look at the timer:

blocking.js

On the stage, the tall red frame is sleepBlocking. The clicks pile up in the queue and the screen freezes, because rendering needs the same thread. Try changing 1000 to 2000.

The fix is never to wait by spinning. You start the slow thing, hand over a function to call when it is done, and let the thread go. That is non-blocking:

non-blocking.js

Like ordering at a café: you don't stand frozen at the counter until the coffee exists. You get on with paying and finding a seat, and they call your name.

16.7 msis one frame at 60 frames a second. Any task longer than that can make a page skip a frame; tasks over 50 ms are what browsers call "long tasks". web.dev: RAIL

02 · CALLBACKS

Callbacks: call me when it's done

A callback is a function you pass to someone else, to be called later. You have already used one: the arrow function handed to setTimeout. Here is a café that takes a callback, in the Node style where the first argument is an error (or null):

callback.js

Press Step through. The step mode records every line, so you can see makeCoffee return before the coffee exists, then the callback run later on a fresh stack.

Follow it on the stage. setTimeout does not wait. It hands the timer to the Web APIs, the browser's own machinery written in C++, and returns at once. The timer counts down outside JavaScript. When it fires, the browser puts your callback in the task queue, and the loop runs it once the stack is empty. The same happens for network requests (the dish; see how the internet moves your packets) and for clicks (the mouse).

Callbacks work, but when each step needs the last one's result, they nest:

pyramid.js

The "pyramid of doom". Now add error handling to every level. Promises were invented to flatten this.

03 · THE EVENT LOOP

The event loop, in 3D

Here is the whole machine. Each part has one job:

  • Call stack: the functions running right now. JavaScript only ever runs what is on top.
  • Web APIs: timers, network, input. They work outside JavaScript, often on other threads.
  • Task queue (amber): callbacks from timers, events and messages, waiting their turn. Browsers keep several task queues and may pick between them, but each runs in order.
  • Microtask queue (pink): promise callbacks and queueMicrotask. Urgent, small jobs.
  • Rendering: requestAnimationFrame callbacks, then style, layout and paint.

The event loop is the ring in the middle. The HTML standard spells out what it does on every turn, and it fits in three lines:

  1. Take one task from a task queue and run it until the stack is empty.
  2. Run every microtask, including ones queued while doing so, until the queue is empty.
  3. If it is time for a new frame (about every 16.7 ms at 60 Hz), render.

Then go round again, or sleep until something arrives. A task is never interrupted: once code starts, it runs to completion. You never have to worry that another callback will change your variables halfway through a function. That guarantee is why single-threaded JavaScript is so much easier to reason about than threads.

04 · ORDERING PUZZLES

setTimeout ordering puzzles

You now know enough to predict any ordering. Before you press Run, write down what this prints:

puzzle-1.js

Then watch the stage: A and E print as the script runs. B is a task. C and D are microtasks, so they run as soon as the script (itself a task) finishes, before any other task.

Answer: A E C D B. Now a harder one, where callbacks schedule more callbacks. Predict it on paper first:

puzzle-2.js

It prints 1 7 4 2 3 6 5. 1 and 7 are synchronous. The script ends, so microtasks run: 4, which sets a third timer. Then the tasks, in the order they were queued: the timer for 2 (and its microtask, 3, runs straight after it, before the next task), then 6, then 5, which was set last.

The microtask rule has a sharp edge. The loop must empty the microtask queue before it does anything else, so microtasks that keep queueing microtasks starve everything: timers, clicks, even rendering.

starvation.js

Change 100000 to Infinity and the timer never fires: the sandbox stops it after 3 seconds. In a real tab the page would hang.

4 msis the minimum delay once timers are nested more than five deep: browsers clamp them, so a chain of setTimeout(fn, 0) can't spin faster. Timers with the same delay always run in the order they were set. HTML Standard: timers

05 · PROMISES

Promises: a box for a future value

A promise is an object that stands for a result that isn't there yet. It is in one of three states: pending, then either fulfilled with a value or rejected with a reason. It settles once, and never changes after that.

promise.js

The function you pass to new Promise (the executor) runs immediately. then doesn't run anything: it stores your callback on the promise. When the promise settles, each stored callback is queued as a microtask.

On the stage, the promise sits on the purple shelf, pending, holding one callback. When the timer task calls resolve, it turns green, and its callback drops into the microtask queue.

The real power is chaining. then returns a new promise for whatever your callback returns. Return a value and the next step gets it; return a promise and the chain waits for it:

chain.js

2 × 10 = 20, then 20 + 1 = 21, after two waits. Flat, not nested, and one .catch at the end would handle a failure anywhere.

06 · ASYNC / AWAIT

async / await: pausing without blocking

async and await (ES2017) let you write promise code that reads like ordinary code. An async function always returns a promise. await pauses that function until a promise settles, and gives you its value.

The word "pause" hides the clever part. Predict the order, then run it:

await-order.js

A B C D E. brew runs synchronously right up to await. Then its frame comes off the stack and is saved, and the caller carries on. The rest of brew runs later, as a microtask.

Watch the stage: at await, the brew frame leaves the tower and parks on the shelf, while the script prints C. The thread is never blocked; only the function is paused. It comes back with all its local variables, exactly where it left off.

The commonest mistake with await is making things wait for each other when they don't need to:

serial-vs-together.js

Two 300 ms waits take 600 ms one after another, and 300 ms when started together. Start everything first, then await.

Python has the same model, with the loop made explicit (asyncio):

brew.py · Python asyncio
import asyncio
 
async def brew(name, secs):
    print(name, "start")
    await asyncio.sleep(secs)     # give the loop back
    print(name, "done")
 
async def main():
    await asyncio.gather(brew("A", 0.3), brew("B", 0.1))
 
asyncio.run(main())
# A start
# B start
# B done
# A done
1 tickis what await on a native promise costs today. It used to cost three microtask turns, until a spec change the V8 team proposed in 2018 made async functions faster than hand-written promise chains. V8: faster async functions

07 · ERRORS

Errors in async code

An error thrown in a callback can't be caught by code that has already finished. This try is useless:

try-timer.js

The callback runs in a later task, with a fresh stack. Nobody is there to catch it, so it is reported as uncaught.

Promises carry errors for you. A rejection skips every then until it reaches a catch, just like an exception jumping up the stack. And a catch that returns a value turns the chain back into success:

chain-errors.js

On the stage, the red "rejected" state passes from promise to promise along the shelf until catch handles it and the chain turns green.

With await, a rejection is thrown at the await, so plain try/catch works again. A rejection nobody handles fires an unhandledrejection event in browsers, and by default crashes Node (since version 15):

await-errors.js

Forgetting to await or return a promise is the most common async bug: the error then has nowhere to go.

08 · ALL · RACE · ALLSETTLED

Promise.all, race, allSettled and any

Four built-ins combine many promises into one. Each makes a different choice about when to stop waiting. The stage feeds the same three promises into all four: A succeeds at 300 ms, B fails at 100 ms, C succeeds at 200 ms.

combinators.js

Change B's fail to ok and run it again. Which lines change?

  • Promise.all: every result, in order, or the first failure. Use it when you need all of them.
  • Promise.allSettled (ES2020): waits for all, never rejects, and reports each one's status. Use it for "try everything, then report".
  • Promise.race: the first to settle, success or failure. Handy for timeouts.
  • Promise.any (ES2021): the first success; it rejects with an AggregateError only if all fail. Use it for "the fastest mirror wins".

None of them cancels the losers. The other promises keep running; you have just stopped listening to them.

09 · CANCELLATION

Cancellation with AbortController

To stop the work itself, the web uses AbortController. The controller owns an AbortSignal; you pass the signal to anything that can be cancelled and call abort() when you change your mind. Your own functions can listen to the signal too:

abort.js

On the stage, the abort task fires the signal: the red wire cuts the 1000 ms timer out of the Web APIs, the promise rejects, and the parked script resumes in its catch.

The same signal works with fetch, event listeners and many Node APIs. A rejected fetch gives you an AbortError, which usually means "we cancelled this on purpose", not a real failure:

search.js · in a page (the sandbox has no network)
const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
 
try {
  const res = await fetch("/api/search?q=cat", {
    signal: controller.signal,
  });
  show(await res.json());
} catch (e) {
  if (e.name === "AbortError") return;   // we cancelled it: fine
  throw e;
}
 
// or give up on its own after 5 seconds:
await fetch(url, { signal: AbortSignal.timeout(5000) });

Cancel when the user navigates away, types a new search, or a deadline passes. Otherwise stale answers arrive late and overwrite fresh ones.

10 · NOT PARALLEL

Why async isn't parallel

Async is concurrency: many jobs in progress at once, taking turns on one thread. It is not parallelism: many jobs running at the same instant on different cores. The difference shows the moment the work is computation instead of waiting:

not-parallel.js

Two waits overlap (about 300 ms), because the waiting happens in the network or a timer, outside JavaScript. Two crunches don't (about 600 ms): there is only one thread to crunch on.

The stage shows all three cases on a timeline. For real parallelism in the browser you start a Web Worker: a second thread with its own stack, its own event loop and no shared variables. You talk to it by passing messages. Node has worker_threads for the same job.

worker.js · two files, in a page
// main.js: hand the heavy work to another thread
const worker = new Worker("crunch.js");
worker.postMessage({ upTo: 50_000_000 });
worker.onmessage = (e) => console.log("sum:", e.data);
// the page stays responsive meanwhile
 
// crunch.js: runs on its own thread, with its own event loop
onmessage = (e) => {
  let sum = 0;
  for (let i = 0; i < e.data.upTo; i++) sum += i;
  postMessage(sum);
};

Underneath, a CPU core runs one instruction stream at a time; several cores, and several machines in a cluster, are how real parallel work gets done. Threads, races and locks are the next chapter.

11 · NODE'S LOOP

Node's event loop, briefly

Node.js uses the same model with a different engine room: libuv, a C library. Its loop goes round six phases, each with its own queue: timers (setTimeout), pending callbacks, idle/prepare (internal), poll (wait for I/O and run its callbacks), check (setImmediate) and close callbacks.

Between callbacks, Node drains two extra queues: process.nextTick first, then promise microtasks. So the familiar rules still hold:

order.js · Node.js
// order.js (CommonJS), run with: node order.js
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("sync");
 
// sync
// nextTick
// promise
// timeout     <- these two can swap: from the main
// immediate   <- module, the order is not guaranteed

From inside an I/O callback the race disappears: the loop is in poll, and check comes next, so setImmediate always wins:

io-order.js · Node.js
const fs = require("node:fs");
 
fs.readFile(__filename, () => {
  // inside an I/O callback we are in the poll phase,
  // so "check" (setImmediate) always comes next
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});
// immediate
// timeout

One more subtlety: Node runs an ES module (.mjs) from inside its promise-based loader, so the promise queue drains first and promise prints before nextTick. Network I/O is non-blocking in the operating system itself (epoll on Linux, kqueue on macOS, IOCP on Windows); file system work, DNS lookups and some crypto run on a small thread pool.

4threads in libuv's pool by default (set UV_THREADPOOL_SIZE to change it). Your JavaScript still runs on one. libuv: thread pool
search-box.js

Only cat is shown: the earlier searches are cancelled even though they were still running. Remove the abort() line and run it a few times to see stale results win.

PRACTICE

Now you do it

Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.

1

Predict, then prove

Without running anything, write the order this program prints, as an array of letters. Use the rules: synchronous code first, then every microtask, then the next task. Notice that an async function runs synchronously until its first await.

puzzle.js
console.log("a");
setTimeout(() => console.log("b"), 0);
(async () => {
  console.log("c");
  await null;
  console.log("d");
})();
Promise.resolve().then(() => console.log("e"));
console.log("f");

Then paste the puzzle into any runner on this page to see if you were right.

exercise-1.js
2

Turn a callback into a promise

Write sleep(ms): it returns a promise that fulfils after ms milliseconds. Then the countdown should print 3, 2, 1 and liftoff, with a pause between each.

exercise-2.js
3

Untangle the pyramid

Wrap getUser and getOrders so each returns a promise, then write async function report(id) that prints the same line as the pyramid, using await and one try/catch. Delete the pyramid when yours works.

exercise-3.js
4

Give up after a deadline

Write withTimeout(promise, ms). It settles the same way as promise, unless ms passes first: then it rejects with new Error("timeout"). The two calls below should print fast and timeout.

exercise-4.js
5

The forEach trap

This should print total: 12 but prints total: 0. Find out why, and fix total so it no longer uses forEach.

exercise-5.js
6

Build the event loop

Finish run(script), a model of the real thing: run the script as the first task, and after every task, run microtasks until there are none left, including ones queued along the way. Then take the next task. It should print A E C D B.

exercise-6.js

RECALL

Check yourself, from memory

Answer each one before you open it. Then come back tomorrow, and again next week, and answer them cold: retrieval spaced over days is the most reliable study method we know of. Why that works.

What does the event loop do on each turn, in order?

Why doesn't setTimeout(fn, 0) run fn straight away?

Name two ways to queue a microtask, and two sources of tasks.

What happens to an async function's frame at await?

Promise.all, allSettled, race, any: which waits for everything?

Why can't a try/catch around setTimeout catch an error thrown in its callback?

Is async code parallel?

GO FURTHER

Resources

  • VIDEO · FREEWhat the heck is the event loop anyway?Philip Roberts, JSConf EU 2014The talk that taught a generation of web developers the call stack, Web APIs and callback queue. 26 minutes, with live animation.
  • VIDEO · FREEIn The LoopJake Archibald, JSConf.Asia 2018The follow-up: tasks, microtasks and the rendering steps (requestAnimationFrame, style, layout, paint), told by someone who helped write the specs.
  • ARTICLE · FREETasks, microtasks, queues and schedulesJake ArchibaldInteractive step-through of ordering puzzles like the ones in this chapter.
  • TOOL · FREELoupePhilip RobertsPaste a small program and watch it move between the stack, Web APIs and the queue. It predates promises, so it has no microtask queue.
  • BOOK · FREEEloquent JavaScript, ch. 11: Asynchronous ProgrammingMarijn HaverbekeCallbacks, promises and async functions, built up with a running example of crows passing messages.
  • DOCS · FREEjavascript.info: Promises, async/awaitIlya KantorShort chapters with exercises after each one, from callbacks to the microtask queue.
  • BOOK · FREEOSTEP, ch. 33: Event-based ConcurrencyRemzi and Andrea Arpaci-DusseauThe operating-systems view: how an event loop is built on select/poll, and why blocking calls are its one great enemy.
  • DOCS · FREEThe Node.js Event LoopNode.js docsThe official tour of the phases, setImmediate versus setTimeout, and process.nextTick.

SOURCES

Where the facts come from

  1. HTML Living Standard: Event loops The processing model: pick one task, perform a microtask checkpoint, then update the rendering if it is time.
  2. HTML Living Standard: Timers Timers with equal timeouts run in the order they were set; nested timers are clamped to at least 4 ms after five levels.
  3. ECMAScript 2026: Jobs and host operations to enqueue jobs The language defines promise jobs; the host (browser or Node) decides when they run.
  4. ECMAScript 2026: Promise objects States (pending, fulfilled, rejected), then, and the combinators all, allSettled, any and race.
  5. ECMAScript 2026: Await
  6. MDN: Using microtasks in JavaScript with queueMicrotask()
  7. MDN: In depth: Microtasks and the JavaScript runtime environment
  8. MDN: Using promises
  9. MDN: async function
  10. MDN: Promise.allSettled()
  11. MDN: AbortController
  12. MDN: AbortSignal.timeout()
  13. MDN: Window: unhandledrejection event
  14. V8: Faster async functions and promises await on a native promise went from three microtask ticks to one (a spec change in 2019).
  15. Node.js: The event loop, timers and process.nextTick() The six phases, and why setTimeout versus setImmediate from the main module is not deterministic.
  16. Node.js CLI: --unhandled-rejections Since Node 15 the default is throw: an unhandled rejection ends the process.
  17. web.dev: Measure performance with the RAIL model 60 frames a second is 16 ms per frame.
  18. W3C: Long Tasks API A long task is one that takes 50 ms or more.
  19. libuv: Design overview The C library under Node's loop: non-blocking sockets, a thread pool for files and DNS.
  20. libuv: Thread pool work scheduling Default size 4, set by UV_THREADPOOL_SIZE.
  21. Liskov and Shrira, Promises (PLDI 1988) Promises as typed placeholders for results of asynchronous calls.
  22. Futures and promises History: Friedman and Wise (1976), Baker and Hewitt (1977), E, then JavaScript.
  23. Python docs: Coroutines and tasks (asyncio)
  24. OSTEP: Event-based Concurrency (Advanced)