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,allSettledES2020,anyES2021,race,withResolversES2024,tryES2025.async/awaitES2017: error handling, sequential vs parallel, top-levelawaitES2022.- 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:
- Run the current script or task until the call stack is empty.
- Run all microtasks: promise callbacks (
then/catch/finally, code afterawait) andqueueMicrotask. Microtasks queued meanwhile run too. - 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
| Method | Fulfils when… | Rejects when… | Use for |
|---|---|---|---|
Promise.all | all fulfil (an array of values, in input order) | the first one rejects | "I need every result" |
Promise.allSettled ES2020 | all settle (never rejects) | — | "tell me what happened to each" |
Promise.any ES2021 | the first one fulfils | all reject (AggregateError) | the fastest working mirror |
Promise.race | the first one to settle, either way | timeouts | |
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.
thenreturns a new promise: return values or promises from it, and errors skip ahead tocatch. all(all or fail-fast),allSettled(each outcome),any(first success),race(first to settle);withResolversandtryare newer helpers.asyncfunctions return promises, andawaitplustry/catchreads like synchronous code. UsePromise.allfor independent work, neverforEachwithasync.- 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.