Asynchronous JavaScript is built on promises: a promise is an object standing for a result that is pending now and later either fulfills with a value or rejects with an error. async/await lets you work with promises like ordinary code and catch errors with try/catch. fetch rejects only on a network failure or an abort, so you check the response status with response.ok; independent requests run at the same time with Promise.all or Promise.allSettled, and you cancel them with AbortController and AbortSignal.timeout().
After this you will be able to
- Explain the execution order of synchronous code, promise microtasks and timers
- Write data loading with
fetchandasync/await, checkingresponse.okand telling HTTP errors, network failures, aborts and timeouts apart - Run independent requests in parallel with
Promise.allandPromise.allSettledand choose between them - Cancel a request with
AbortController, limit it withAbortSignal.timeout()and combine signals withAbortSignal.any()
Know beforehand
Verified with
Node.js 24 LTS (24.18.0)JavaScript (ES2024)Fetch APIAbortSignal.timeout / AbortSignal.any (Baseline 2024)Microsoft Edge 155 + Playwright 1.64
Checked against docs: October 11, 2026
Contents
- Synchronous and asynchronous: the event loop without deep theory
- Promises: states, then, catch and finally
- async/await and try/catch
- fetch: the response, response.ok and json()
- In parallel: Promise.all and Promise.allSettled
- Cancelling and timeouts: AbortController and AbortSignal.timeout
- Practice: a list with loading, error, retry and cancel
- Exercises and next steps
Synchronous and asynchronous: the event loop without deep theory
JavaScript runs code on a single thread: one statement at a time. Yet the page does not freeze while a network request is in flight. The trick is the event loop: long operations — a request, a timer, reading a file — are carried out by the browser or Node.js, and when the result is ready, a function that handles it is put in a queue. According to MDN, each such job runs to completion before the next one starts; it cannot be interrupted halfway.
console.log("1. synchronous code: start");
setTimeout(() => console.log("4. timer — a task from the queue"), 0);
Promise.resolve().then(() => console.log("3. promise — a microtask"));
console.log("2. synchronous code: end");$ node order.mjs
1. synchronous code: start
2. synchronous code: end
3. promise — a microtask
4. timer — a task from the queue- First, all synchronous code runs to the end: lines 1 and 2.
- Then microtasks, i.e. promise handlers. According to MDN, they run as soon as the current code has finished and the stack is empty, before control returns to the event loop.
- Then the next task from the queue: the timer callback, even with a 0 delay.
Promises: states, then, catch and finally
A promise is an object that represents the future result of an asynchronous operation. According to MDN it has three states: pending, fulfilled (there is a value) and rejected (there is a reason, an error). A fulfilled or rejected promise is called settled, and its state never changes again.
// A promise that fulfills after ms milliseconds
function wait(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
// A promise that rejects after ms milliseconds
function failAfter(ms, message) {
return new Promise((resolve, reject) => setTimeout(() => reject(new Error(message)), ms));
}
const order = wait(200, "Cappuccino is ready");
console.log(order); // still pending
order
.then((text) => {
console.log(text);
return text.length; // the value goes to the next then
})
.then((length) => console.log("Characters:", length))
.catch((error) => console.log("Error:", error.message))
.finally(() => console.log("Order closed"));
failAfter(100, "Out of milk")
.then(() => console.log("We never get here"))
.catch((error) => console.log("Error:", error.message));$ node promise-basics.mjs
Promise { <pending> }
Error: Out of milk
Cappuccino is ready
Characters: 19
Order closed- Right after creation the promise is
<pending>: there is no value yet. - "Out of milk" was printed before "Cappuccino is ready": the second promise settled after 100 ms, the first after 200. Output order follows completion time, not line order.
thenreturns a new promise: whatever the handler returns (text.length) is passed to the nextthen. That is how a chain is built.catchcatches a rejection from any step above it in the chain, andfinallyruns either way, which is handy for hiding a loading indicator.
pending
- Meaning
- The operation is still running
- Which handler runs
- None yet: we wait
fulfilled
- Meaning
- Success, there is a value
- Which handler runs
then, thenfinally
rejected
- Meaning
- Failure, there is a reason
- Which handler runs
catch, thenfinally
async/await and try/catch
then chains get unwieldy quickly. async/await is the same promise mechanism written as ordinary sequential code. According to MDN, an async function always returns a promise, and await pauses only that function until the promise settles; the rest of the program keeps running. If the promise rejects, await throws, and a plain try/catch catches it.
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const elapsed = (start) => `${Math.round((performance.now() - start) / 100) * 100} ms`;
async function makeOrder() {
const coffee = await wait(300, "cappuccino"); // the function waits here, other code does not
const dessert = await wait(300, "cheesecake");
return `${coffee} and ${dessert}`; // an async function always returns a promise
}
async function makeOrderParallel() {
const [coffee, dessert] = await Promise.all([wait(300, "cappuccino"), wait(300, "cheesecake")]);
return `${coffee} and ${dessert}`;
}
let start = performance.now();
console.log(await makeOrder(), "—", elapsed(start));
start = performance.now();
console.log(await makeOrderParallel(), "—", elapsed(start));
async function failingOrder() {
await wait(100);
throw new Error("The coffee machine is under maintenance"); // becomes a rejected promise
}
try {
await failingOrder();
} catch (error) {
console.log("Caught:", error.message);
}$ node async-await.mjs
cappuccino and cheesecake — 600 ms
cappuccino and cheesecake — 300 ms
Caught: The coffee machine is under maintenanceThe main trap is needless sequencing. In makeOrder the second await starts only after the first, so two independent 300 ms waits took 600 ms. In makeOrderParallel both promises are created at once and Promise.all waits for them together: 300 ms. If the second request does not depend on the result of the first, don't await them one after another.
fetch: the response, response.ok and json()
What an HTTP request and response consist of and how to call fetch was covered in the HTTP and JSON lesson. Here we wrap fetch in a function you can safely call from anywhere. The key fact from MDN: the fetch promise rejects only on a network error, while a 404 or 500 response arrives as an ordinary Response. So the status is checked by hand: response.ok is true for codes 200–299.
export class HttpError extends Error {
constructor(response) {
super(`HTTP ${response.status} for ${response.url}`);
this.name = "HttpError";
this.status = response.status;
}
}
// fetch + status check + JSON parsing in one place
export async function getJson(url, { signal } = {}) {
const response = await fetch(url, { signal }); // rejects only on a network error or abort
if (!response.ok) throw new HttpError(response); // 404 and 500 arrive as a normal response
return response.json(); // also a promise: the body is read as a stream
}import { getJson } from "./get-json.mjs";
const API = "http://127.0.0.1:8090/api";
async function show(url) {
try {
const data = await getJson(url);
console.log("OK:", data.map((item) => item.name).join(", "));
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
}
await show(`${API}/menu`);
await show(`${API}/missing`);
await show("http://127.0.0.1:8091/api/menu"); // nothing listens on this port$ node fetch-demo.mjs
OK: Cappuccino, Latte, Raf, Cheesecake
HttpError: HTTP 404 for http://127.0.0.1:8090/api/missing
TypeError: fetch failedA 2xx response
- What fetch returned
Response,ok: true- What getJson throws
- Nothing: it returns the data
A 404, 500, 503 response
- What fetch returned
Response,ok: false- What getJson throws
HttpErrorwithstatus
Server unreachable, no network
- What fetch returned
- A rejected promise
- What getJson throws
TypeError(in Node.js "fetch failed", reason incause)
Cancelled with abort()
- What fetch returned
- A rejected promise
- What getJson throws
AbortError
AbortSignal.timeout() expired
- What fetch returned
- A rejected promise
- What getJson throws
TimeoutError
response.json() is a promise too: the body arrives as a stream, and parsing starts once it has been received in full. If the server returned something other than JSON, this promise rejects with a SyntaxError.
In parallel: Promise.all and Promise.allSettled
A page often needs several independent responses: the menu, stock levels, promotions. Sequential awaits add up the time of all requests; running them in parallel takes as long as the slowest one. Promise has static methods for this; the two main ones are all and allSettled.
import { getJson } from "./get-json.mjs";
const API = "http://127.0.0.1:8090/api";
const elapsed = (start) => `${Math.round((performance.now() - start) / 100) * 100} ms`;
// Promise.all: we need every result, the requests run at the same time
let start = performance.now();
const [menu, stock] = await Promise.all([
getJson(`${API}/menu?delay=400`),
getJson(`${API}/stock?delay=400`),
]);
console.log(`all: ${menu.length} items, ${stock.beans} kg of beans — ${elapsed(start)}`);
// If one request fails, Promise.all rejects as a whole
try {
await Promise.all([getJson(`${API}/menu`), getJson(`${API}/promo`)]);
} catch (error) {
console.log("all rejected:", error.message);
}
// Promise.allSettled: wait for all of them and learn the outcome of each
const results = await Promise.allSettled([
getJson(`${API}/menu`),
getJson(`${API}/promo`),
getJson(`${API}/stock`),
]);
for (const r of results) {
console.log("allSettled:", r.status, r.status === "fulfilled" ? "got data" : r.reason.message);
}$ node parallel.mjs
all: 4 items, 12 kg of beans — 400 ms
all rejected: HTTP 503 for http://127.0.0.1:8090/api/promo
allSettled: fulfilled got data
allSettled: rejected HTTP 503 for http://127.0.0.1:8090/api/promo
allSettled: fulfilled got dataPromise.all
- Fulfills with an array of values in the same order as the promises
- Rejects immediately on the first error, and the other results are lost
- MDN: when tasks are related and you cannot continue without any of them
- Example: a product and its price for an order page
Promise.allSettled
- Always waits for all of them and never rejects
- Returns
{ status, value }or{ status, reason }objects - MDN: when tasks are independent or you need the outcome of each
- Example: dashboard widgets, where a broken promo must not hide the menu
Two more methods are less common: Promise.race settles together with the first promise that settles, successful or not, and Promise.any with the first successful one, rejecting only if all of them fail. You no longer need race for request timeouts: AbortSignal.timeout() does that and also stops the request itself.
Cancelling and timeouts: AbortController and AbortSignal.timeout
A request can become unnecessary: the user left the page, pressed "Cancel" or typed another character into a search box. And a server might not respond at all: fetch without a limit will wait a long time. For both cases fetch accepts a signal option. The signal comes from an AbortController: calling controller.abort() rejects the fetch promise with an AbortError. AbortSignal.timeout(ms) creates a signal that fires on its own after the given time, with a TimeoutError, and AbortSignal.any([...]) combines several signals: whichever fires first wins.
import { getJson } from "./get-json.mjs";
const SLOW = "http://127.0.0.1:8090/api/slow"; // the server responds after 5 seconds
// 1. Cancel on demand: the user left the page or pressed "Cancel"
const controller = new AbortController();
setTimeout(() => controller.abort(), 200);
try {
await getJson(SLOW, { signal: controller.signal });
} catch (error) {
console.log("1:", error.name);
}
// 2. Timeout: do not wait longer than 500 ms
try {
await getJson(SLOW, { signal: AbortSignal.timeout(500) });
} catch (error) {
console.log("2:", error.name);
}
// 3. Both: whichever comes first
const userCancel = new AbortController();
const signal = AbortSignal.any([userCancel.signal, AbortSignal.timeout(3000)]);
setTimeout(() => userCancel.abort(), 300);
try {
await getJson(SLOW, { signal });
} catch (error) {
console.log("3:", error.name, "— the user cancelled before the timeout");
}$ node abort.mjs
1: AbortError
2: TimeoutError
3: AbortError — the user cancelled before the timeout- Different error names let you react differently: a user cancellation is usually ignored silently, a timeout is shown as an error with an offer to retry.
- MDN notes that the timeout counts active time: in a page held in the back-forward cache the countdown is paused.
- MDN lists
AbortSignal.timeoutandAbortSignal.anyas Baseline 2024; Node.js has had them since versions 17.3 and 20.3. Very old browsers need a fallback built onsetTimeoutandcontroller.abort(). - Aborting stops waiting for the response on the client side, but the server may already have received and processed the request. Aborting does not roll back an action on the server.
Practice: a list with loading, error, retry and cancel
Let's build what all of this is for: a "Load menu" button that shows a loading state, renders the list, offers "Retry" on error and lets you cancel while the request is running. We won't wait longer than 5 seconds. The server uses node:http without external packages and also serves the page, so requests go to the same origin and CORS is not needed.
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
const menu = [
{ id: 1, name: "Cappuccino", price: 1600 },
{ id: 2, name: "Latte", price: 1800 },
{ id: 3, name: "Raf", price: 2200 },
{ id: 4, name: "Cheesecake", price: 2400 },
];
const stock = { beans: 12, milk: 30 };
let flakyCalls = 0;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function sendJson(res, status, data) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
const delay = Number(url.searchParams.get("delay") ?? 0);
if (delay) await wait(delay); // simulate a slow network
switch (url.pathname) {
case "/":
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
return res.end(await readFile(new URL("index.html", import.meta.url)));
case "/list.js":
res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
return res.end(await readFile(new URL("list.js", import.meta.url)));
case "/api/menu":
return sendJson(res, 200, menu);
case "/api/stock":
return sendJson(res, 200, stock);
case "/api/promo":
return sendJson(res, 503, { error: "Promo service is down" });
case "/api/slow":
await wait(5000);
return sendJson(res, 200, { ok: true });
case "/api/flaky": // the first two requests get 503, then 200
flakyCalls++;
return flakyCalls <= 2
? sendJson(res, 503, { error: "Try again later" })
: sendJson(res, 200, menu);
case "/api/search": {
const q = (url.searchParams.get("q") ?? "").toLowerCase();
return sendJson(res, 200, menu.filter((item) => item.name.toLowerCase().includes(q)));
}
default:
return sendJson(res, 404, { error: "Not found" });
}
});
server.listen(8090, "127.0.0.1", () => console.log("API: http://127.0.0.1:8090"));<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Coffee shop menu</title>
<script src="list.js" defer></script>
</head>
<body data-state="idle">
<main>
<h1>Menu</h1>
<button type="button" id="load">Load menu</button>
<button type="button" id="cancel" hidden>Cancel</button>
<p id="status" role="status"></p>
<ul id="menu"></ul>
</main>
</body>
</html>const loadButton = document.querySelector("#load");
const cancelButton = document.querySelector("#cancel");
const statusText = document.querySelector("#status");
const list = document.querySelector("#menu");
let controller = null;
async function getJson(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
function setState(state, message) {
document.body.dataset.state = state; // idle | loading | done | error
statusText.textContent = message;
loadButton.disabled = state === "loading";
loadButton.textContent = state === "error" ? "Retry" : "Load menu";
cancelButton.hidden = state !== "loading";
}
function renderMenu(items) {
list.replaceChildren(...items.map((item) => {
const li = document.createElement("li");
li.textContent = `${item.name} — ${item.price} ₸`;
return li;
}));
}
async function loadMenu() {
controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]);
setState("loading", "Loading the menu…");
list.replaceChildren();
try {
const items = await getJson("/api/menu", signal);
renderMenu(items);
setState("done", `Items on the menu: ${items.length}`);
} catch (error) {
if (error.name === "AbortError") {
setState("idle", "Loading cancelled");
} else if (error.name === "TimeoutError") {
setState("error", "The server did not respond within 5 seconds");
} else {
setState("error", "Could not load the menu. Check your connection");
}
} finally {
controller = null;
}
}
loadButton.addEventListener("click", loadMenu);
cancelButton.addEventListener("click", () => controller?.abort());Start the server
Run
node server.mjsin the folder with the three files and openhttp://127.0.0.1:8090in a browser.Load the menu
Press "Load menu": four items appear along with "Items on the menu: 4".
Turn on a slow network
In DevTools on the Network tab pick a slow network profile and press the button again; make sure to press "Cancel" in time.
Break the server
Stop
server.mjsand press the button: an error and a "Retry" button appear. Start the server again and the retry loads the menu.
Success
- How we simulated it
- The real server, response delayed by 500 ms
- Result
- While loading the button is disabled and "Cancel" is visible; then 4 items
Cancel
- How we simulated it
- No response; "Cancel" clicked after 200 ms
- Result
- "Loading cancelled", empty list, the button is "Load menu" again
Timeout
- How we simulated it
- No response
- Result
- After ~5 s "The server did not respond within 5 seconds" and "Retry"
Server error
- How we simulated it
- A 500 response
- Result
- An error message and "Retry"; retrying with a working server loaded the menu
No network
- How we simulated it
- Connection dropped
- Result
- An error message, the list is cleared
Exercises and next steps
Next, these techniques show up in React: loading data in useEffect and aborting when the component unmounts. And a fetch wrapper with types, retries and a single error format is built in the TypeScript API client recipe.
How this was verified3 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.