5. Asynchronous Programming in JavaScript
Web applications are full of operations that take an unknown amount of time, such as network requests, timers, user input and file reads. JavaScript still runs your code on a single thread. This chapter explains how the language handles waiting without freezing: the event loop and its queues, then the three generations of asynchronous code (callbacks, promises and async/await), and finally how the browser talks to servers with XMLHttpRequest and fetch. Almost every bug that involves "it works sometimes" or "the value is undefined here" in front-end code comes from a misunderstanding of the ideas in this chapter.
<a id="51-the-concurrency-model"></a>
5.1 The Concurrency Model
This subchapter explains how a single-threaded language can handle many things at once: the call stack, the event loop that feeds it, the queues that decide what runs next, the timing APIs built on top of them, and why blocking the thread is the cardinal sin of front-end code.
Single-Threaded Execution and the Call Stack
Many languages handle concurrent work with multiple threads, each running its own code at the same time with shared memory. That model is powerful but notoriously hard to get right: two threads writing the same variable at the same time produce race conditions, and protecting data with locks leads to deadlocks. JavaScript was designed in 1995 to script a web page, where the main shared resource is the DOM. Letting several threads mutate the DOM at once would have made every page a minefield. So JavaScript runs on one thread: at any moment exactly one piece of your JavaScript is executing, and it runs until it finishes.
This property is called run-to-completion. Once a function starts, nothing else in your JavaScript can interrupt it: no event handler, no timer callback, no network response. They all wait until the current code has finished. This makes reasoning simple (no locks, no data races on ordinary variables) but has a cost: if your code runs for a long time, everything else waits too.
The call stack. To keep track of where it is, the engine uses a call stack: a stack of frames, one per function call that has started but not yet returned. Each frame holds the function's local variables, its arguments and the position to return to.
- Calling a function pushes a new frame on top of the stack.
- Returning from a function pops its frame.
- The frame on top is the code currently running.
- When the stack is empty, the engine is idle and can pick up the next piece of work.
Think of a stack of plates in a cafeteria: you can only add or remove the top plate. A function that calls another function puts a new plate on top and cannot continue until that plate is removed.
function square(n) {
return n * n;
}
function sumOfSquares(a, b) {
return square(a) + square(b); // two nested calls
}
function main() {
const result = sumOfSquares(3, 4);
console.log(result); // 25
}
main();
The stack evolves like this (each row is a snapshot, with the top of the stack on the left):
time call stack (top -> bottom) event
---- ------------------------------------ ----------------------------------
t1 main main() is called
t2 sumOfSquares | main main calls sumOfSquares(3, 4)
t3 square | sumOfSquares | main square(3) runs, returns 9, is popped
t4 square | sumOfSquares | main square(4) runs, returns 16, is popped
t5 sumOfSquares | main sumOfSquares returns 25, is popped
t6 console.log | main console.log prints 25, is popped
t7 main main returns, is popped
t8 (empty) the engine is free for the next task
Stack traces. When an error is thrown, the engine records the frames on the stack at that moment. That is the stack trace you see in the console, read from the innermost call (top) to the outermost (bottom).
function c() { throw new Error("boom"); }
function b() { c(); }
function a() { b(); }
a();
// Uncaught Error: boom
// at c (app.js:1:22) <- top of the stack: where the error happened
// at b (app.js:2:16)
// at a (app.js:3:16)
// at app.js:4:1 <- bottom: the top-level script
Stack overflow. The stack has a limited size (typically a few thousand to tens of thousands of frames, depending on the engine and frame size). Unbounded recursion fills it and the engine throws a RangeError.
function forever(n) {
return forever(n + 1); // no base case: every call adds a frame
}
forever(0); // RangeError: Maximum call stack size exceeded
What "single-threaded" does and does not mean. Only the execution of your JavaScript is single-threaded. The browser itself is heavily multi-threaded: network requests, image decoding, timers, disk access and parts of rendering happen on other threads. When those operations finish, they do not interrupt your code. They put a callback in a queue, and the event loop (next topic) runs it later on the main thread.
+--------------------------- Browser process ---------------------------+
| |
| Main thread (one at a time) Other browser threads |
| +-----------------------+ +--------------------------+ |
| | JavaScript engine | request | Network stack | |
| | +-----------------+ | ---------> | Timers | |
| | | Call stack | | | File system, decoders | |
| | +-----------------+ | <--------- | (run in parallel) | |
| | Style, layout, paint | callback +--------------------------+ |
| +-----------------------+ queued |
+------------------------------------------------------------------------+
You can run JavaScript on true parallel threads with Web Workers (6.4), but each worker has its own stack, its own event loop and no access to the DOM. Workers talk to the main thread by passing messages, not by sharing ordinary variables.
Key Takeaways
- JavaScript runs your code on a single thread. Code runs to completion: nothing interrupts a running function.
- The call stack holds one frame per active function call. Calling pushes a frame, returning pops it, and the top frame is the code that is running.
- Stack traces are snapshots of the call stack at the moment an error is created.
- Unbounded recursion exhausts the stack and throws
RangeError: Maximum call stack size exceeded. - The browser is multi-threaded. Only your JavaScript runs on one thread, and results from other threads come back as queued callbacks.
Practice
- (Beginner) Write three functions
first,secondandthird, where each calls the next andthirdcallsconsole.trace(). Run the code and match each line of the printed trace to a frame on the call stack.- (Intermediate) Write a recursive
depth()function that counts how many times it can call itself before aRangeErroris thrown (catch the error and print the counter). Compare the result in two different browsers, then add three large local variables to the function and observe how the limit changes.- (Interview) "If JavaScript is single-threaded, how can a page download five images at the same time while the user scrolls?" Hint: separate "the thread that runs JavaScript" from "the threads the browser uses to do I/O", and explain how results come back.
The Event Loop
If JavaScript can only do one thing at a time and code runs to completion, something has to decide what runs next once the call stack is empty. A click happened, a timer expired and a network response arrived, all while your code was busy. They must be run one by one, in some order, without being lost. The component that coordinates this is the event loop.
An analogy: a restaurant with a single chef (the main thread). Waiters (browser threads) take orders, fetch ingredients and watch the oven in parallel, but only the chef cooks. When a waiter has something for the chef, they pin a ticket on a rail (the queue). The chef finishes the current dish completely (run-to-completion), then takes the next ticket. The chef never stops in the middle of a dish to look at the rail.
The basic algorithm. Simplified, the event loop in a browser repeats these steps forever:
while (true) {
1. Take the oldest task from a task queue (if any) and run it until the call stack is empty.
2. Run ALL microtasks in the microtask queue, including any added while doing so.
3. If it is time to render a frame (about every 16.7 ms at 60 Hz):
a. run requestAnimationFrame callbacks
b. recalculate styles, layout, paint
4. If there is spare time before the next frame, run idle callbacks (requestIdleCallback).
5. If there is nothing to do, sleep until something is queued.
}
Each turn of this loop is called a tick or an iteration. The details of tasks versus microtasks are covered in the next topic. Here the key idea is the overall flow:
+---------------------+
| Call stack | <-- runs one thing at a time
+----------^----------+
| event loop moves work here when the stack is empty
+--------------------+----------------------+
| | |
+--+-------------+ +---+------------+ +------+---------+
| Microtask queue| | Task queue(s) | | Render steps |
| promise | | click, timer, | | rAF, style, |
| reactions | | network, msg | | layout, paint |
+----------------+ +---^------------+ +----------------+
|
+-----------+-------------+
| Browser / Web APIs | setTimeout, fetch, DOM events...
| (other threads) |
+-------------------------+
Following one example. Consider this code:
console.log("A");
setTimeout(() => {
console.log("B"); // runs in a later task
}, 0);
console.log("C");
// Output: A, C, B
Step by step:
- The whole script is itself a task.
console.log("A")runs and printsA. setTimeoutis a Web API. The engine hands the callback and the delay to the browser's timer system and returns immediately. Nothing is printed.console.log("C")printsC. The script ends and the call stack is empty.- Meanwhile the timer (0 ms) has expired, and the browser has put the callback in the task queue.
- The event loop sees an empty stack, takes the callback task and runs it:
Bis printed.
B comes last even with a delay of 0, because a callback can only run when the stack is empty. A delay is a minimum waiting time, not a guarantee.
Rendering waits too. The browser can only repaint the page between tasks (step 3). If you change the DOM several times in one task, the user only ever sees the final state. If one task runs for 2 seconds, the page cannot repaint for 2 seconds, which is what users experience as a frozen page.
const box = document.querySelector("#box");
box.style.background = "red";
box.style.background = "blue"; // the user never sees red: no render step ran in between
One loop per agent. Every window (and each same-origin group of iframes) has its own event loop. Each Web Worker has its own as well. Node.js has a different event loop built on the libuv library, with more phases (timers, I/O callbacks, setImmediate, close callbacks), covered in 11.3. The concepts of tasks, microtasks and run-to-completion are the same.
Key Takeaways
- The event loop repeatedly takes one task, runs it to completion, drains the microtask queue, and gives the browser a chance to render.
- Asynchronous APIs hand work to the browser and return immediately. Their callbacks are queued and run later, only when the call stack is empty.
- A timer delay is a minimum, never an exact time.
- Rendering happens between tasks, so a long task freezes both input handling and painting.
- Each window and each worker has its own event loop. Node.js uses a different loop implementation built on the same principles.
Practice
- (Beginner) Predict the output order of a script that logs
1, schedulessetTimeout(() => console.log(2), 0), schedulessetTimeout(() => console.log(3), 10), and logs4. Run it to check.- (Intermediate) Create a button that, when clicked, sets a status element to "Working..." and then runs a loop that takes about 3 seconds before setting it to "Done". Explain why "Working..." never appears. Fix it so the text is visible before the heavy work starts, using only
setTimeout.- (Interview) "What is the event loop, and why does
setTimeout(fn, 0)not runfnimmediately?" Hint: describe the relationship between the call stack, the queue and the condition that must be true before a queued callback runs.
Task Queue and Microtask Queue
The previous topic described "the queue" as if there were only one. In reality there are two very different kinds of queue, and their priority rules explain output orders that otherwise look random. The distinction exists because some work must happen "as soon as possible, but after the current code", before the browser handles any other event or repaints. Promise reactions are the main example: when a promise settles, its handlers should run before anything else gets a chance to observe an inconsistent state.
Tasks (macrotasks). A task is a unit of work scheduled by the browser. Sources include:
- running a
<script>element setTimeoutandsetIntervalcallbacks- user input events (click, keydown, input...)
- network events (XHR
load, WebSocketmessage) postMessageandMessageChannelmessages
The browser may keep several task queues (for example one for user input and one for timers) and can choose which queue to serve next, typically prioritising user input. Inside a single queue, order is first in, first out. Only one task runs per event loop iteration.
Microtasks. A microtask is a smaller unit of work that runs right after the current task (or the current callback) finishes, before the next task and before rendering. Sources include:
- promise reactions (callbacks passed to
then,catch,finally) - the continuation of an
asyncfunction afterawait queueMicrotask(fn)MutationObservercallbacks
The microtask queue is drained completely. After each task, the event loop runs microtasks until the queue is empty, including microtasks added by other microtasks. This is the key rule.
One event loop iteration:
+------------------+ +------------------------------+ +-----------+
| run ONE task |-->| run ALL microtasks |-->| maybe |--> next iteration
| (e.g. a click | | (and those they schedule) | | render |
| handler) | | until the queue is empty | | |
+------------------+ +------------------------------+ +-----------+
The classic ordering puzzle.
console.log("1: script start");
setTimeout(() => console.log("2: timeout"), 0); // task
Promise.resolve()
.then(() => console.log("3: promise 1")) // microtask
.then(() => console.log("4: promise 2")); // microtask, queued when promise 1 finishes
queueMicrotask(() => console.log("5: microtask")); // microtask
console.log("6: script end");
// Output:
// 1: script start
// 6: script end
// 3: promise 1
// 5: microtask
// 4: promise 2
// 2: timeout
Why:
- The script is the current task. It prints
1and6, and schedules one task (the timeout) and two microtasks (promise 1,microtask). - The script task ends, so the microtask queue is drained:
promise 1runs (and its completion queuespromise 2), thenmicrotaskruns, thenpromise 2runs because the queue must be empty before moving on. - Only then does the event loop take the next task:
timeout.
The state of the queues just after the script ends:
Microtask queue: [ promise 1 ] [ microtask ]
Task queue: [ timeout ]
After running promise 1:
Microtask queue: [ microtask ] [ promise 2 ] <- promise 2 joins the end of the same drain
Task queue: [ timeout ]
Comparison.
| Task (macrotask) | Microtask | |
|---|---|---|
| Typical sources | setTimeout, setInterval, events, network callbacks, postMessage | Promise reactions, await continuations, queueMicrotask, MutationObserver |
| How many per iteration | One | All of them, until the queue is empty |
| Runs before rendering? | Rendering may happen between two tasks | Always before the next render |
| Can starve the page? | No, other tasks and renders interleave | Yes, a microtask that keeps scheduling microtasks blocks forever |
Microtask starvation. Because the queue is drained completely, a microtask that always schedules another one never lets the event loop move on. The page freezes as badly as with an infinite loop, even though each microtask is short.
function spin() {
queueMicrotask(spin); // each microtask schedules another: the queue never empties
}
// spin(); // freezes the tab: no task, no click, no render ever runs again
function polite() {
setTimeout(polite, 0); // each call is a new task: the browser can render and handle input between them
}
queueMicrotask in practice. Use it when you need to defer work until the current synchronous code has finished, but before anything else happens. A typical use is batching: many synchronous changes are collected, and one microtask processes them all.
const pending = [];
let scheduled = false;
function logChange(change) {
pending.push(change);
if (!scheduled) {
scheduled = true;
queueMicrotask(() => { // runs once, after all synchronous calls
console.log("Batch:", pending.splice(0));
scheduled = false;
});
}
}
logChange("a"); logChange("b"); logChange("c");
// Batch: ["a", "b", "c"] (a single log, not three)
Key Takeaways
- Tasks come from timers, events, network callbacks and messages. The event loop runs one task per iteration.
- Microtasks come from promise reactions,
awaitcontinuations,queueMicrotaskandMutationObserver. - After every task the microtask queue is drained completely, including microtasks added during the drain.
- Promise callbacks therefore always run before a
setTimeout(..., 0)callback scheduled at the same time. - A chain of microtasks that never ends starves the event loop and freezes the page. Use a task (
setTimeout) when you want to yield to the browser.
Practice
- (Beginner) Write a script that mixes two
console.logcalls, onesetTimeout(..., 0), onePromise.resolve().then(...)and onequeueMicrotask(...). Write down your predicted output order before running it.- (Intermediate) Predict the output of a
setTimeoutcallback that itself callsPromise.resolve().then(() => console.log("inner micro"))andsetTimeout(() => console.log("inner task"), 0), placed next to a second top-levelsetTimeoutthat logs"second task". Explain the order in terms of iterations of the event loop.- (Interview) "What is the difference between a microtask and a macrotask? Give an example where the difference changes the program's output." Hint: focus on how many of each run per event loop iteration, and when rendering can happen.
Timers and requestAnimationFrame
Programs often need to do something later (show a tooltip after 500 ms), repeatedly (poll a server every 30 seconds) or once per screen refresh (animate an element). JavaScript cannot simply "sleep", because sleeping would block the only thread. Instead, the browser provides scheduling APIs that queue a callback for the right moment.
setTimeout and clearTimeout. setTimeout(callback, delay, ...args) asks the browser to queue callback as a task after at least delay milliseconds. It returns a numeric ID that you can pass to clearTimeout to cancel it.
const id = setTimeout((name) => {
console.log(`Hello, ${name}`);
}, 1000, "Ada"); // extra arguments are passed to the callback
// Changed our mind before the second elapsed:
clearTimeout(id); // the callback will never run
A common pattern is a "delay" helper that returns a promise (see Promisification of Callback APIs, 5.2):
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await sleep(500); // pauses only this async function, not the thread
Why timers are not precise. The delay is a minimum. The actual delay is longer when:
- the main thread is busy: the callback waits until the current task and the microtasks have finished
- timers are nested deeply: after five levels of nested
setTimeoutcalls, browsers clamp the delay to at least 4 ms - the tab is in the background: browsers throttle timers to at most about once per second, and some (for example Chrome) throttle chained timers in long-hidden pages to once per minute
- the device is in a power-saving mode
const start = performance.now();
setTimeout(() => {
console.log(`Asked for 0 ms, waited ${performance.now() - start} ms`);
}, 0);
for (let i = 0; i < 1e8; i++) {} // busy loop: the timer callback must wait for it
// "Asked for 0 ms, waited 85.3 ms" (varies)
setInterval and its pitfalls. setInterval(callback, delay) queues the callback repeatedly, every delay ms, until clearInterval(id) is called. Two problems are common:
- Drift and pile-up. The interval does not wait for the previous callback to finish. If a callback takes longer than the interval, runs pile up back to back. Browsers skip some runs rather than queueing infinitely, but timing becomes irregular.
- Overlapping async work. If the callback starts an asynchronous request, a slow response can overlap with the next run.
A recursive setTimeout schedules the next run only after the current one has completed, which guarantees a gap between runs:
// setInterval: runs every 5 s even if the previous request has not finished
const intervalId = setInterval(pollServer, 5000);
// Recursive setTimeout: waits 5 s AFTER each request completes
let timeoutId;
async function pollLoop() {
await pollServer(); // finish this run completely...
timeoutId = setTimeout(pollLoop, 5000); // ...then schedule the next one
}
pollLoop();
// Stop either version when it is no longer needed (for example, when a component is removed)
clearInterval(intervalId);
clearTimeout(timeoutId);
setInterval(fn, 100) with fn taking 60 ms:
|--fn--| |--fn--| |--fn--|
0 60 100 160 200 260 -> starts are 100 ms apart, gaps are only 40 ms
recursive setTimeout(fn, 100):
|--fn--| |--fn--| |--fn--|
0 60 160 220 320 -> gaps are always 100 ms
requestAnimationFrame. Animating with setTimeout(step, 16) is a bad idea: the timer is not aligned with the display's refresh, so some frames get two updates and others none (visible as stutter), and it keeps running in background tabs. requestAnimationFrame(callback) (often shortened to rAF) asks the browser to run callback just before the next repaint. The browser calls it once per frame (60 times per second on a 60 Hz screen, 120 on a 120 Hz screen) and pauses it entirely in hidden tabs.
The callback receives a high-resolution timestamp. Base the animation on elapsed time, not on a frame counter, so that it runs at the same speed on every screen.
const ball = document.querySelector("#ball");
const speed = 200; // pixels per second
let start = null;
let frameId;
function step(timestamp) { // timestamp: ms since page load, same clock as performance.now()
if (start === null) start = timestamp;
const elapsed = timestamp - start;
const x = Math.min((elapsed / 1000) * speed, 400); // time-based, frame-rate independent
ball.style.transform = `translateX(${x}px)`;
if (x < 400) {
frameId = requestAnimationFrame(step); // must re-request every frame
}
}
frameId = requestAnimationFrame(step);
// cancelAnimationFrame(frameId); // stops the animation
Where each callback runs within one frame (about 16.7 ms at 60 Hz):
| task | microtasks | rAF callbacks | style | layout | paint | idle (requestIdleCallback) |
^ read/write DOM here; changes appear in this frame's paint
requestIdleCallback. For low-priority work (sending analytics, pre-computing data), requestIdleCallback(cb, { timeout }) runs cb when the browser has spare time in a frame. The callback receives a deadline object whose timeRemaining() tells you how long you may work. It is not available in all browsers (Safari does not support it), so feature-detect it.
Choosing a scheduling API.
| API | Runs | Use for |
|---|---|---|
setTimeout(fn, ms) | Once, as a task, after at least ms | Delays, deferring work to a later task, yielding to the browser |
setInterval(fn, ms) | Repeatedly, every ms | Simple periodic work that is short and synchronous |
Recursive setTimeout | Repeatedly, with a gap after each run | Polling and async periodic work |
requestAnimationFrame(fn) | Once, before the next repaint | Visual updates and animations |
requestIdleCallback(fn) | When the browser is idle | Low-priority background work |
queueMicrotask(fn) | After the current code, before any task | Batching, consistent async ordering |
Key Takeaways
setTimeoutandsetIntervalqueue tasks after a minimum delay. Busy threads, nesting, background tabs and power saving make the real delay longer.- Always keep the returned ID and clear timers you no longer need. Forgotten intervals are a classic memory leak (4.4).
- Prefer a recursive
setTimeouttosetIntervalwhen each run does asynchronous work or must not overlap. - Use
requestAnimationFramefor animations. It is synchronised with the display, paused in hidden tabs, and should use the timestamp to compute progress. - Use
requestIdleCallbackfor work that can wait, with a fallback where it is not supported.
Practice
- (Beginner) Build a countdown from 10 to 0 that updates a
<span>once per second using a recursivesetTimeout, and provide a "Stop" button that cancels it.- (Intermediate) Animate a square moving 300 px to the right over exactly 2 seconds, first with
setInterval(step, 16)moving 2.4 px per step, then withrequestAnimationFrameand elapsed time. Switch to another tab for 5 seconds during each animation and compare the results when you return.- (Interview) "Why should you use
requestAnimationFrameinstead ofsetTimeoutfor animations?" Hint: talk about synchronisation with the refresh rate, behaviour in background tabs, and where the callback runs in the frame.
Blocking vs Non-Blocking Code
Everything in this subchapter leads to one practical rule: never block the main thread. Blocking code keeps the call stack busy for a long time, so the event loop cannot process clicks, run timers or repaint. Non-blocking code starts an operation, returns immediately, and handles the result later in a callback. Understanding the difference lets you design code that keeps the page responsive.
An analogy. In a blocking coffee shop, the cashier takes your order, makes your coffee and hands it to you before taking the next order. The line does not move while the machine runs. In a non-blocking coffee shop, the cashier takes your order, gives you a number and serves the next customer. When your coffee is ready, your number is called. The cashier (the main thread) is never idle waiting for the machine (I/O).
Blocking code. Code is blocking when it does not return until its work is done. In the browser, two kinds of blocking matter:
- Long computations: loops over huge arrays, complex parsing, image processing written in JavaScript.
- Synchronous I/O: APIs that wait for an external resource before returning. Most are deprecated or absent in browsers, but some remain:
alert,confirmandprompt, synchronousXMLHttpRequest(deprecated on the main thread) and large synchronouslocalStorageoperations.
// Blocking: the page is frozen for the whole duration
button.addEventListener("click", () => {
const start = Date.now();
while (Date.now() - start < 3000) {} // busy-wait for 3 seconds
status.textContent = "Done"; // clicks, scrolling and animations were frozen meanwhile
});
// Non-blocking: start the operation, handle the result later
button.addEventListener("click", () => {
status.textContent = "Loading..."; // this paint happens right after the handler returns
fetch("/api/report") // network I/O happens on another thread
.then((res) => res.json())
.then((data) => {
status.textContent = `Loaded ${data.items.length} items`;
});
// the handler returns immediately; the page stays responsive
});
Blocking (3 s computation in a click handler):
main thread: [click handler ==============================] [render]
user clicks: x x x <- all wait; nothing repaints
Non-blocking (fetch):
main thread: [handler][render] ...idle, handles input... [then callback][render]
network: [===========request in flight==========]
Measuring "too long". The browser treats any task that runs longer than 50 ms as a long task. Humans perceive a delay above roughly 100 ms between an action and its visible response as lag, and the Core Web Vitals metric INP (Interaction to Next Paint) measures exactly this. Long tasks are marked in red in the DevTools Performance panel.
Strategies for heavy computation. Some work cannot be avoided, but it can be moved or split:
| Strategy | How | When |
|---|---|---|
| Chunking | Process a slice of the data, then yield to the event loop with setTimeout (or scheduler.yield() where supported) and continue | Large loops that must touch the DOM or main-thread state |
| Web Workers (6.4) | Run the computation on a separate thread and post the result back | CPU-heavy pure computation (parsing, compression, image filters) |
| Better algorithms | Reduce the complexity, cache results, avoid repeated DOM reads | Always the first thing to check |
| Server-side | Do the work on the server or at build time | Data that is the same for every user |
Chunking example. Split a big job into chunks and yield between them so that the browser can render and handle input:
function yieldToMain() {
// Use the Scheduler API where available, otherwise fall back to a new task
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function processAll(items, processItem) {
let lastYield = performance.now();
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
if (performance.now() - lastYield > 40) { // stay under the 50 ms long-task budget
await yieldToMain(); // let the browser paint and handle input
lastYield = performance.now();
}
}
}
processAll(hugeArray, (item) => { /* expensive work */ });
Note that yielding through a microtask (for example await Promise.resolve()) does not help: microtasks run before rendering and input handling, so the page stays frozen. You must yield through a task.
Non-blocking does not mean parallel. A non-blocking API lets the waiting happen elsewhere. Your callbacks still run one at a time on the main thread. Ten fetch calls can be in flight at once (concurrency), but their then callbacks never run at the same time (no parallelism).
| Term | Meaning | JavaScript example |
|---|---|---|
| Synchronous | The caller waits for the result before continuing | JSON.parse(text) |
| Asynchronous | The caller continues, and the result is delivered later | fetch(url) |
| Blocking | The thread cannot do anything else while waiting | A 3-second while loop, alert() |
| Non-blocking | The thread is free while the operation is in progress | Any I/O API with a callback or promise |
| Concurrent | Several operations are in progress during the same period | Three requests in flight at once |
| Parallel | Several computations execute at the same instant | Code running in a Web Worker alongside the main thread |
Key Takeaways
- Blocking code keeps the call stack busy, so input, timers and rendering all stall. Non-blocking code starts work and handles the result in a later callback.
- Tasks longer than 50 ms are long tasks. Delays above about 100 ms feel laggy and hurt INP.
- Split heavy loops into chunks and yield with a task (
setTimeout,scheduler.yield()), not a microtask. - Move CPU-heavy pure computation to a Web Worker.
- Asynchronous I/O gives you concurrency, not parallelism. Callbacks still run one at a time.
Practice
- (Beginner) Add a CSS animation (a spinning square) to a page, then add a button that runs a 3-second
whileloop. Observe what happens to the animation and to text selection while the loop runs.- (Intermediate) Write a function that computes the prime numbers below 5,000,000 and displays a progress bar while doing so. First write it as one loop (the bar will not update), then refactor it to process the range in chunks and yield between chunks so that the progress bar animates smoothly.
- (Interview) "What is the difference between concurrency and parallelism, and which one does the browser's event loop give you?" Hint: consider ten simultaneous
fetchcalls versus ten simultaneous CPU-heavy loops, and where Web Workers fit.
<a id="52-callbacks-and-promises"></a>
5.2 Callbacks and Promises
This subchapter follows how asynchronous code evolved in JavaScript, from plain callbacks and their structural problems to promises, the standard object that represents a future result, and the tools for composing promises and converting older callback APIs.
Callback Pattern and Error-First Convention
An asynchronous operation returns before its result exists, so the result cannot be the function's return value. The oldest and simplest solution is to pass the operation a function to call when the result is ready: a callback. Functions are first-class values in JavaScript (3.3), so passing one as an argument is natural.
An analogy: leaving your phone number at a busy restaurant. You do not stand at the counter waiting. The restaurant calls you back when a table is free.
Synchronous vs asynchronous callbacks. Not every callback is asynchronous. array.map(fn) calls fn immediately, before map returns. An asynchronous callback is called later, after the function that received it has returned.
// Synchronous callback: runs before forEach returns
[1, 2, 3].forEach((n) => console.log("sync", n));
console.log("after forEach"); // printed after all three "sync" lines
// Asynchronous callback: runs in a later task
setTimeout(() => console.log("async"), 0);
console.log("after setTimeout"); // printed BEFORE "async"
Event-style callbacks. DOM events use callbacks that can fire many times:
button.addEventListener("click", (event) => { // called on every click
console.log("Clicked at", event.clientX, event.clientY);
});
Completion-style callbacks. For a one-shot operation the callback receives the outcome. Here is a small function that loads a script and reports success or failure:
function loadScript(src, callback) {
const script = document.createElement("script");
script.src = src;
script.onload = () => callback(null, script); // success
script.onerror = () => callback(new Error(`Failed to load ${src}`)); // failure
document.head.append(script);
}
loadScript("/js/chart.js", (err, script) => {
if (err) {
console.error(err.message);
return; // stop here on failure
}
console.log("Loaded", script.src);
});
The error-first convention. Errors in asynchronous code cannot be caught with a try/catch around the call, because by the time the error happens the try block has long finished:
try {
setTimeout(() => {
throw new Error("too late"); // thrown in a later task, with a different call stack
}, 0);
} catch (e) {
console.log("never reached"); // the try block finished before the callback ran
}
So errors must be delivered to the callback, not thrown. Node.js standardised the error-first callback (also called "Node-style" or "errback"), which many browser libraries also follow:
- The callback is the last argument of the function.
- The callback's first parameter is an error:
null(orundefined) on success, anErrorobject on failure. - The remaining parameters hold the result and are only meaningful when the error is empty.
- The callback is called exactly once.
// Node.js: reading a file with an error-first callback
const fs = require("node:fs");
fs.readFile("config.json", "utf8", (err, text) => {
if (err) { // ALWAYS check the error first
console.error("Could not read config:", err.code); // e.g. "ENOENT"
return;
}
const config = JSON.parse(text); // safe: text exists when err is null
console.log(config.port);
});
Writing your own error-first API. Always call the callback asynchronously, even when the result is available immediately (for example from a cache). A function that is sometimes synchronous and sometimes asynchronous makes the order of operations unpredictable for callers. This problem is known as "releasing Zalgo".
const cache = new Map();
function getUser(id, callback) {
if (cache.has(id)) {
// Wrong: callback(null, cache.get(id)) would run synchronously here
queueMicrotask(() => callback(null, cache.get(id))); // always asynchronous
return;
}
fetchUserFromServer(id, (err, user) => {
if (err) return callback(err);
cache.set(id, user);
callback(null, user);
});
}
Key Takeaways
- A callback is a function passed to another function to be called later. Callbacks can be synchronous (
map,forEach) or asynchronous (setTimeout, events, I/O). try/catcharound an asynchronous call cannot catch errors that happen later in the callback.- Error-first callbacks take
(err, result): checkerrfirst and return early on failure. - A callback-based API should be consistently asynchronous and call its callback exactly once.
Practice
- (Beginner) Write
delayedSum(a, b, callback)that waits 500 ms and then callscallback(null, a + b), or callscallback(new TypeError(...))immediately (but asynchronously) if either argument is not a number. Use it with a proper error-first handler.- (Intermediate) Write a
loadImage(url, callback)function that follows the error-first convention, using anImageobject'sonloadandonerror. Then write a test that proves the callback is never called synchronously, even when the image is already in the browser cache.- (Interview) "Why do Node.js callbacks put the error as the first argument instead of throwing an exception?" Hint: think about which call stack is active when the asynchronous operation fails, and what would happen if the caller forgot to handle the error.
Callback Hell and Inversion of Control
Callbacks work well for one asynchronous step. Real tasks usually need several steps in sequence, each depending on the previous result: authenticate, then fetch the profile, then fetch the orders, then render. With callbacks, each step must be nested inside the previous step's callback. Two distinct problems follow: one about readability and one about trust.
Problem 1: callback hell (the pyramid of doom). The code drifts to the right with every step, and error handling is repeated at every level.
login(credentials, (err, session) => {
if (err) return showError(err);
getProfile(session.userId, (err, profile) => {
if (err) return showError(err);
getOrders(profile.id, (err, orders) => {
if (err) return showError(err);
getShippingStatus(orders[0].id, (err, status) => {
if (err) return showError(err);
render(profile, orders, status); // the actual logic, five levels deep
});
});
});
});
login(...
getProfile(...
getOrders(...
getShippingStatus(...
render(...) <- the "pyramid of doom"
The shape itself is a symptom of deeper problems:
- Error handling is duplicated at every level, and forgetting one
if (err)silently swallows an error. - Control flow is hard to express. Loops, conditionals, retries and "run these three in parallel and wait for all" require hand-written counters and flags.
- Variables leak between levels through closures, so any level can accidentally modify state used by another.
Refactoring into named functions flattens the indentation, but the logic is still scattered across functions that each know who comes next:
function start() { login(credentials, onLogin); }
function onLogin(err, session) {
if (err) return showError(err);
getProfile(session.userId, onProfile); // each step hard-codes the next one
}
function onProfile(err, profile) { /* ... */ }
Running callbacks in parallel by hand. Waiting for several independent operations shows how much bookkeeping callbacks require:
function loadAll(urls, callback) {
const results = new Array(urls.length);
let remaining = urls.length;
let failed = false;
urls.forEach((url, i) => {
getJSON(url, (err, data) => {
if (failed) return; // ignore results after the first error
if (err) {
failed = true;
return callback(err);
}
results[i] = data; // keep the original order
if (--remaining === 0) callback(null, results);
});
});
}
Problem 2: inversion of control. This is the more serious problem. When you pass a callback to a function, you hand control of your program's continuation to someone else's code. You trust that code to call your callback correctly. With third-party libraries, that trust can be misplaced. The callback might be:
| Failure | Consequence |
|---|---|
| Called too early (synchronously) | Code after the call runs in an unexpected order |
| Called too late or never | The program hangs silently, with no error |
| Called more than once | A payment is charged twice, a list is rendered twice |
| Called with the wrong arguments | Errors are silently ignored or misinterpreted |
| Errors thrown inside it are swallowed | Bugs disappear without a trace |
// You give a third-party analytics library control over your checkout flow:
analytics.trackPurchase(order, () => {
chargeCreditCard(order); // what if the library retries and calls this twice?
});
To protect yourself, you end up wrapping every callback in defensive code: a flag that ignores second calls, a timer that fails if the callback never arrives, and so on. Every project reinvents these guards.
What the solution needs. A better primitive should:
- Let the asynchronous function return a value that represents the future result, so that the caller keeps control.
- Guarantee that the outcome is delivered exactly once, and always asynchronously.
- Propagate errors automatically through a sequence of steps, like exceptions in synchronous code.
- Compose easily: sequences, parallel groups, races.
This is exactly what promises provide (next topic).
Key Takeaways
- Sequencing dependent callbacks creates deeply nested code, with error handling repeated at each level.
- Parallel work and other control flow (loops, retries) with callbacks requires manual counters and flags.
- Inversion of control means trusting other code to call your continuation exactly once, at the right time, with the right arguments.
- Promises solve both problems: the caller keeps a value representing the result, and the promise guarantees single, asynchronous delivery.
Practice
- (Beginner) Using
setTimeout-based callbacks, write code that logs "Step 1", "Step 2" and "Step 3", one second apart, where each step starts only after the previous one finishes. Notice the nesting.- (Intermediate) Write a wrapper
once(callback)that returns a function which callscallbackonly on its first invocation and logs a warning on subsequent ones. Then writewithTimeout(callback, ms)that callscallback(new Error("timeout"))if the wrapped function has not been invoked withinms. Use both to protect a deliberately misbehaving fake library function.- (Interview) "What is inversion of control in the context of callbacks, and how do promises address it?" Hint: compare who holds the continuation in each model, and list the guarantees a promise makes that a plain callback cannot.
Promise States and Lifecycle
A promise is an object that represents the eventual result of an asynchronous operation. Instead of passing your continuation into the operation, the operation returns a promise and you attach your continuation to it. Control stays with you.
An analogy: an order receipt with a number. When you order at a food counter, you immediately get a receipt (the promise). The food (the value) does not exist yet. Later, your number is called (fulfilled) or you are told the kitchen ran out (rejected). You can hand the receipt to a friend, and they can also wait for the food. Whatever happens, the outcome is announced once and never changes.
The three states.
resolve(value)
+----------------------> FULFILLED (has a value)
|
PENDING ----+
|
+----------------------> REJECTED (has a reason, usually an Error)
reject(error) or a thrown error
FULFILLED and REJECTED are "settled" states: final and immutable.
| State | Meaning | Data |
|---|---|---|
pending | The operation has not completed yet | None |
fulfilled | The operation succeeded | A value |
rejected | The operation failed | A reason (by convention an Error) |
A promise that is either fulfilled or rejected is called settled. Once settled, a promise never changes state again, and its value or reason never changes.
Creating a promise. The Promise constructor takes an executor function, which receives two functions: resolve and reject. The executor runs synchronously, immediately, inside the constructor. It typically starts an asynchronous operation and calls resolve or reject when it completes.
const coinFlip = new Promise((resolve, reject) => {
console.log("Executor runs now"); // synchronous
setTimeout(() => {
if (Math.random() < 0.5) {
resolve("heads"); // fulfil with a value
} else {
reject(new Error("tails")); // reject with an Error
}
}, 1000);
});
console.log("After constructor"); // printed after "Executor runs now"
coinFlip.then(
(value) => console.log("Won:", value), // runs if fulfilled
(error) => console.log("Lost:", error.message) // runs if rejected
);
The guarantees.
- Settles at most once. Calls to
resolveorrejectafter the first one are ignored. - Handlers always run asynchronously, as microtasks, even if the promise is already settled when the handler is attached.
- Late handlers still work. Attaching
thento a promise that settled an hour ago calls the handler with the stored value. - A thrown error in the executor rejects the promise, so you do not need a
try/catchinside it for synchronous code.
const p = new Promise((resolve, reject) => {
resolve(1);
resolve(2); // ignored: already settled
reject(new Error("x")); // ignored
});
p.then((v) => console.log(v)); // 1
const q = new Promise(() => {
throw new Error("oops"); // equivalent to calling reject(new Error("oops"))
});
q.catch((e) => console.log("Caught:", e.message)); // "Caught: oops"
Promise.resolve("ready").then((v) => console.log(v));
console.log("sync"); // printed first: handlers are always asynchronous
"Resolved" is not the same as "fulfilled". Calling resolve with another promise (or any thenable, an object with a then method) does not fulfil immediately. The outer promise follows the inner one and adopts its eventual state. The outer promise is then "resolved" (its fate is locked in) but still pending.
const inner = new Promise((resolve) => setTimeout(() => resolve("inner value"), 1000));
const outer = new Promise((resolve) => resolve(inner)); // resolved, but still pending
outer.then((v) => console.log(v)); // after 1 s: "inner value" (not the inner promise object)
This adoption mechanism is what makes promise chaining work (next topic), and it is why Promise.resolve(x) and await x work with promises from other libraries that expose a compatible then method.
Shortcuts.
Promise.resolve(42); // an already-fulfilled promise (or follows x if x is a thenable)
Promise.reject(new Error("no")); // an already-rejected promise
// ES2024: create a promise and get its resolve/reject functions outside the executor
const { promise, resolve, reject } = Promise.withResolvers();
button.addEventListener("click", () => resolve("clicked"), { once: true });
promise.then(console.log);
Inspecting state. There is no public property to read a promise's state synchronously, on purpose: code should react to settlement through handlers, not poll. In DevTools, logging a promise shows its internal [[PromiseState]] and [[PromiseResult]].
Unhandled rejections. If a promise is rejected and no rejection handler is ever attached, the browser fires an unhandledrejection event on window and logs "Uncaught (in promise)". Node.js crashes the process by default. Always handle rejections (see Error Handling in Async Code, 5.3).
Key Takeaways
- A promise represents a future result. It is
pending, then settles once tofulfilled(with a value) orrejected(with a reason), and never changes again. - The executor runs synchronously. A throw inside it rejects the promise.
- Handlers always run asynchronously as microtasks, even on an already-settled promise, and handlers attached late still receive the result.
- Resolving with a promise or thenable makes the outer promise follow it. "Resolved" means locked in, not necessarily fulfilled.
- Always reject with
Errorobjects so that stack traces are preserved, and always handle rejections.
Practice
- (Beginner) Create a promise
wait(ms)that fulfils with the string"done after <ms> ms". Attach a handler to it twice, once immediately and once inside asetTimeoutof 3 seconds, and confirm that both handlers receive the value.- (Intermediate) Write
loadImage(url)that returns a promise which fulfils with theHTMLImageElementon load and rejects with anErroron failure. UsePromise.withResolvers()for one version and the constructor for another, and test both with a valid and an invalid URL.- (Interview) "What is the difference between a promise being resolved and being fulfilled?" Hint: consider what happens when you call
resolvewith another pending promise.
Chaining then, catch and finally
A single promise replaces a single callback. The real power of promises appears with sequences: the method then returns a new promise, so steps can be chained in a flat list instead of nested pyramids, and errors flow down the chain like exceptions flow up a call stack.
How then works. p.then(onFulfilled, onRejected) registers handlers and immediately returns a new promise p2. When p settles, the relevant handler runs, and what the handler does determines the state of p2:
| The handler... | p2 becomes |
|---|---|
Returns a plain value v | Fulfilled with v |
| Returns nothing | Fulfilled with undefined |
| Returns a promise or thenable | Follows it: settles the same way, at the same time |
Throws an error e | Rejected with e |
| Is missing for the outcome that occurred | Settles the same way as p (pass-through) |
Flattening the pyramid. The callback example from the previous topic, assuming each function now returns a promise:
login(credentials)
.then((session) => getProfile(session.userId)) // returning a promise: next step waits for it
.then((profile) => getOrders(profile.id))
.then((orders) => getShippingStatus(orders[0].id))
.then((status) => render(status))
.catch((err) => showError(err)) // ONE handler for an error in ANY step
.finally(() => hideSpinner()); // runs in both cases
login --> then --> then --> then --> then --> catch --> finally
| | | | | ^
+---------+--------+--------+--------+--------+
a rejection anywhere skips the remaining then handlers
and jumps to the nearest catch
Passing values along. Each handler receives what the previous one returned.
Promise.resolve(2)
.then((x) => x * 10) // 20
.then((x) => {
console.log(x); // 20
// no return statement: the next handler gets undefined
})
.then((x) => console.log(x)); // undefined
Forgetting to return is the most common chaining bug. Without return, the chain does not wait for the inner promise, and its result is lost:
// Wrong: the inner promise is not returned
getUser(1)
.then((user) => {
getOrders(user.id); // starts the request but does not return it
})
.then((orders) => console.log(orders)); // undefined, and logged BEFORE the orders arrive
// Right
getUser(1)
.then((user) => getOrders(user.id)) // arrow with expression body returns implicitly
.then((orders) => console.log(orders));
catch. p.catch(onRejected) is shorthand for p.then(undefined, onRejected). A catch recovers: if its handler returns normally, the promise it returns is fulfilled, and the chain continues on the success path. To keep the chain rejected, rethrow.
fetchPrice("AAPL")
.catch((err) => {
console.warn("Using cached price:", err.message);
return cachedPrice; // recovery: the chain continues with this value
})
.then((price) => display(price)); // runs with the live OR the cached price
fetchPrice("AAPL")
.catch((err) => {
logToServer(err);
throw err; // rethrow: the next catch still sees the error
})
.then(display) // skipped on failure
.catch(showError);
then(a, b) versus then(a).catch(b). They are not equivalent. With the two-argument form, b does not catch errors thrown inside a:
p.then(
(v) => { throw new Error("bug in success handler"); },
(e) => console.log("Not called for the error above") // only handles rejections of p
);
p.then((v) => { throw new Error("bug in success handler"); })
.catch((e) => console.log("Caught:", e.message)); // handles both p's rejection and the bug
finally. p.finally(onFinally) runs a cleanup function whether p fulfils or rejects. The handler receives no arguments, and normally does not change the outcome: the value or error passes through. The exception is when onFinally throws or returns a rejected promise; then the chain rejects with that new error.
showSpinner();
fetchData()
.then(render)
.catch(showError)
.finally(() => hideSpinner()); // always runs; the spinner never gets stuck
Promise.resolve(5)
.finally(() => 99) // return value of finally is ignored
.then((v) => console.log(v)); // 5
Branching versus chaining. Calling then twice on the same promise creates two independent branches, not a chain:
const p = Promise.resolve(1);
p.then((x) => x + 1).then((x) => console.log("chain:", x)); // chain: 2
p.then((x) => console.log("branch:", x)); // branch: 1 (gets p's value, not 2)
+--> then(x + 1) --> then(log) <- chain
p ---+
+--> then(log) <- separate branch
Nesting when you need earlier values. Sometimes a later step needs a value from an earlier step. Nesting one level is acceptable, or you can carry an object forward (and async/await, 5.3, makes this trivial):
getUser(1)
.then((user) =>
getOrders(user.id).then((orders) => ({ user, orders })) // bundle both values
)
.then(({ user, orders }) => render(user, orders));
Key Takeaways
thenalways returns a new promise whose state depends on what the handler returns or throws.- Return promises from handlers so that the chain waits for them. A missing
returnis the most common bug. - A rejection skips
thenhandlers until the nearestcatch. Acatchthat returns normally recovers the chain, and rethrowing keeps it rejected. then(a, b)does not catch errors thrown ina. Preferthen(a).catch(b).finallyis for cleanup. It receives no arguments and passes the original outcome through unless it throws.
Practice
- (Beginner) Starting from
Promise.resolve(3), build a chain that doubles the value, adds 4, then logs it. Then insert a step that throws an error when the value is greater than 5, and add acatchthat logs the message and afinallythat logs "done".- (Intermediate) Given
getUser(id),getPosts(userId)andgetComments(postId)(implement them as fake functions that return promises after a random delay), build a single flat chain that logs the comments of the first post of user 1. MakegetPostsrandomly reject, and ensure that the chain shows a fallback message and still logs "finished" at the end.- (Interview) "What is logged by
Promise.reject(new Error('x')).catch(e => 'recovered').then(v => console.log(v)), and why?" Hint: consider what state the promise returned bycatchis in when its handler returns normally.
Promise Combinators
Chaining handles sequential steps. Many real situations involve several independent operations at once: load a user's profile, settings and notifications in parallel, use whichever of two mirrors answers first, or give up if a request takes more than 5 seconds. The Promise class provides four static methods, called combinators, that take an iterable of promises and return a single promise.
Promise.all: everything must succeed. Fulfils with an array of all values, in the same order as the input (not the completion order). It rejects as soon as any input rejects, with that reason ("fail-fast").
const [user, settings, notifications] = await Promise.all([
fetchJSON("/api/user"), // all three requests start immediately...
fetchJSON("/api/settings"),
fetchJSON("/api/notifications"), // ...and run concurrently
]);
// total time is about the slowest request, not the sum of the three
Fail-fast does not cancel the other operations. They keep running; their results are simply ignored. Cancellation needs AbortController (5.3).
Promise.allSettled: wait for everything, never reject. Fulfils, once all inputs have settled, with an array of outcome objects: { status: "fulfilled", value } or { status: "rejected", reason }. Use it when partial success is acceptable.
const results = await Promise.allSettled([
sendEmail(a), sendEmail(b), sendEmail(c),
]);
const failed = results
.map((r, i) => ({ ...r, index: i }))
.filter((r) => r.status === "rejected");
console.log(`${results.length - failed.length} sent, ${failed.length} failed`);
failed.forEach((f) => console.error(`#${f.index}:`, f.reason.message));
Promise.race: the first to settle wins. Settles the same way as the first input to settle, fulfilled or rejected. The classic use is a timeout:
function timeout(ms) {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms} ms`)), ms)
);
}
const data = await Promise.race([
fetchJSON("/api/slow-report"),
timeout(5000), // rejects if the request is not done in 5 s
]);
(For fetch, AbortSignal.timeout(ms) is better because it actually stops the request, see 5.3.)
Promise.any: the first to succeed wins. Fulfils with the first fulfilled value and ignores rejections. It rejects only if all inputs reject, with an AggregateError whose errors property lists every reason.
try {
const image = await Promise.any([
loadImage("https://cdn1.example.com/logo.png"),
loadImage("https://cdn2.example.com/logo.png"), // whichever mirror succeeds first
]);
document.body.append(image);
} catch (err) {
console.error(err instanceof AggregateError); // true: every mirror failed
console.error(err.errors); // [Error, Error]
}
Comparison.
| Combinator | Fulfils when | Rejects when | Result | Typical use |
|---|---|---|---|---|
Promise.all | All fulfil | The first rejection | Array of values (input order) | Independent requests that are all required |
Promise.allSettled | All settle | Never | Array of {status, value | reason} | Batch jobs where partial failure is fine |
Promise.race | The first settles by fulfilling | The first settles by rejecting | The first value or reason | Timeouts, "first response wins" |
Promise.any | The first fulfils | All reject | The first value, or an AggregateError | Redundant sources, fallbacks |
Inputs: A: ----ok(1) B: --ok(2) C: ------fail(x)
Promise.all ------------------------------------ fail(x) (at C's time)
Promise.allSettled ------------------------------------ [{ok 1},{ok 2},{fail x}]
Promise.race -- ok(2) (B settled first)
Promise.any -- ok(2) (B fulfilled first)
Edge cases.
- Non-promise values in the input are treated as already-fulfilled promises:
Promise.all([1, fetchX()])works. - With an empty array,
allandallSettledfulfil immediately with[],anyrejects immediately with anAggregateError, andracestays pending forever. - The input is any iterable, so a
Setor a generator also works.
Key Takeaways
- Combinators turn many promises into one. All of them start waiting at once, so the operations run concurrently.
allneeds every operation to succeed and fails fast.allSettledalways reports every outcome.racefollows the first to settle (success or failure).anyfollows the first success and fails only if everything fails.- Combinators do not cancel the losing or remaining operations. Use
AbortControllerfor that. - Results of
allandallSettledare in input order, regardless of completion order.
Practice
- (Beginner) Write
delay(ms, value)that fulfils withvalueafterms. UsePromise.allwith delays of 300, 100 and 200 ms and confirm that the results come back in input order and that the total time is about 300 ms.- (Intermediate) Implement your own
promiseAll(iterable)using onlynew Promiseandthen, handling input order, non-promise values, fail-fast rejection and the empty input case. Test it against the built-inPromise.all.- (Interview) "When would you choose
Promise.allSettledoverPromise.all, andPromise.anyoverPromise.race?" Hint: think about whether a single failure should abort the whole operation, and whether a fast failure should win.
Promisification of Callback APIs
Promises arrived in the language in ES2015, but many APIs were designed earlier around callbacks and events: Node's original fs module, setTimeout, XMLHttpRequest, FileReader, IndexedDB, and countless libraries. To use them with promise chains and async/await, you wrap them in a function that returns a promise. This is called promisification.
The general recipe. Call the old API inside a promise executor, and connect its success and failure signals to resolve and reject.
// Callback-based original: readConfig(path, (err, data) => {...})
function readConfigAsync(path) {
return new Promise((resolve, reject) => {
readConfig(path, (err, data) => {
if (err) reject(err); // error-first: error -> reject
else resolve(data); // success -> resolve
});
});
}
readConfigAsync("app.json").then(console.log).catch(console.error);
Timers. setTimeout has no error, only completion:
const delay = (ms, value) => new Promise((resolve) => setTimeout(resolve, ms, value));
delay(1000, "hi").then(console.log); // "hi" after 1 s
A generic promisify for error-first functions. Because the error-first convention is uniform, one helper can convert any such function. The callback is appended as the last argument.
function promisify(fn) {
return function (...args) {
return new Promise((resolve, reject) => {
fn.call(this, ...args, (err, result) => { // keep `this` for methods (3.3, call/apply/bind)
if (err) reject(err);
else resolve(result);
});
});
};
}
const loadScriptAsync = promisify(loadScript); // from the error-first topic
await loadScriptAsync("/js/chart.js");
Node.js provides this helper as util.promisify, and most core modules now have promise versions built in:
// Node.js
import { promisify } from "node:util";
import { readFile } from "node:fs/promises"; // promise-based fs, no wrapping needed
import { setTimeout as sleep } from "node:timers/promises";
const text = await readFile("config.json", "utf8");
await sleep(500);
Promisifying event-based APIs. Objects that fire events (load/error, success/error) are promisified the same way. Listen for the success event and the failure event, and remove both listeners after the first one fires so that nothing leaks.
function readFileAsText(file) { // wraps the browser's FileReader
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}
function once(target, eventName) { // wait for the next occurrence of any DOM event
return new Promise((resolve) => {
target.addEventListener(eventName, resolve, { once: true }); // auto-removed after firing
});
}
const input = document.querySelector("input[type=file]");
await once(input, "change");
const text = await readFileAsText(input.files[0]);
When not to promisify. A promise settles once. It is a poor fit for things that happen many times: clicks, WebSocket messages, sensor readings. For repeated events, keep callbacks, or use async iterators or streams (5.3).
| Source | Promisify? | Why |
|---|---|---|
One-shot callback (readFile, setTimeout) | Yes | Exactly one outcome |
One-shot event (load, DOMContentLoaded) | Yes | Wait once for the first occurrence |
Repeated events (click, message) | No (or only "the next one") | A promise cannot deliver several values |
| API that already returns promises | No | Do not wrap promises in new Promise (an anti-pattern) |
The explicit-construction anti-pattern. Wrapping an existing promise in new Promise adds code, and often loses errors:
// Anti-pattern: redundant, and a throw inside then is never forwarded to reject
function getUserName(id) {
return new Promise((resolve, reject) => {
fetch(`/api/users/${id}`)
.then((res) => res.json())
.then((user) => resolve(user.name)); // missing .catch(reject): rejections are lost
});
}
// Correct: just return the chain
function getUserName(id) {
return fetch(`/api/users/${id}`)
.then((res) => res.json())
.then((user) => user.name);
}
Key Takeaways
- Promisification wraps a callback or event API in a function that returns a promise, mapping success to
resolveand failure toreject. - Error-first functions can be converted generically (
util.promisifyin Node.js). Many modern APIs already offer promise versions. - For event-based APIs, handle both the success and the failure event, and remove listeners after settling.
- Promises fit one-shot results. Repeated events need callbacks, async iterators or streams.
- Never wrap a promise in
new Promise. Return the existing chain instead.
Practice
- (Beginner) Promisify
navigator.geolocation.getCurrentPosition(success, error)intogetPosition()and log the latitude and longitude withthen/catch.- (Intermediate) Write
promisifyAll(obj)that, for every function-valued propertynameofobj, adds a propertynameAsyncthat is the promisified version (bound toobj). Test it on an object with two error-first methods that usethis.- (Interview) "What is the 'explicit promise construction' anti-pattern, and what bug does it commonly introduce?" Hint: consider what happens when the inner chain rejects and nobody calls
reject.
<a id="53-asyncawait-and-advanced-patterns"></a>
5.3 async/await and Advanced Patterns
This subchapter covers async/await, the syntax that lets promise-based code read like synchronous code, and the patterns built on it: error handling, controlling concurrency, consuming asynchronous sequences, cancelling work, and limiting how often operations run.
async Functions and await Semantics
Promise chains are much better than nested callbacks, but they are still not how we naturally write logic. Loops, conditionals and variables that span several steps become awkward when every step is a separate then callback. ES2017 introduced async functions and the await operator. They are built entirely on promises, but they let you write asynchronous code in the same shape as synchronous code.
async functions always return a promise. Marking a function async changes its return value:
- If the body returns a value
v, the function returns a promise fulfilled withv. - If the body throws, the function returns a promise rejected with the error.
- If the body returns a promise, the returned promise follows it.
async function getAnswer() {
return 42; // wrapped: the caller receives Promise<42>
}
async function fail() {
throw new Error("nope"); // becomes a rejected promise, not a synchronous throw
}
console.log(getAnswer()); // Promise { 42 }
getAnswer().then(console.log); // 42
fail().catch((e) => console.log(e.message)); // "nope"
All function forms can be async:
async function declaration() {}
const expression = async function () {};
const arrow = async () => {};
const obj = { async method() {} };
class Service { async load() {} }
await pauses the function, not the thread. await expr waits for the promise expr to settle:
- If it fulfils,
awaitevaluates to the value. - If it rejects,
awaitthrows the reason at that line, so you can use ordinarytry/catch. - If
expris not a promise, it is wrapped withPromise.resolve, soawait 5gives5(after a microtask).
// With then chains
function showUserOrders(id) {
return getUser(id)
.then((user) => getOrders(user.id).then((orders) => ({ user, orders })))
.then(({ user, orders }) => render(user, orders));
}
// With async/await: same behaviour, reads top to bottom
async function showUserOrders(id) {
const user = await getUser(id); // pause until the user arrives
const orders = await getOrders(user.id); // `user` is still in scope: no bundling needed
render(user, orders);
}
What happens during an await. When the engine reaches await, it suspends the async function: it saves its local variables and position (much like a generator, 3.3) and returns to the caller. The call stack unwinds and the event loop is free to run other work. When the awaited promise settles, the rest of the function is scheduled as a microtask and resumes exactly where it stopped.
async function demo() {
console.log("2: demo starts"); // runs synchronously, as part of the call
await null; // suspend; the rest becomes a microtask
console.log("4: demo resumes");
}
console.log("1: before");
demo();
console.log("3: after"); // the caller continues while demo is suspended
// 1: before
// 2: demo starts
// 3: after
// 4: demo resumes
Call stack over time:
[main]-[demo]-(await: demo suspended, returns a pending promise)-[main continues]-(stack empty)
|
microtask: [demo resumes] <-----+
So an async function runs synchronously up to its first await. This explains why code before the first await happens immediately on call.
Top-level await. In ES modules (<script type="module">, .mjs files), await is allowed at the top level of the module. Modules that import this module wait until it has finished evaluating.
// config.mjs
const response = await fetch("/config.json");
export const config = await response.json(); // importers receive a ready config
In classic scripts and inside non-async functions, await is a syntax error.
await in callbacks. await only works directly inside an async function. A common mistake is using it inside a callback passed to a non-async function, such as forEach:
async function saveAll(items) {
items.forEach(async (item) => {
await save(item); // awaits inside the arrow function only
});
console.log("All saved?"); // printed immediately: forEach does not wait for promises
}
async function saveAllCorrectly(items) {
for (const item of items) {
await save(item); // sequential: one after another
}
console.log("All saved"); // printed after the last save
}
Comparison.
| Callbacks | Promise chains | async/await | |
|---|---|---|---|
| Sequencing | Nesting | Flat chain of then | Ordinary statements |
| Error handling | if (err) at every level | .catch() | try/catch |
| Sharing variables between steps | Closures | Bundling or nesting | Ordinary local variables |
| Loops and conditionals | Manual recursion | Awkward | Ordinary for, if, while |
| Underlying mechanism | Functions | Promises | Promises plus suspension |
Key Takeaways
- An
asyncfunction always returns a promise. Returned values fulfil it and thrown errors reject it. awaitsuspends only the current async function, never the thread. The rest of the function resumes as a microtask.- An async function runs synchronously until its first
await. - A rejected awaited promise throws at the
awaitline, sotry/catchworks. awaitinside a callback does not make the outer function wait.forEachignores promises; usefor...oforPromise.all.
Practice
- (Beginner) Rewrite the following chain as an
asyncfunction:getUser(1).then(u => getPosts(u.id)).then(posts => console.log(posts.length)).- (Intermediate) Predict the output order of a script that logs
"A", calls an async function that logs"B", awaitsPromise.resolve(), then logs"C", and finally logs"D"after the call, with asetTimeout(() => console.log("E"), 0)placed before everything. Explain each step in terms of tasks and microtasks.- (Interview) "Does
awaitblock the JavaScript thread? What actually happens when execution reaches anawait?" Hint: describe what happens to the async function's state, what the caller receives, and how execution resumes.
Error Handling in Async Code
Asynchronous errors are easy to lose. A rejection that nobody handles does not stop the program in the browser. It just logs a warning while the user stares at a spinner forever. This topic shows where errors go in each style, how to catch them reliably, and how to detect the ones you missed.
try/catch/finally with await. Inside an async function, a rejected await throws, so the synchronous error-handling tools apply (3.4):
async function loadDashboard() {
showSpinner();
try {
const user = await getUser();
const stats = await getStats(user.id);
render(user, stats);
} catch (err) {
// catches a rejection from getUser or getStats AND any error thrown by render
showError(`Could not load the dashboard: ${err.message}`);
} finally {
hideSpinner(); // runs on success and on failure
}
}
Errors propagate through await like exceptions through calls. If you do not catch an error in an async function, its returned promise rejects, and the error reaches whoever awaits that function. You only need to handle errors at the level that can do something useful about them.
async function getStats(id) {
const res = await fetch(`/api/stats/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // turn HTTP errors into rejections
return res.json();
}
// No try/catch here: loadDashboard decides how to show the error.
Handling an error for one step only. A single try around many awaits is convenient but coarse. For a step with its own fallback, either use a small try block or attach .catch to that one promise:
async function loadProfile(id) {
const user = await getUser(id); // errors propagate to the caller
const avatar = await getAvatar(id).catch(() => DEFAULT_AVATAR); // local fallback
return { user, avatar };
}
return vs return await inside try. Returning a promise from inside a try block without awaiting it means its rejection happens after the function has already left the try. The catch will not see it.
async function withoutAwait() {
try {
return fetchData(); // the rejection escapes: catch is skipped
} catch (err) {
return "fallback";
}
}
async function withAwait() {
try {
return await fetchData(); // the rejection is thrown here, inside try
} catch (err) {
return "fallback"; // works as intended
}
}
Outside a try block, return await and return behave the same.
Floating promises. A promise that is created and then ignored is called floating. If it rejects, the error goes nowhere useful.
async function onSubmit() {
saveDraft(); // floating: no await, no catch; failures are invisible
await submitForm();
}
async function onSubmitFixed() {
saveDraft().catch((err) => console.warn("Draft not saved:", err)); // deliberately fire-and-forget
await submitForm();
}
Linters such as typescript-eslint's no-floating-promises rule detect these automatically.
Errors that escape all promises. A throw inside a plain callback scheduled by another API (a timer, an event listener) is not connected to any promise, so neither .catch nor an outer try/catch can catch it:
new Promise((resolve, reject) => {
setTimeout(() => {
throw new Error("lost"); // uncaught: this callback runs outside the executor
}, 100);
}).catch(() => console.log("never called")); // the promise stays pending forever
new Promise((resolve, reject) => {
setTimeout(() => {
try {
resolve(riskyOperation());
} catch (err) {
reject(err); // forward the error explicitly
}
}, 100);
});
Global safety nets. Use these to log errors you failed to handle, not as your error-handling strategy.
// Browser: promise rejections with no handler
window.addEventListener("unhandledrejection", (event) => {
reportToServer(event.reason); // the rejection reason
event.preventDefault(); // optional: suppress the default console message
});
// Browser: synchronous errors that reach the top of the stack
window.addEventListener("error", (event) => {
reportToServer(event.error);
});
// Node.js
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
process.exitCode = 1;
});
Rejecting with Error objects. Always reject (and throw) instances of Error or a subclass, never strings. Only Error objects carry a stack trace, and code that checks err.message or err instanceof NetworkError relies on it. The cause option (ES2022) lets you wrap a low-level error without losing it:
try {
await db.save(order);
} catch (err) {
throw new Error(`Saving order ${order.id} failed`, { cause: err }); // keeps the original error
}
Summary of where errors go.
| Code | Where the error goes | How to handle it |
|---|---|---|
throw in an async function | Rejects the function's promise | try/catch in the caller with await, or .catch |
Rejected await | Thrown at the await line | try/catch around it |
throw inside then | Rejects the promise returned by then | .catch later in the chain |
| Floating promise rejects | unhandledrejection event | await it or attach .catch |
throw in a timer or event callback | Global error event | try/catch inside the callback |
Key Takeaways
- In async functions, use
try/catch/finallyaroundawaitexactly as with synchronous code. - Let errors propagate to the level that can handle them. Convert non-exception failures (such as HTTP 500) into thrown errors.
- Use
return awaitinsidetryblocks, otherwise thecatchmisses the rejection. - Never leave promises floating:
awaitthem or attach a.catch. unhandledrejectionanderrorevents are safety nets for logging, not a substitute for local handling.
Practice
- (Beginner) Write an async function
safeParse(url)that fetches a URL and parses it as JSON, returningnulland logging a warning if the request fails, the status is not OK, or the body is not valid JSON.- (Intermediate) Create a page with a global
unhandledrejectionhandler that displays a toast message. Write three buttons that each produce a different kind of async error (a floating rejected promise, a rejection inside athenwith nocatch, and athrowinside asetTimeout) and observe which ones reach the handler. Explain why.- (Interview) "Inside a
tryblock in an async function, what is the difference betweenreturn promiseandreturn await promise?" Hint: consider when the rejection happens relative to the function leaving thetryblock.
Sequential vs Parallel Execution
async/await makes asynchronous code look sequential, and that is both its strength and its trap. Writing await before each call makes each operation wait for the previous one, even when they are independent. A page that loads three unrelated resources one after another takes three times longer than necessary.
The waterfall problem.
// Sequential: about 300 + 200 + 400 = 900 ms
async function loadPageSlow() {
const user = await fetchJSON("/api/user"); // 300 ms
const news = await fetchJSON("/api/news"); // 200 ms, but did not need to wait for user
const weather = await fetchJSON("/api/weather"); // 400 ms
return { user, news, weather };
}
// Concurrent: about max(300, 200, 400) = 400 ms
async function loadPageFast() {
const [user, news, weather] = await Promise.all([
fetchJSON("/api/user"),
fetchJSON("/api/news"),
fetchJSON("/api/weather"),
]);
return { user, news, weather };
}
Sequential:
user [=====300=====]
news [===200===]
weather [=======400=======] total ~900 ms
Concurrent:
user [=====300=====]
news [===200===]
weather [=======400=======] total ~400 ms
The rule. Requests start when the function that creates the promise is called, not when the promise is awaited. To run operations concurrently, start them all first, then await.
async function loadPage() {
const userPromise = fetchJSON("/api/user"); // starts now
const newsPromise = fetchJSON("/api/news"); // starts now, in parallel
const user = await userPromise;
const news = await newsPromise;
return { user, news };
}
This works, but has a trap: if newsPromise rejects while the function is still awaiting userPromise, nothing is handling it yet, and the browser may report an unhandled rejection. Promise.all attaches handlers to all promises immediately, so prefer it.
Real dependencies stay sequential. When one step needs the result of another, it must wait. Mix both patterns:
async function loadOrderPage(orderId) {
const order = await getOrder(orderId); // needed by everything below
const [customer, items, shipping] = await Promise.all([
getCustomer(order.customerId), // these three only depend on order
getItems(order.itemIds),
getShipping(order.shippingId),
]);
return { order, customer, items, shipping };
}
getOrder [====]
customer [======]
items [===]
shipping [========]
Loops: sequential vs concurrent.
const ids = [1, 2, 3, 4, 5];
// Sequential: one at a time, in order. Use when order matters or the server needs gentle use.
const sequential = [];
for (const id of ids) {
sequential.push(await getUser(id));
}
// Concurrent: all at once. Use for independent, few operations.
const concurrent = await Promise.all(ids.map((id) => getUser(id)));
// map returns an array of promises immediately; Promise.all waits for all of them
Limited concurrency. Firing 1,000 requests at once can overwhelm the server, hit rate limits, or saturate the browser (which limits HTTP/1.1 connections to about 6 per host and queues the rest). The middle ground is a concurrency limit: run at most n operations at the same time.
async function mapWithLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0; // index of the next item to process
async function worker() {
while (next < items.length) {
const i = next++; // claim an index (safe: single-threaded)
results[i] = await fn(items[i], i);
}
}
// Start `limit` workers that pull from the shared index until it runs out
const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
await Promise.all(workers);
return results; // in input order
}
const thumbnails = await mapWithLimit(imageUrls, 4, (url) => createThumbnail(url));
limit = 3, eight jobs of different lengths:
worker 1: [job0=====][job3==][job6=======]
worker 2: [job1==][job4========][job7==]
worker 3: [job2======][job5===]
Choosing a strategy.
| Strategy | Code | Time | Use when |
|---|---|---|---|
| Sequential | for...of with await | Sum of all durations | Steps depend on each other, order matters, or side effects must not overlap |
| Fully concurrent | Promise.all(items.map(fn)) | Longest duration | Few independent operations |
| Limited concurrency | Worker pool (mapWithLimit) | Roughly total / limit | Many independent operations, rate limits |
| Concurrent with partial failure | Promise.allSettled(...) | Longest duration | Each result is optional |
Key Takeaways
- An operation starts when its function is called.
awaitonly waits for a result that is already on its way. - Writing
awaitbefore each independent call creates a waterfall. Start independent operations together and combine them withPromise.all. - Keep steps sequential only when there is a real data dependency, and parallelise everything that depends on the same step.
ids.map(async ...)withPromise.allis concurrent.for...ofwithawaitis sequential.- For large batches, limit concurrency with a worker pool to respect servers and browser connection limits.
Practice
- (Beginner) Using a
delay(ms, value)helper, write two functions that each obtain three values from delays of 500, 700 and 300 ms: one sequential and one concurrent. Measure both withperformance.now().- (Intermediate) Given a list of 20 user IDs and a fake
getUser(id)that takes 200 to 600 ms, fetch all users with a concurrency limit of 4. Log when each request starts and ends to verify that no more than 4 are ever in flight, and confirm that results are in input order.- (Interview) "This code takes 3 seconds; how would you make it faster?
const a = await f(); const b = await g(); const c = await h(a);" Hint: identify which calls depend on which results, then decide which ones can overlap.
Async Iterators and for await of
A promise delivers one value. Some data arrives as a sequence of values over time: the pages of a paginated API, the chunks of a large download, the lines of a log file, messages from a server. You want to loop over them, but each item is only available asynchronously. Regular iterators (3.3) cannot do this, because their next() must return the item synchronously. Async iterators extend the iteration protocol to asynchronous sequences.
The protocol. Recall that a synchronous iterator has a next() method returning { value, done }. An async iterator has a next() method that returns a promise of { value, done }. An object is async iterable if it has a [Symbol.asyncIterator]() method that returns an async iterator.
| Sync iteration | Async iteration | |
|---|---|---|
| Iterable method | [Symbol.iterator]() | [Symbol.asyncIterator]() |
next() returns | { value, done } | Promise<{ value, done }> |
| Loop | for (const x of it) | for await (const x of it) |
| Generator syntax | function* with yield | async function* with yield and await |
for await...of. This loop awaits each next() result before running the body. It can only be used inside an async function or at the top level of a module.
async function* countdown(from) { // an async generator
for (let i = from; i > 0; i--) {
await new Promise((r) => setTimeout(r, 1000));
yield i; // deliver one value, then pause until asked again
}
}
for await (const n of countdown(3)) { // waits about one second per iteration
console.log(n); // 3, 2, 1
}
console.log("Liftoff");
Async generators. As in the example above, the simplest way to create an async iterator is an async function*. Inside it you can both await (to wait for data) and yield (to hand a value to the consumer). The generator is lazy: it only produces the next value when the consumer asks for it, so a slow consumer automatically slows down the producer (this is called backpressure).
Real example: pagination. Many APIs return results in pages, with a link to the next page. An async generator hides the paging logic and exposes a simple stream of items:
async function* fetchAllIssues(repoUrl) {
let url = `${repoUrl}/issues?per_page=100`;
while (url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const issues = await res.json();
yield* issues; // yield each item of this page, one by one
url = parseNextLink(res.headers.get("Link")); // null on the last page
}
}
for await (const issue of fetchAllIssues("https://api.example.com/repos/acme/app")) {
console.log(issue.title);
if (issue.title.includes("critical")) break; // stop early: no further pages are fetched
}
Early exit and cleanup. When the loop exits early (break, return, or a thrown error), for await calls the iterator's return() method. For an async generator, this runs any finally block inside it, which is the place to release resources such as connections or reader locks.
async function* readLines(stream) {
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split("\n");
buffer = lines.pop(); // keep the incomplete last line for the next chunk
yield* lines;
}
if (buffer) yield buffer;
} finally {
reader.releaseLock(); // runs on normal end, on break, and on error
}
}
Built-in async iterables.
ReadableStream(the body of afetchresponse, 5.4) is async iterable in current browsers and Node.js. Feature-detect it withSymbol.asyncIterator in ReadableStream.prototype.- In Node.js: readable streams,
fs.promises.opendirdirectory handles,readlineinterfaces, andevents.on(emitter, name). Array.fromAsync(asyncIterable)collects every value into an array.
const res = await fetch("/large-log.txt");
for await (const chunk of res.body) { // chunk is a Uint8Array
console.log(`Received ${chunk.byteLength} bytes`);
}
const firstTen = await Array.fromAsync(countdown(10)); // [10, 9, ..., 1]
for await over an array of promises. for await also works on a sync iterable of promises, awaiting each in turn. All the promises were already started, so this is concurrent, but results are still processed in array order. If a later promise rejects while you are still awaiting an earlier one, the rejection is unhandled at first, so prefer Promise.all or Promise.allSettled for arrays of promises.
Key Takeaways
- Async iterators deliver a sequence of values over time.
next()returns a promise of{ value, done }. for await...ofconsumes async iterables, awaiting each item in turn. It is only valid in async functions and modules.async function*generators combineawaitandyield. They are lazy, so they produce data only as fast as the consumer reads it.- Breaking out of
for awaitcallsreturn()on the iterator, running the generator'sfinallyblock for cleanup. - Streams (including
fetchresponse bodies) are async iterable in modern environments.
Practice
- (Beginner) Write an async generator
ticker(n, ms)that yields the numbers 1 ton, one everymsmilliseconds, and consume it withfor await...of.- (Intermediate) Write an async generator
paginate(fetchPage)wherefetchPage(page)returns a promise of{ items, hasMore }. Then write a helpertake(asyncIterable, n)(itself an async generator) that yields only the firstnitems, and verify with logging that no extra pages are requested oncenitems have been taken.- (Interview) "What is the difference between
for await (const x of arrayOfPromises)andawait Promise.all(arrayOfPromises)?" Hint: compare when processing of each result begins, what happens if a later promise rejects early, and the order of side effects.
Cancellation with AbortController
Promises have no built-in way to say "never mind". Yet cancellation is common in user interfaces: the user types a new search query before the previous results arrive, navigates away from a page mid-download, or closes a dialog that was loading data. Without cancellation, stale responses can overwrite fresh ones, and bandwidth and battery are wasted. The web platform's standard cancellation mechanism is AbortController.
Two objects, two roles.
- An
AbortControlleris the remote control. Whoever owns it can callcontroller.abort(reason). - Its
controller.signalis anAbortSignal, the receiver. You pass it to cancellable operations. It has anabortedflag, areason, and fires anabortevent.
This separation is deliberate: you can hand the signal to many operations without giving them the power to cancel each other.
AbortController -- abort() --> AbortSignal --event--> fetch #1 (rejects with AbortError)
(caller keeps) (passed down) --event--> fetch #2
--event--> addEventListener (listeners removed)
--event--> your own async function
Cancelling a fetch.
const controller = new AbortController();
fetch("/api/big-report", { signal: controller.signal })
.then((res) => res.json())
.then((data) => render(data))
.catch((err) => {
if (err.name === "AbortError") {
console.log("Request cancelled"); // expected: not a real failure
} else {
showError(err);
}
});
cancelButton.onclick = () => controller.abort(); // stops the request, including the body download
When aborted, the fetch promise (or the pending res.json()) rejects with a DOMException named "AbortError". Always distinguish it from real errors, so that cancelling does not show an error message to the user.
Timeouts. AbortSignal.timeout(ms) returns a signal that aborts automatically after ms milliseconds, with a DOMException named "TimeoutError". AbortSignal.any([...signals]) creates a signal that aborts when any of the given signals aborts, which lets you combine user cancellation with a timeout:
const userCancel = new AbortController();
try {
const res = await fetch("/api/search?q=js", {
signal: AbortSignal.any([userCancel.signal, AbortSignal.timeout(8000)]),
});
render(await res.json());
} catch (err) {
if (err.name === "TimeoutError") showError("The server is taking too long.");
else if (err.name !== "AbortError") showError(err.message);
}
Cancelling stale requests (search-as-you-type). Abort the previous request each time a new one starts. This also prevents the race condition in which a slow, old response arrives after a fast, new one and overwrites it.
let currentController = null;
searchInput.addEventListener("input", async () => {
currentController?.abort(); // cancel the previous search, if any
currentController = new AbortController();
const { signal } = currentController;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(searchInput.value)}`, { signal });
const results = await res.json();
renderResults(results); // only the latest request gets here
} catch (err) {
if (err.name !== "AbortError") showError(err);
}
});
Removing event listeners. addEventListener accepts a signal option. When the signal aborts, the listener is removed. One abort() can tear down a whole group of listeners, which is ideal for component cleanup.
const controller = new AbortController();
const { signal } = controller;
window.addEventListener("resize", onResize, { signal });
window.addEventListener("scroll", onScroll, { signal });
document.addEventListener("keydown", onKey, { signal });
function destroyComponent() {
controller.abort(); // removes all three listeners at once
}
Making your own functions cancellable. Accept a signal option, check it before starting, and listen for its abort event to stop work and reject.
function delay(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
signal?.throwIfAborted(); // reject immediately if already aborted
const id = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(id); // stop the underlying work
reject(signal.reason); // an AbortError DOMException by default
}
signal?.addEventListener("abort", onAbort, { once: true });
});
}
async function processBatch(items, { signal }) {
for (const item of items) {
signal.throwIfAborted(); // check between steps of long work
await processItem(item, { signal }); // pass the signal down to nested operations
}
}
What cancellation is not. Aborting a request stops the browser from waiting for the response. It does not undo work the server has already done. If a POST /orders request is aborted, the order may still have been created. Design non-idempotent operations accordingly (10.4).
Key Takeaways
AbortControllercreates the cancel action (abort()). ItsAbortSignalis passed to operations that should be cancellable.- An aborted
fetchrejects with aDOMExceptionnamed"AbortError". Handle it separately from real errors. AbortSignal.timeout(ms)gives a built-in timeout ("TimeoutError"), andAbortSignal.anycombines signals.- Abort the previous request when starting a new one to avoid stale responses overwriting fresh ones.
- Pass
{ signal }toaddEventListenerto remove many listeners with one call. Accept asignalin your own async functions and pass it down.
Practice
- (Beginner) Add a "Cancel" button to a page that starts a
fetchfor a large file. Clicking it should abort the request and display "Cancelled" (not an error message).- (Intermediate) Write
fetchWithTimeout(url, { timeout, signal })that aborts aftertimeoutms or when the caller'ssignalaborts, whichever comes first, and throws a customTimeoutErrorclass for timeouts. Test the three outcomes: success, timeout and caller cancellation.- (Interview) "In a search-as-you-type feature, results sometimes show for an older query than the one typed. Why does this happen and how do you fix it?" Hint: consider responses arriving out of order, and two fixes: cancellation, and ignoring responses that are not the latest.
Throttling, Debouncing and Retry Logic
Some events fire far more often than you want to react to them: input fires on every keystroke, scroll and resize can fire dozens of times per second, and mousemove even more. Running an expensive handler (a network request, a layout calculation) on each one wastes resources and makes the page sluggish. Other operations fail occasionally for transient reasons (a dropped connection, a server under load) and succeed if simply tried again. This topic covers three patterns for controlling when and how often an asynchronous operation runs.
Debouncing: wait until things calm down. A debounced function delays execution until a certain time has passed without any new call. Every new call resets the timer. An analogy: an elevator door waits a few seconds after the last person enters before closing. Each new person resets the wait.
Use it when only the final state matters: search suggestions after the user stops typing, saving a draft, validating a field, recalculating a layout after resizing stops.
function debounce(fn, wait) {
let timerId;
return function (...args) {
clearTimeout(timerId); // cancel the previously scheduled call
timerId = setTimeout(() => fn.apply(this, args), wait); // schedule a fresh one
};
}
const search = debounce((query) => {
fetch(`/api/search?q=${encodeURIComponent(query)}`); // one request per pause in typing
}, 300);
input.addEventListener("input", (e) => search(e.target.value));
Throttling: at most once per interval. A throttled function runs at most once every interval ms, no matter how often it is called. Calls in between are dropped (or one trailing call is kept). An analogy: a tap that drips once per second regardless of how hard you open it.
Use it when you need regular updates during a continuous activity: updating a scroll progress bar, infinite scroll checks, sending the cursor position in a collaborative app, rate-limiting a "Like" button.
function throttle(fn, interval) {
let lastRun = 0;
let trailingId = null;
let lastArgs, lastThis;
return function (...args) {
lastArgs = args; // always remember the most recent call
lastThis = this;
const remaining = interval - (Date.now() - lastRun);
if (remaining <= 0) { // enough time has passed: run now
clearTimeout(trailingId);
trailingId = null;
lastRun = Date.now();
fn.apply(lastThis, lastArgs);
} else if (!trailingId) { // schedule one trailing call for the end of the interval
trailingId = setTimeout(() => {
lastRun = Date.now();
trailingId = null;
fn.apply(lastThis, lastArgs); // uses the latest arguments, not the ones from scheduling time
}, remaining);
}
};
}
window.addEventListener("scroll", throttle(updateProgressBar, 100));
For purely visual updates, throttling to the frame rate with requestAnimationFrame is often the best choice:
let ticking = false;
window.addEventListener("scroll", () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => { // at most once per frame
updateProgressBar();
ticking = false;
});
}
});
Raw events: x x x x x x x x x x x x x x x . . . . . . . . x x x . . . . .
Throttle(100): X X X X X(trailing) X X
Debounce(300): X X
^ 300 ms after the last event
| Debounce | Throttle | |
|---|---|---|
| Runs | Once, after calls stop for wait ms | At most once per interval ms, during the activity |
| Continuous activity | Never runs until it stops | Runs regularly |
| Use for | Search box, autosave, resize end, form validation | Scroll position, drag, mousemove, rate-limited buttons |
| Analogy | Elevator door | Dripping tap |
Retry with exponential backoff. Network operations sometimes fail transiently. Retrying immediately and repeatedly can make things worse (if the server is overloaded, thousands of clients hammering it prevents recovery). Exponential backoff waits longer after each failure (for example 0.5 s, 1 s, 2 s, 4 s...). Adding jitter (randomness) prevents many clients from retrying in sync.
Rules for safe retries:
- Retry only transient failures: network errors, timeouts, HTTP 408, 429, 500, 502, 503, 504. Never retry 400, 401, 403 or 404: the result will be the same.
- Retry only idempotent operations (GET, PUT, DELETE) unless the server supports idempotency keys. Retrying a
POST /paymentscould charge twice. - Respect a
Retry-Afterheader when the server sends one. - Cap the number of attempts and the maximum delay, and support cancellation.
class HttpError extends Error {
constructor(response) {
super(`HTTP ${response.status}`);
this.status = response.status;
}
}
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
async function fetchWithRetry(url, options = {}, { retries = 4, baseDelay = 500, maxDelay = 8000 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(url, options);
if (!res.ok) throw new HttpError(res);
return res;
} catch (err) {
const retryable =
err.name !== "AbortError" && // never retry a deliberate cancel
(err instanceof TypeError || // fetch throws TypeError on network failure
(err instanceof HttpError && RETRYABLE.has(err.status)));
if (!retryable || attempt >= retries) throw err; // give up
const exp = Math.min(maxDelay, baseDelay * 2 ** attempt); // 500, 1000, 2000, 4000...
const wait = Math.random() * exp; // "full jitter": anywhere in [0, exp)
console.warn(`Attempt ${attempt + 1} failed (${err.message}); retrying in ${Math.round(wait)} ms`);
await delay(wait, { signal: options.signal }); // cancellable delay from the previous topic
}
}
}
attempt 1 X
<-0.5s->
attempt 2 X
<---1s--->
attempt 3 X
<-------2s------->
attempt 4 OK
Key Takeaways
- Debounce when only the final value matters: run once after the calls stop.
- Throttle when you need steady updates during continuous activity: run at most once per interval. For visual updates, throttle with
requestAnimationFrame. - Retry only transient errors and idempotent operations, with a capped number of attempts.
- Use exponential backoff with jitter so that retries do not overload a struggling server, and respect
Retry-After. - Make delays and retries cancellable, and never retry an
AbortError.
Practice
- (Beginner) Attach a
mousemovehandler that increments a counter, and display three counters side by side: raw calls, throttled calls (200 ms) and debounced calls (200 ms). Move the mouse continuously, then stop, and explain the differences.- (Intermediate) Extend
debounceso that the returned function hascancel()andflush()methods (flushruns a pending call immediately), and add aleadingoption that runs on the first call instead of the last. Write tests with fake timers or real short delays.- (Interview) "Your mobile app retries failed requests every second. During an outage, the server stays down even after the bug is fixed. What is happening and how would you change the retry policy?" Hint: think about the combined load of all clients, synchronised retries, and which errors deserve a retry at all.
<a id="54-client-server-communication-from-the-browser"></a>
5.4 Client-Server Communication from the Browser
This subchapter applies the asynchronous techniques of the chapter to the most common asynchronous operation on the Web: an HTTP request made from JavaScript. It covers the original XMLHttpRequest API, the modern fetch API and its building blocks, common body formats, the cross-origin rules that govern which requests are allowed, and streaming large downloads and uploads.
XMLHttpRequest and the AJAX Model
In the early Web, every interaction with the server meant a full page load: click a link or submit a form, and the browser discarded the current page and rendered a new one from scratch. Even changing a single number on the page required a round trip of the entire document. Around 2000, browsers gained an object that let JavaScript send HTTP requests in the background and update only part of the page. In 2005 the term AJAX (Asynchronous JavaScript and XML) was coined for this technique, and applications such as Gmail and Google Maps showed that web pages could feel like desktop applications.
The AJAX model.
Classic model: AJAX model:
Browser Server Browser (page stays loaded) Server
| -- GET /page ---> | | -- XHR GET /api/items -------> |
| <-- full HTML --- | | <-- small data (XML/JSON) ---- |
| (page replaced) | | JS updates one part of the DOM |
| -- POST form ---> | | -- XHR POST /api/items ------> |
| <-- full HTML --- | | <-- {"ok": true} ------------- |
| (page replaced) | | JS updates one part of the DOM |
The "X" (XML) is historical. Today the data is almost always JSON, but the name stuck. The same model underlies every single-page application, whether it uses XHR, fetch, or a library on top of them.
Using XMLHttpRequest. XHR is an event-based API. You create an object, configure it, attach handlers and send it:
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/products?category=books"); // method, URL (async by default)
xhr.responseType = "json"; // parse the body automatically
xhr.timeout = 10000; // ms; fires "timeout" if exceeded
xhr.onload = () => { // the response arrived (ANY status code)
if (xhr.status >= 200 && xhr.status < 300) {
renderProducts(xhr.response); // already parsed thanks to responseType
} else {
showError(`Server error: ${xhr.status}`);
}
};
xhr.onerror = () => showError("Network error"); // DNS failure, offline, CORS blocked...
xhr.ontimeout = () => showError("Request timed out");
xhr.send(); // body goes here for POST/PUT
The readyState lifecycle. Older code uses onreadystatechange and checks readyState:
readyState | Constant | Meaning |
|---|---|---|
| 0 | UNSENT | Object created, open() not called |
| 1 | OPENED | open() called |
| 2 | HEADERS_RECEIVED | Response headers and status are available |
| 3 | LOADING | Body is downloading (responseText holds partial data) |
| 4 | DONE | The operation is complete (success or failure) |
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
console.log(xhr.responseText);
}
};
Sending data and headers.
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/orders");
xhr.setRequestHeader("Content-Type", "application/json"); // after open(), before send()
xhr.onload = () => console.log(xhr.status, xhr.getResponseHeader("Location"));
xhr.send(JSON.stringify({ productId: 7, quantity: 2 }));
Progress events and abort. XHR's one remaining advantage over fetch is upload progress: xhr.upload.onprogress reports how many bytes have been sent. xhr.abort() cancels a request.
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) progressBar.value = e.loaded / e.total;
};
cancelButton.onclick = () => xhr.abort(); // fires the "abort" event
Synchronous XHR. Passing false as the third argument to open makes send() block until the response arrives. On the main thread this freezes the page and is deprecated. Browsers log warnings and restrict it. Never use it.
Why XHR was superseded. XHR works, but its event-based design predates promises: no chaining, manual promisification, scattered handlers, and a confusing API (responseText vs response, readyState numbers, header methods). fetch (next topic) replaces it for almost all new code. You still need to read XHR in legacy code, older libraries (jQuery's $.ajax, 8.2, and Axios in the browser use it internally), and when you need upload progress.
Key Takeaways
- AJAX means updating part of a page with data fetched in the background instead of reloading the whole page. Today the data is usually JSON.
- XHR is event-based:
open, configure, attachonload/onerror/ontimeout, thensend. onloadfires for every HTTP status. Checkxhr.statusyourself.onerroronly fires for network-level failures.readyStategoes from 0 to 4.responseTypecontrols how the body is parsed.- Synchronous XHR is deprecated. XHR remains useful mainly for upload progress events and legacy code.
Practice
- (Beginner) Use XHR to load
https://jsonplaceholder.typicode.com/userswithresponseType = "json"and render the user names in a list. Display a message for both a non-2xx status and a network error (test with a wrong domain).- (Intermediate) Write
xhrRequest({ method, url, body, headers, timeout })that returns a promise, rejects on network error, timeout and abort with distinct error messages, and resolves with{ status, headers, data }. Use it to send a JSONPOSTrequest.- (Interview) "What problem did AJAX solve, and why is it still called AJAX even though most applications no longer use XML?" Hint: contrast full-page reloads with partial updates, and consider what part of the name describes the technique versus the data format.
The Fetch API
fetch is the modern, promise-based replacement for XHR, available in all browsers, in Web Workers, and natively in Node.js (version 18 and later), Deno and Bun. It is built from reusable objects (Request, Response, Headers, next topic) that are also used by Service Workers (15.2), and it integrates naturally with async/await, AbortController and streams.
The basic call. fetch(resource, options) starts a request and returns a promise of a Response.
const response = await fetch("/api/products"); // waits for the status line and headers
const products = await response.json(); // waits for the body and parses it
Notice the two awaits. The first promise fulfils as soon as the headers arrive, so you can inspect the status before downloading a possibly large body. Reading the body is a second asynchronous step.
fetch() called ----> headers received ----------> body fully received
fetch promise fulfils response.json() fulfils
(status, headers available) (data available)
The most important surprise: HTTP errors do not reject. The fetch promise rejects only for network-level failures: the server is unreachable, DNS fails, the connection drops, CORS blocks the response, or the request is aborted. A 404 or 500 response is a successful HTTP exchange from fetch's point of view, so the promise fulfils. You must check response.ok (true for status 200 to 299) yourself.
async function getJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) { // 404, 500, etc.
throw new Error(`HTTP ${response.status} ${response.statusText} for ${url}`);
}
return response.json();
}
try {
const user = await getJSON("/api/users/42");
} catch (err) {
// TypeError for network failures, our Error for HTTP failures,
// DOMException "AbortError" for cancellation
showError(err.message);
}
| Situation | fetch promise | response.ok |
|---|---|---|
| 200 OK | Fulfils | true |
| 201 Created, 204 No Content | Fulfils | true |
| 404 Not Found, 500 Internal Server Error | Fulfils | false |
| Server unreachable, DNS failure, offline | Rejects (TypeError) | Not applicable |
| Blocked by CORS | Rejects (TypeError) | Not applicable |
| Aborted | Rejects (DOMException AbortError) | Not applicable |
Options. The second argument configures the request:
const response = await fetch("/api/orders", {
method: "POST", // default "GET"
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ productId: 7, quantity: 2 }), // string, FormData, Blob, URLSearchParams...
credentials: "same-origin", // when to send cookies (default)
signal: AbortSignal.timeout(10000), // cancellation/timeout (5.3)
});
| Option | Common values | Purpose |
|---|---|---|
method | "GET", "POST", "PUT", "PATCH", "DELETE" | HTTP method (10.1) |
headers | Object, array of pairs, or Headers | Request headers |
body | String, FormData, URLSearchParams, Blob, ArrayBuffer, ReadableStream | Request payload (not allowed with GET/HEAD) |
credentials | "same-origin" (default), "include", "omit" | Whether to send cookies and HTTP auth |
mode | "cors" (default), "same-origin", "no-cors" | Cross-origin behaviour (see CORS below) |
cache | "default", "no-store", "reload", "no-cache", "force-cache" | Interaction with the HTTP cache |
redirect | "follow" (default), "error", "manual" | What to do with 3xx responses |
signal | AbortSignal | Cancellation |
keepalive | true | Let the request outlive the page (analytics on unload) |
Building URLs safely. Never concatenate user input into a URL without encoding it. Use URL and URLSearchParams:
const url = new URL("/api/search", location.origin);
url.searchParams.set("q", userInput); // encodes spaces, &, =, etc.
url.searchParams.set("page", 2);
const res = await fetch(url); // /api/search?q=rock+%26+roll&page=2
A small reusable client. Most applications wrap fetch once, centralising the base URL, headers, JSON handling and error checking:
async function api(path, { method = "GET", body, headers = {}, signal } = {}) {
const response = await fetch(`/api${path}`, {
method,
headers: {
Accept: "application/json",
...(body !== undefined && { "Content-Type": "application/json" }),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
});
if (!response.ok) {
const detail = await response.text().catch(() => ""); // server error message, if any
throw new Error(`${method} ${path} failed: ${response.status} ${detail}`);
}
return response.status === 204 ? null : response.json(); // 204 has no body to parse
}
const order = await api("/orders", { method: "POST", body: { productId: 7 } });
fetch vs XHR.
XMLHttpRequest | fetch | |
|---|---|---|
| Style | Events and callbacks | Promises |
| HTTP error statuses | onload fires, check status | Promise fulfils, check ok |
| Cancellation | xhr.abort() | AbortController signal |
| Timeout | xhr.timeout | AbortSignal.timeout(ms) |
| Download progress | onprogress event | Read response.body stream (see Streaming) |
| Upload progress | xhr.upload.onprogress | Not available |
| Streaming | No (partial responseText only) | Yes, ReadableStream bodies |
| Available in Service Workers and Node.js | No | Yes |
Key Takeaways
fetch(url, options)returns a promise of aResponse. Reading the body (json(),text()...) is a second asynchronous step.- The promise rejects only on network failures, CORS blocks and aborts. HTTP error statuses fulfil, so always check
response.ok. - Configure method, headers, body, credentials, cache and cancellation through the options object.
- Build URLs with
URLandURLSearchParamsto encode user input correctly. - Wrap
fetchin a small helper that centralises headers, JSON handling and error checking.
Practice
- (Beginner) Fetch
https://jsonplaceholder.typicode.com/posts/1and display its title. Then change the URL to/posts/999999and make your code show "Post not found" based on the status, not on a missing field.- (Intermediate) Build the
api()helper above, extend it to support a query object (api("/products", { query: { page: 2, sort: "price" } })), and add a 10-second default timeout that callers can override or combine with their ownsignal.- (Interview) "A colleague's code catches no errors when the server returns 500, even though it has a
.catch(). Why?" Hint: consider what fetch considers a failure, and what theokproperty is for.
Request, Response and Headers Objects
fetch is a thin function on top of three classes that model HTTP messages. Most of the time you use them implicitly, but knowing them lets you inspect responses properly, clone and modify requests, build responses yourself (in Service Workers, tests and mocks), and understand why a body can only be read once.
Headers. A Headers object is a case-insensitive, multi-valued map of HTTP header fields.
const headers = new Headers({ "Content-Type": "application/json" });
headers.append("Accept-Language", "en");
headers.set("X-Request-Id", crypto.randomUUID()); // replace any existing value
console.log(headers.get("content-type")); // "application/json" (names are case-insensitive)
console.log(headers.has("Authorization")); // false
headers.delete("Accept-Language");
for (const [name, value] of headers) { // iterable, names come back lower-cased
console.log(name, value);
}
appendadds a value (several values are joined with", "),setreplaces.getSetCookie()returns the separateSet-Cookievalues where the environment exposes them (Node.js). Browsers never exposeSet-Cookieto JavaScript.- Some headers are forbidden in browser requests and silently ignored if you set them, because the browser must control them: for example
Host,Cookie,Origin,Referer,Content-LengthandConnection.
Reading response headers.
const res = await fetch("/api/report");
console.log(res.headers.get("Content-Type")); // "application/json; charset=utf-8"
console.log(res.headers.get("ETag")); // null if missing or not exposed by CORS
Response. A Response represents the reply. Its main properties are:
| Property | Meaning |
|---|---|
status, statusText | 404, "Not Found" (statusText is often empty with HTTP/2) |
ok | true if status is 200 to 299 |
headers | A read-only Headers object |
url | Final URL, after redirects |
redirected | true if any redirect was followed |
type | "basic" (same origin), "cors", "opaque" (no-cors) |
body | A ReadableStream of bytes, or null |
bodyUsed | true once the body has been read |
Body methods read the whole body and return a promise:
| Method | Result | Use for |
|---|---|---|
json() | Parsed JavaScript value | JSON APIs |
text() | String (decoded as UTF-8) | HTML, plain text, CSV |
blob() | Blob | Images, files to download or display |
arrayBuffer() | ArrayBuffer | Binary formats (4.2, Typed Arrays) |
bytes() | Uint8Array | Binary data, newer shorthand for arrayBuffer |
formData() | FormData | Multipart or URL-encoded bodies (mostly in Service Workers) |
A body can only be read once. The body is a stream: once consumed, the data is gone. A second read rejects with a TypeError. If you need it twice (for example to log the raw text and also parse it), clone the response first or read it as text and parse it yourself.
const res = await fetch("/api/data");
const copy = res.clone(); // must be cloned BEFORE reading
const data = await res.json();
const raw = await copy.text(); // works: the clone has its own body
// await res.text(); // TypeError: body has already been consumed
// Alternative: read once as text, then parse
const text = await (await fetch("/api/data")).text();
let parsed;
try {
parsed = JSON.parse(text);
} catch {
console.error("Server did not return JSON:", text.slice(0, 200));
}
Displaying a binary response.
const res = await fetch("/images/chart.png");
const blob = await res.blob();
const url = URL.createObjectURL(blob); // a temporary blob: URL pointing at the data
img.src = url;
img.onload = () => URL.revokeObjectURL(url); // free the memory once displayed
Constructing Response objects. You can create responses yourself, which is essential in Service Workers and convenient for tests:
const notFound = new Response("Not here", { status: 404, headers: { "Content-Type": "text/plain" } });
const jsonRes = Response.json({ ok: true }, { status: 201 }); // sets Content-Type: application/json
const failure = Response.error(); // a network-error response
// Mocking fetch in a test
globalThis.fetch = async () => Response.json([{ id: 1, name: "Test product" }]);
Request. A Request represents an outgoing request. fetch(url, options) creates one internally, but you can create it yourself to reuse or derive it:
const base = new Request("/api/items", {
headers: { Authorization: `Bearer ${token}` },
});
const withPost = new Request(base, { // copy everything, override some fields
method: "POST",
body: JSON.stringify({ name: "Lamp" }),
headers: { ...Object.fromEntries(base.headers), "Content-Type": "application/json" },
});
console.log(withPost.method, withPost.url); // "POST", "https://example.com/api/items"
const res = await fetch(withPost);
Request has the same body methods and the same read-once rule as Response, plus properties mirroring the fetch options (method, headers, credentials, mode, signal...).
+-------------------------+
fetch(input, init) --> | Request | -- network --> +--------------------------+
| method, url, headers | | Response |
| body (stream, once) | | status, ok, headers |
| credentials, signal... | | body (stream, once) |
+-------------------------+ | json() text() blob()... |
+--------------------------+
Key Takeaways
Headersis a case-insensitive map. Some request headers are forbidden and controlled by the browser.Responseexposesstatus,ok,headers,url,typeand a body that can be read withjson(),text(),blob(),arrayBuffer()and others.- Bodies are streams and can only be consumed once. Use
clone()before reading if you need the body twice. RequestandResponsecan be constructed directly, which is useful for reusable request templates, Service Workers and test mocks.- Use
URL.createObjectURLto display binary responses, and revoke the URL afterwards.
Practice
- (Beginner) Fetch any page from your own site and log every response header with a
for...ofloop overresponse.headers, along withstatus,type,urlandredirected.- (Intermediate) Write a
loggingFetch(input, init)wrapper that logs the method, URL, status and duration of every request, and also logs the first 200 characters of the response body, while still returning a response whose body the caller can read normally.- (Interview) "Why does calling
response.json()twice fail, and how can you work around it?" Hint: think about whatresponse.bodyis, and whatclone()does to it.
Working with JSON and FormData
The body of a request has to be encoded in a format that both sides agree on, announced by the Content-Type header. Picking the wrong format, or the wrong header, is a frequent source of "the server receives an empty body" bugs. In practice browsers send three formats: JSON for APIs, URL-encoded data for simple forms, and multipart form data for forms that include files.
| Format | Content-Type | Body type in JS | Typical use |
|---|---|---|---|
| JSON | application/json | JSON.stringify(obj) | REST and JSON APIs (10.4) |
| URL-encoded | application/x-www-form-urlencoded | URLSearchParams | Simple forms without files, OAuth token endpoints |
| Multipart | multipart/form-data; boundary=... | FormData | Forms with files, mixed text and binary |
| Raw binary | application/octet-stream, image/png... | Blob, File, ArrayBuffer | Uploading a single file as-is |
Sending JSON. Serialise with JSON.stringify (4.1) and set the Content-Type yourself. Without the header, fetch sends a string body as text/plain, and many servers will not parse it.
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" }, // required for JSON
body: JSON.stringify({ name: "Ada", roles: ["admin"], joined: new Date() }),
});
// Dates become ISO strings, undefined and functions are dropped (4.1)
Receiving JSON. response.json() parses the body and rejects with a SyntaxError if the body is not valid JSON, for example when an error page in HTML is returned. Check the status (and optionally the content type) before parsing:
const res = await fetch("/api/users/42");
const isJson = res.headers.get("Content-Type")?.includes("application/json");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!isJson) throw new Error("Expected JSON but got " + res.headers.get("Content-Type"));
const user = await res.json();
URLSearchParams for URL-encoded bodies. Passing a URLSearchParams object as the body automatically sets Content-Type: application/x-www-form-urlencoded;charset=UTF-8.
const body = new URLSearchParams({ grant_type: "password", username: "ada", password: "s3cret" });
await fetch("/oauth/token", { method: "POST", body });
// body sent: grant_type=password&username=ada&password=s3cret
FormData. A FormData object is a list of name/value pairs, where values can be strings or files. You can build it by hand or capture an entire <form> element in one line. Passed as the body, it is encoded as multipart/form-data.
<form id="profile-form">
<input name="displayName" value="Ada">
<input name="avatar" type="file" accept="image/*">
<input name="interests" type="checkbox" value="math" checked>
<input name="interests" type="checkbox" value="music" checked>
<button>Save</button>
</form>
const form = document.querySelector("#profile-form");
form.addEventListener("submit", async (event) => {
event.preventDefault(); // stop the classic full-page form submission
const data = new FormData(form); // reads every named, enabled control
data.append("clientTime", new Date().toISOString()); // add extra fields
console.log(data.get("displayName")); // "Ada"
console.log(data.getAll("interests")); // ["math", "music"]: repeated names are kept
const res = await fetch("/api/profile", {
method: "POST",
body: data, // DO NOT set Content-Type yourself
});
if (!res.ok) showError("Saving failed");
});
Never set Content-Type for FormData. The multipart format separates fields with a random boundary string, which must appear in the header (multipart/form-data; boundary=----WebKitFormBoundaryX). The browser generates it. If you set the header manually, the boundary is missing and the server cannot parse the body.
What a multipart body looks like on the wire:
POST /api/profile HTTP/1.1
Content-Type: multipart/form-data; boundary=----Bnd7x
------Bnd7x
Content-Disposition: form-data; name="displayName"
Ada
------Bnd7x
Content-Disposition: form-data; name="avatar"; filename="me.png"
Content-Type: image/png
<binary bytes of the image>
------Bnd7x--
Converting between formats.
const formData = new FormData(form);
// FormData -> plain object (repeated names keep only the last value) -> JSON
const obj = Object.fromEntries(formData);
await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(obj), // only useful if the form has no files
});
// FormData -> URLSearchParams (text fields only)
const params = new URLSearchParams(formData);
await fetch(`/api/search?${params}`); // a GET with the form's values in the query string
Key Takeaways
- The
Content-Typeheader must match the body format, or the server will not parse it. - For JSON, call
JSON.stringifyand setContent-Type: application/jsonyourself. Checkokbefore callingresponse.json(). URLSearchParamsproduces URL-encoded bodies and query strings, and sets the header automatically.FormDatacaptures forms (including files and repeated fields) and is sent as multipart. Never set itsContent-Typemanually, because the browser must add the boundary.- Use
Object.fromEntries(formData)to turn a file-less form into an object for JSON APIs.
Practice
- (Beginner) Create a contact form (name, email, message) and submit it with
fetchandFormDatatohttps://httpbin.org/post. Display theformfield of the echoed response.- (Intermediate) Write
toFormData(obj)that converts a nested object such as{ user: { name: "Ada", tags: ["a", "b"] }, avatar: fileObject }intoFormDatausing bracket notation (user[name],user[tags][]). Send it tohttpbin.org/post, then do the same with JSON, and compare what the server receives.- (Interview) "A file upload works in Postman but the server receives no file when sent from the browser with
fetch. The code setsContent-Type: multipart/form-data. What is wrong?" Hint: look at what a multipartContent-Typeheader must contain, and who generates it.
Cross-Origin Resource Sharing
If any website could use JavaScript to read responses from any other site, a malicious page could read your webmail, your bank balance or your company's intranet, because the browser would attach your cookies to the requests. The same-origin policy prevents this: by default, a script may only read responses from its own origin. CORS (Cross-Origin Resource Sharing) is the standard way for a server to relax that rule and say "this other origin is allowed to read my responses".
What an origin is. An origin is the triple scheme + host + port. Two URLs have the same origin only if all three match.
URL compared with https://app.example.com | Same origin? | Why |
|---|---|---|
https://app.example.com/api/users | Yes | Only the path differs |
http://app.example.com | No | Different scheme |
https://api.example.com | No | Different host (subdomains count) |
https://app.example.com:8443 | No | Different port |
What the same-origin policy restricts. It restricts reading, not sending. A page can embed cross-origin images, scripts and stylesheets, and submit forms to other sites. What it cannot do without permission is read a cross-origin response with fetch or XHR, or send certain kinds of requests. (Preventing unwanted cross-site writes is the job of CSRF protections, 10.3.)
Simple requests. A cross-origin request is "simple" if it could have been produced by an ordinary HTML form:
- method
GET,HEADorPOST - only CORS-safelisted headers (
Accept,Accept-Language,Content-Language, andContent-Typewith a limited set of values) Content-Type, if present, istext/plain,application/x-www-form-urlencodedormultipart/form-data
For a simple request, the browser sends it directly with an Origin header and checks the response:
Browser (page on https://app.example.com) Server https://api.other.com
| GET /products |
| Origin: https://app.example.com ---------> |
| |
| <--------- 200 OK |
| Access-Control-Allow-Origin: https://app.example.com
| |
| Browser checks the header: matches -> JS can read the response
| (if missing or different: response hidden, fetch rejects with TypeError)
Preflighted requests. Anything else, such as a PUT or DELETE, a JSON body (Content-Type: application/json), or an Authorization header, could have effects that a form could never produce. Before sending it, the browser asks for permission with a preflight request: an OPTIONS request that describes the real request.
Browser Server
| OPTIONS /orders |
| Origin: https://app.example.com |
| Access-Control-Request-Method: PUT |
| Access-Control-Request-Headers: content-type, authorization
| ---------> |
| <--------- 204 No Content |
| Access-Control-Allow-Origin: https://app.example.com
| Access-Control-Allow-Methods: GET, POST, PUT, DELETE
| Access-Control-Allow-Headers: Content-Type, Authorization
| Access-Control-Max-Age: 600 (cache this answer for 10 min)
| |
| PUT /orders (the actual request) ---------> |
| <--------- 200 OK + Access-Control-Allow-Origin |
If the preflight response does not allow the method or headers, the actual request is never sent, and fetch rejects with a TypeError.
Response headers set by the server.
| Header | Meaning |
|---|---|
Access-Control-Allow-Origin | Origin allowed to read: an exact origin, or * for any origin |
Access-Control-Allow-Methods | Methods allowed (preflight) |
Access-Control-Allow-Headers | Request headers allowed (preflight) |
Access-Control-Max-Age | Seconds the browser may cache the preflight result |
Access-Control-Allow-Credentials | true to allow requests that carry cookies or HTTP auth |
Access-Control-Expose-Headers | Response headers JavaScript may read beyond the safelisted ones (for example ETag, X-Total-Count) |
Credentials. By default, cross-origin fetch does not send cookies. To send them:
await fetch("https://api.other.com/me", { credentials: "include" });
The server must then reply with Access-Control-Allow-Credentials: true and an explicit origin in Access-Control-Allow-Origin. The wildcard * is not allowed with credentials. The cookies themselves must also allow cross-site sending (SameSite=None; Secure, 10.2).
Configuring the server (Express example, 12.2).
const ALLOWED = new Set(["https://app.example.com", "http://localhost:5173"]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED.has(origin)) {
res.set("Access-Control-Allow-Origin", origin); // echo the allowed origin, never blindly
res.set("Vary", "Origin"); // caches must store per-origin responses
res.set("Access-Control-Allow-Credentials", "true");
}
if (req.method === "OPTIONS") { // answer preflights
res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.set("Access-Control-Max-Age", "600");
return res.sendStatus(204);
}
next();
});
Common misconceptions.
- CORS is enforced by the browser, not the server.
curl, Postman and server-to-server calls ignore it. CORS protects users' browsers, not your API. Your API still needs authentication and authorisation. - CORS errors cannot be fixed from front-end code. Only the server (or a proxy on your own origin, such as a development server proxy) can grant access.
mode: "no-cors"does not bypass CORS. It produces an opaque response: status0, no headers, no readable body. It is only useful for fire-and-forget requests or caching in Service Workers.- CORS error details are hidden from JavaScript on purpose. The
TypeErrorsays only "Failed to fetch". Check the browser console and the Network panel for the real reason.
Key Takeaways
- An origin is scheme + host + port. The same-origin policy stops scripts from reading cross-origin responses without permission.
- Simple requests are sent directly. Other requests (custom methods, JSON bodies,
Authorization) are preceded by anOPTIONSpreflight. - The server grants access with
Access-Control-Allow-*headers. The browser enforces them. - Requests with credentials need
credentials: "include",Access-Control-Allow-Credentials: trueand an explicit origin (no*). - CORS protects users in browsers, not APIs. It is fixed on the server, and
no-corsgives an unreadable opaque response.
Practice
- (Beginner) Serve a page on
http://localhost:3000and a tiny API onhttp://localhost:4000. Fetch the API from the page and observe the CORS error in the console and the Network panel. Fix it by addingAccess-Control-Allow-Originon the API.- (Intermediate) Change the request to a
PUTwith a JSON body and anAuthorizationheader. Observe the preflight in the Network panel, make the API answer it correctly, and useAccess-Control-Max-Ageto stop repeated preflights. Then add cookies withcredentials: "include"and make that work too.- (Interview) "If CORS is not a server-side security mechanism, what does it actually protect, and why can't an attacker simply call your API with curl to get around it?" Hint: think about whose cookies and network position a malicious web page could use, compared with an attacker's own command line.
Streaming Responses and File Uploads
response.json() and response.text() wait for the entire body before giving you anything. That is fine for small API responses, but not for a 2 GB video file, a long-running server process that reports progress line by line, or a language model that generates text word by word. Streams let you process data as it arrives, chunk by chunk, with constant memory, and show progress to the user. Uploads raise the opposite problem: sending large files from the browser efficiently and with feedback.
Response bodies are streams. response.body is a ReadableStream of Uint8Array chunks. You read it with a reader, one chunk at a time:
const response = await fetch("/videos/lecture.mp4");
const reader = response.body.getReader();
const total = Number(response.headers.get("Content-Length")) || 0; // 0 if unknown (e.g. compressed)
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read(); // value is a Uint8Array chunk
if (done) break;
chunks.push(value);
received += value.byteLength;
if (total) progressBar.value = received / total; // download progress
}
const blob = new Blob(chunks, { type: response.headers.get("Content-Type") });
video.src = URL.createObjectURL(blob);
Server ---[chunk 1]---[chunk 2]---[chunk 3]--- ... ---[chunk n]---| done
| | | |
read() read() read() read() -> { done: true }
process process process finish
Note that Content-Length may be missing, or describe the compressed size while the stream delivers decompressed bytes, so treat progress as approximate.
Decoding text streams. For text, pipe the byte stream through a TextDecoderStream, which correctly handles multi-byte characters split across chunks.
const res = await fetch("/api/logs/stream");
const textStream = res.body.pipeThrough(new TextDecoderStream());
for await (const text of textStream) { // async iteration (5.3), where supported
logView.textContent += text; // append as it arrives
}
Streaming structured data (NDJSON). Servers often stream newline-delimited JSON: one JSON object per line. Combine the line-splitting generator from 5.3 with JSON.parse:
async function* ndjson(response) {
for await (const line of readLines(response.body)) { // readLines from "Async Iterators"
if (line.trim()) yield JSON.parse(line);
}
}
const res = await fetch("/api/search/stream?q=lamps");
for await (const result of ndjson(res)) {
appendResult(result); // results appear one by one, as the server finds them
}
The same idea underlies Server-Sent Events (EventSource), a standard text format for server push over one HTTP response, and the token-by-token output of AI chat interfaces. WebSocket (10.5) is the alternative when communication must be two-way.
Uploading files. A file chosen by the user is a File object (a Blob with a name and a modification date). There are two common ways to upload it:
const input = document.querySelector("input[type=file]");
const file = input.files[0];
// 1. Multipart: the file plus other fields, as an HTML form would send it
const data = new FormData();
data.append("title", "Holiday photo");
data.append("photo", file); // file name and type are included automatically
await fetch("/api/photos", { method: "POST", body: data });
// 2. Raw body: just the bytes, with metadata in headers or the URL
await fetch(`/api/photos/${encodeURIComponent(file.name)}`, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file, // the browser streams it from disk
});
Before uploading, validate on the client for user experience (size, type), and always validate again on the server: client checks are trivially bypassed.
const MAX = 10 * 1024 * 1024; // 10 MB
function validateUpload(file) {
if (file.size > MAX) return "The file must be smaller than 10 MB.";
if (!["image/png", "image/jpeg"].includes(file.type)) return "PNG or JPEG only.";
return null; // valid
}
const problem = validateUpload(file);
if (problem) showError(problem); // otherwise proceed with the upload
Upload progress. fetch has no upload progress events. When you need a progress bar for an upload, use XHR's upload.onprogress:
function uploadWithProgress(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total); // fraction of bytes sent
};
xhr.onload = () => (xhr.status < 300 ? resolve(xhr.response) : reject(new Error(`HTTP ${xhr.status}`)));
xhr.onerror = () => reject(new TypeError("Network error"));
xhr.send(formData);
});
}
await uploadWithProgress("/api/photos", data, (p) => (progressBar.value = p));
Chunked (resumable) uploads. For very large files, split the file into pieces with Blob.slice and upload them one by one. If the connection drops, only the current chunk must be resent. Production systems use protocols such as tus or cloud providers' multipart upload APIs, but the core idea is simple:
async function uploadInChunks(file, uploadId, chunkSize = 5 * 1024 * 1024) {
for (let start = 0; start < file.size; start += chunkSize) {
const chunk = file.slice(start, start + chunkSize); // a Blob view: no copy in memory
await fetchWithRetry(`/api/uploads/${uploadId}`, { // retry logic from 5.3
method: "PUT",
headers: {
"Content-Range": `bytes ${start}-${start + chunk.size - 1}/${file.size}`,
},
body: chunk,
});
progressBar.value = (start + chunk.size) / file.size; // progress per completed chunk
}
}
Streaming request bodies. In Chromium-based browsers, a ReadableStream can also be used as a request body (with the option duplex: "half", over HTTP/2 or later), for example to compress data while it is uploaded. Support is not universal, so feature-detect it and keep a fallback.
| Need | Tool |
|---|---|
| Small JSON response | response.json() |
| Large download with progress | response.body.getReader() and Content-Length |
| Text or line-by-line data as it arrives | pipeThrough(new TextDecoderStream()), async iteration, NDJSON |
| One-way server push of events | Server-Sent Events (EventSource) |
| Upload a file with other fields | FormData with fetch |
| Upload with a progress bar | XHR upload.onprogress |
| Very large or unreliable uploads | Blob.slice chunks with retries |
Key Takeaways
response.bodyis aReadableStreamof byte chunks. Read it incrementally for progress, low memory use and early display.- Decode text streams with
TextDecoderStream, and split them into lines or NDJSON records with an async generator. - Upload files with
FormData(multipart with other fields) or directly as the body (raw bytes). Validate on the client for convenience and on the server for security. fetchhas no upload progress events. Use XHRupload.onprogresswhen a progress bar is required.- Split very large uploads into
Blob.slicechunks so that failures only resend one piece.
Practice
- (Beginner) Download a large image with
fetch, read the body with a reader, and show a percentage counter based onContent-Length. Display the image when the download completes.- (Intermediate) Write a small Node.js server that streams 20 NDJSON lines, one every 300 ms. On the client, render each object as soon as its line arrives, and add a "Stop" button that aborts the stream with an
AbortControllerand releases the reader.- (Interview) "How would you implement an upload of a 4 GB video from the browser that survives a network interruption at 90%?" Hint: consider splitting the file, how the server tracks which parts it has received, how the client finds out where to resume, and what to do on transient failures.
<a id="6-browser-apis-dom-style-and-events"></a>