Chapter 12 · Asynchronous JavaScript

Promises, async/await & the Event Loop

JavaScript runs your code on a single thread, yet it handles thousands of network requests and timers. The secret is the event loop. This chapter explains the event loop first, then promises (ES6), the combinators, async/await (ES2017), cancellation, and the mistakes that cause slow or crashing async code.

You will learn

  • The event loop: call stack, macrotasks (timers, I/O) and microtasks (promise callbacks).
  • Promises ES2015: states, chaining, error propagation, finally.
  • Promise.all, allSettled ES2020, any ES2021, race, withResolvers ES2024, try ES2025.
  • async/await ES2017: error handling, sequential vs parallel, top-level await ES2022.
  • Timeouts and cancellation with AbortController, and unhandled rejections.

The event loop

JavaScript executes one piece of code at a time, on the call stack. Slow work, like waiting for a timer, a file or the network, is handed to the host (the browser or Node). When that work finishes, the host queues a callback. The event loop repeats one simple cycle:

  1. Run the current script or task until the call stack is empty.
  2. Run all microtasks: promise callbacks (then/catch/finally, code after await) and queueMicrotask. Microtasks queued meanwhile run too.
  3. Take the next macrotask (a timer, an I/O callback, an event) and go back to step 1.

Analogy: a single chef (the thread) cooks one dish at a time. Microtasks are notes stuck to the current order; they're all handled before the chef picks up the next ticket from the rail (macrotasks).

Read the output carefully: all synchronous code runs first, then every microtask in the order it was queued, and only then the timer, even with a 0 ms delay.

⚠️ Pitfall: blocking the loop. A long synchronous loop (say, 2 seconds of computation) freezes everything: no timers, no clicks, no network callbacks, because the event loop only moves on when the stack is empty. Move heavy CPU work to a Worker (worker_threads in Node).

☕ Java: there's no thread pool running your callbacks in parallel; everything runs on one thread, so there are no data races between callbacks. A promise is like a CompletableFuture, and then is like thenApply/thenCompose, but every callback runs on the same event-loop thread.

Promises ES2015

A promise is an object representing a value that will be available later. It's in one of three states: pending, fulfilled (with a value) or rejected (with a reason, usually an Error). Once it's settled, it never changes.

Chaining

then returns a new promise. Whatever the callback returns becomes the next value; if it returns a promise, the chain waits for it. A thrown error (or a rejected promise) skips ahead to the next catch.

❌ Forgetting to return breaks the chain

✅ Return the promise

📝 Older style you'll still see: callbacks. readFile(path, (err, data) => { … }), nested several levels deep (the "pyramid of doom"). Node offers promise versions: node:fs/promises, and util.promisify for old callback APIs.

Combining promises

MethodFulfils when…Rejects when…Use for
Promise.allall fulfil (an array of values, in input order)the first one rejects"I need every result"
Promise.allSettled ES2020all settle (never rejects)—"tell me what happened to each"
Promise.any ES2021the first one fulfilsall reject (AggregateError)the fastest working mirror
Promise.racethe first one to settle, either waytimeouts

withResolvers ES2024 and try ES2025

async / await ES2017

An async function always returns a promise. Inside it, await promise pauses that function (not the whole program) until the promise settles, then gives you the value, or throws the rejection reason. So you can use ordinary try/catch and loops.

Sequential vs parallel

await inside a loop waits for each item before starting the next one. When the tasks are independent, start them all and await Promise.all.

⚠️ Pitfall: forEach doesn't wait. ids.forEach(async (id) => { await save(id); }) starts everything and returns immediately; code after it runs before any save finishes, and errors go unhandled. Use for…of with await (sequential) or Promise.all(ids.map(…)) (parallel).

Top-level await ES2022

In a module, you can use await outside any function, as the examples in this chapter do. Modules that import a module using top-level await wait for it to finish first. In classic scripts and CommonJS, wrap the code in an async function.

Timeouts and cancellation

Promises can't be cancelled by themselves. The standard mechanism is AbortController: you pass its signal to an API (fetch, Node's timers, your own functions), and calling abort() makes the operation reject with an AbortError. AbortSignal.timeout(ms) gives you a signal that aborts by itself.

Notice that Node's timer reported an AbortError, with the signal's TimeoutError as its cause; other APIs, like fetch, reject with the TimeoutError directly. In the browser (and in Node 18+), the same works with fetch(url, { signal }):

Unhandled rejections

A rejected promise that nobody handles is a bug. Browsers log it; Node terminates the process by default. Always end a chain with catch, or await inside try/catch.

More examples

1. Retry with exponential backoff

2. Limiting concurrency to N at a time

3. A timeout wrapper with race

4. Collecting async results with Array.fromAsync

5. Promisifying a callback API

Recap

  • One thread. Synchronous code runs, then all microtasks (promise callbacks, code after await), then the next macrotask (timers, I/O). Don't block the loop.
  • A promise is pending, then fulfilled or rejected. then returns a new promise: return values or promises from it, and errors skip ahead to catch.
  • all (all or fail-fast), allSettled (each outcome), any (first success), race (first to settle); withResolvers and try are newer helpers.
  • async functions return promises, and await plus try/catch reads like synchronous code. Use Promise.all for independent work, never forEach with async.
  • Cancel with AbortController/AbortSignal.timeout, and always handle rejections: Node exits on unhandled ones.

Exercises

Exercise 1 🟢 easy predict the output

In what order are A, B, C and D printed?

Show solution

C, B, D, A. Synchronous code first, then the microtasks in queue order, then the timer.

Exercise 2 🟢 easy build it

Write sleep(ms), which returns a promise that fulfils after ms milliseconds, and use it with await.

Show solution

The executor schedules a timer that calls resolve. In Node you can also import { setTimeout as sleep } from "node:timers/promises".

Exercise 3 🟢 easy fix the bug

This prints "all saved" before anything is saved. Fix it.

Show solution

forEach ignores the promises its callback returns. Collect them with map and await them all.

Exercise 4 🟢 easy explain why

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

Show solution

It queues fn as a macrotask. That only runs after the current synchronous code has finished and all pending microtasks have run. "0" means "as soon as possible", which is never "right now".

Exercise 5 🟡 medium predict the output

What does this print?

Show solution

[ 'fulfilled:1', 'rejected:x', 'fulfilled:3' ]. allSettled never rejects, and it keeps the input order.

Exercise 6 🟡 medium refactor it

Rewrite this then chain with async/await, keeping the error handling.

Show solution

Each then step becomes an await, and .catch becomes try/catch.

Exercise 7 🟡 medium build it · playground

Create playground/payments.js with a fake chargeCard(cents, { signal }) that takes 30 ms, fails for amounts over 50000, and supports cancellation. Then write payAll(orders, { timeoutMs }), which charges all orders in parallel and returns one { id, ok, info } object per order, using allSettled.

Show solution

One shared timeout signal cancels every charge still in flight. Checking signal.aborted is more reliable than inspecting the error's name, because APIs wrap abort reasons differently (Node's timers reject with an AbortError whose cause is the TimeoutError). allSettled reports each outcome without one failure hiding the others.

Exercise 8 🟡 medium predict the output

What does this print?

Show solution

start, f1, end, f2, f done. The async function runs synchronously until its first await; the rest resumes as a microtask after the synchronous code has finished.

Exercise 9 🔴 hard explain why

In a try block, why does return await promise behave differently from return promise?

Show solution

With return promise, the function returns before the promise settles, so a later rejection isn't caught by the surrounding catch (and a finally runs too early). return await waits inside the try, so rejections become catchable exceptions there. Outside a try, both are equivalent.

Exercise 10 🔴 hard mini-project · playground

Create playground/queue.js: an AsyncQueue class that runs added async jobs with a concurrency limit, where add(fn) returns a promise for that job's result, and onIdle() resolves when everything has finished. Use Promise.withResolvers.

Show solution

"c" waits for a free slot, then finishes before "a". withResolvers lets add hand out a promise that's settled later by #pump; Promise.try turns synchronous throws into rejections; finally frees the slot either way.

Official docs