Asynchrony
Cancellation, Timeouts and fetch
fetch resolves on a 500, hangs forever without a signal, and cannot be retried safely unless the request is idempotent. Here is how to handle all three.
Fetch is the smallest-looking API in this course and the one with the most assumptions
built in. It does not reject when a request fails at the HTTP level, so a
try/catch around it will not see your 500. It has no timeout of its
own, so a server that accepts the connection and never answers hangs the request until the
process ends. And it is the API people wrap in a retry loop first, usually for a
POST, which is where a retry turns one charge into three.
None of that is hidden behaviour, and all of it is documented. The reason it costs people time is that a working request looks like a correct one. Everything below runs against a local Node server, so you can watch the server side of each decision and see what the client cancelled and what it did not.
Start a local server you control
Third-party APIs cannot be made to fail on demand, rate-limit you at the right moment, or stall for exactly long enough to hit your deadline. This server can, and it prints every request it receives, which is how the examples below show what happened on the wire.
Show server.mjs
// server.mjs — run with: node server.mjs
import { createServer } from "node:http";
const PORT = 8787;
const attempts = new Map();
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
console.log(req.method, url.pathname + url.search);
if (url.pathname === "/ok") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ id: 7, name: "order 7" }));
return;
}
if (url.pathname === "/missing") {
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
return;
}
if (url.pathname === "/boom") {
res.writeHead(500, { "content-type": "text/plain" });
res.end("internal error");
return;
}
// Fails the first failUntil - 1 requests for a given key, then succeeds.
if (url.pathname === "/flaky") {
const key = url.searchParams.get("key") ?? "default";
const failUntil = Number(url.searchParams.get("failUntil") ?? 3);
const seen = (attempts.get(key) ?? 0) + 1;
attempts.set(key, seen);
if (seen < failUntil) {
res.writeHead(503, { "content-type": "text/plain", "retry-after": "1" });
res.end(`attempt ${seen}: still warming up`);
return;
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ key, attempt: seen, status: "ready" }));
return;
}
const slow = url.pathname.match(/^\/slow\/(\d+)$/);
if (slow) {
const ms = Number(slow[1]);
await new Promise((r) => setTimeout(r, ms));
if (res.writableEnded || res.destroyed) return;
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ waited: ms }));
return;
}
res.writeHead(400, { "content-type": "text/plain" });
res.end("unknown route");
});
server.listen(PORT, () => console.log(`listening on http://localhost:${PORT}`));
/slow/:ms is the one to keep in mind. It delays the response and then checks
whether the client is still there, so a cancelled request leaves a line in the log saying the
server finished work nobody is waiting for.
fetch fulfils on a 500, and only rejects on a transport failure
The promise from fetch rejects when the request could not be completed at all:
DNS failed, the connection was refused, TLS failed, or the request was aborted. An HTTP response
is a completed request, whatever its status code, so the promise fulfils with a
Response whose ok is false.
const base = "http://localhost:8787";
for (const path of ["/ok", "/missing", "/boom", "/nope"]) {
const response = await fetch(base + path);
console.log(
path.padEnd(8),
"status:", response.status,
"| ok:", String(response.ok).padEnd(5),
"| body:", (await response.text()).slice(0, 40),
);
}
/ok status: 200 | ok: true | body: {"id":7,"name":"order 7"}
/missing status: 404 | ok: false | body: {"error":"not found"}
/boom status: 500 | ok: false | body: internal error
/nope status: 400 | ok: false | body: unknown route
response.ok is true exactly for statuses in the 200 to 299 range.
response.status is the number you log or branch on. Redirects are the case that
catches people who expect status to be 200: the default redirect mode
follows them, so a request that was sent a 301 can end up fulfilling with a
200 from somewhere else, and response.redirected is the flag that tells
you it happened.
To see what a rejection actually looks like, send a request to a port with nothing listening:
try {
await fetch("http://localhost:9999/nothing");
} catch (err) {
console.log("name:", err.name, "| message:", err.message);
console.log("cause code:", err.cause?.code);
console.log("is a TypeError:", err instanceof TypeError);
}
name: TypeError | message: fetch failed
cause code: ECONNREFUSED
is a TypeError: true
That is the whole rejection vocabulary: a TypeError with the real reason tucked
into cause. The generic message is why err.cause is worth logging
alongside err.message; on its own, "fetch failed" tells you nothing about
whether the network is down or your URL is wrong.
One helper turns bad statuses into exceptions
Checking response.ok at every call site does not survive a code review: the
second developer to add a call will read the body without checking the status. Put the check in
one function and make the failure an exception like any other.
async function requestJson(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
const body = await response.text();
throw new Error(`HTTP ${response.status} ${response.statusText} for ${url}: ${body.slice(0, 80)}`);
}
return response.json();
}
console.log("ok:", await requestJson(`${base}/ok`));
try {
await requestJson(`${base}/boom`);
} catch (err) {
console.log("threw:", err.message);
}
ok: { id: 7, name: 'order 7' }
threw: HTTP 500 Internal Server Error for http://localhost:8787/boom: internal error
Two decisions are worth defending. The error message keeps the status, the status text and a slice of the body, because an upstream error body is often the only description of what went wrong and truncating it prevents a log line from becoming a novel. And the helper reads the body as text rather than JSON, because by definition it is running on a response whose shape you cannot trust. Callers that need the parsed error payload for an API-specific code should catch the error and re-read, or the helper should attach the response; the version above keeps the common case short.
Resisting the instinct to return null on failure is the other half. A caller that
receives null for both "the server said no" and "no data exists" cannot tell them
apart, and the check that would have caught it is now missing in two places.
AbortController cancels the client, not the server
Every fetch accepts a signal option. An AbortController
owns one signal, and calling abort() makes the pending request reject with a
DOMException named AbortError.
const controller = new AbortController();
setTimeout(() => controller.abort(), 100);
try {
await fetch(`${base}/slow/2000`, { signal: controller.signal });
} catch (err) {
console.log("cancel ->", err.name, "|", err.message);
console.log("aborted:", controller.signal.aborted);
}
// the same signal, already aborted, fails immediately
try {
await fetch(`${base}/ok`, { signal: controller.signal });
} catch (err) {
console.log("reused ->", err.name);
}
cancel -> AbortError | This operation was aborted
aborted: true
reused -> AbortError
Aborting frees your side of the connection and stops you waiting. It does not undo work on the
server: a database write that was already committed stays committed, and a command that was
already sent takes effect. The server log for that first request shows it, because the
/slow route reports when it finishes work after the client has gone:
GET /slow/2000
/slow/2000 finished after the client left
That distinction is the entire reason idempotency matters later in this lesson. Cancellation introduces exactly the situation where you do not know whether the work happened: the request was sent, the answer never arrived, and the server may or may not have acted on it.
Calling abort(reason) with an argument replaces the default
AbortError with your own value, which surfaces as the rejection itself rather than as
a wrapped exception. That is useful for distinguishing "the user navigated away" from "we ran out
of budget" in a log, and confusing in a first example, so the code here uses the plain form.
controller.signal.reason is where the value lives if you want to inspect it without
catching.
AbortSignal.timeout gives a deadline in one line
A timer plus a controller is the portable way to bound a request, and it leaks the timer if
you forget to clear it. AbortSignal.timeout(ms) is the built-in form:
const okRes = await fetch(`${base}/ok`, { signal: AbortSignal.timeout(1000) });
console.log("in time ->", okRes.status, await okRes.json());
try {
await fetch(`${base}/slow/2000`, { signal: AbortSignal.timeout(150) });
} catch (err) {
console.log("timeout ->", err.name, "|", err.message);
}
const bodyRes = await fetch(`${base}/slow/300`, { signal: AbortSignal.timeout(1000) });
console.log("body ->", await bodyRes.json());
in time -> 200 { id: 7, name: 'order 7' }
timeout -> TimeoutError | The operation was aborted due to timeout
body -> { waited: 300 }
The failure is a TimeoutError, not an AbortError, and the
difference is worth branching on: a timeout is usually retryable, and a user cancellation is
not. The signal starts counting when you create it, not when the fetch begins, so create it per
attempt rather than reusing one across a retry loop. A signal created before three retries of
500 milliseconds has already expired by the third.
The deadline covers the whole exchange, including reading the body. Give the same request a
150-millisecond budget while the server takes 300 milliseconds and the call itself rejects, so a
large download needs a budget sized for the download and not only for the headers. When you need
both a caller-driven cancellation and a deadline, combine them with
AbortSignal.any([signalA, signalB]), which aborts with whichever reason fires
first.
Retry with backoff and jitter, and only what is idempotent
A retry is a loop around a request, and every part of the loop is a decision. The one that matters most is whether the request is safe to send twice.
A GET, HEAD, PUT or DELETE is defined as
idempotent: sending it twice has the same effect on the server as sending it once. A
POST is not, unless the server provides something like an idempotency key. So a
retry loop belongs around the first group by default, and around the second only with a key the
server agrees to deduplicate on.
const base = "http://localhost:8787";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function backoffDelay(attempt, { baseMs = 100, capMs = 2000 } = {}) {
const ceiling = Math.min(capMs, baseMs * 2 ** (attempt - 1));
return Math.round(Math.random() * ceiling);
}
async function getJson(path, { attempts = 5, timeoutMs = 1000 } = {}) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(base + path, { signal: AbortSignal.timeout(timeoutMs) });
if (response.ok) return await response.json();
const description = `${response.status} ${response.statusText}`;
if (response.status >= 400 && response.status < 500) {
throw Object.assign(new Error(description), { fatal: true });
}
lastError = new Error(description);
} catch (err) {
if (err.fatal) throw err;
lastError = err;
}
if (attempt === attempts) break;
const wait = backoffDelay(attempt);
console.log(` attempt ${attempt} failed (${lastError.message}), waiting ${wait}ms`);
await sleep(wait);
}
throw lastError;
}
const key = `run-${Date.now()}`;
console.log("flaky endpoint, fails twice then succeeds:");
console.log(" got:", await getJson(`/flaky?key=${key}&failUntil=3`));
console.log("a 404 is not retried:");
try {
await getJson("/missing");
} catch (err) {
console.log(" threw immediately:", err.message);
}
console.log("delays from the backoff function, attempts 1 to 8:");
console.log(" ", Array.from({ length: 8 }, (_, i) => backoffDelay(i + 1)).join(" "));
flaky endpoint, fails twice then succeeds:
attempt 1 failed (503 Service Unavailable), waiting 51ms
attempt 2 failed (503 Service Unavailable), waiting 143ms
got: { key: 'run-1792174613445', attempt: 3, status: 'ready' }
a 404 is not retried:
threw immediately: 404 Not Found
delays from the backoff function, attempts 1 to 8:
6 149 95 34 383 635 1462 1596
Read the three decisions in that function. The 4xx branch is marked fatal and rethrown immediately, because a client error will not fix itself and retrying it turns one bad request into five. A network failure, a timeout or a 5xx stays retryable, because those are the states a service recovers from on its own. And the wait is exponential with full jitter: the ceiling doubles each attempt, capped at two seconds, and the actual delay is a random value below the ceiling rather than the ceiling itself.
Jitter earns its place when many clients retry at once. Without it, every client that failed at the same moment waits the same amount and retries in the same instant, so the recovering service is hit by a synchronised wave. With full jitter the retries spread across the whole window, which is why the sample delays look random instead of doubling. Your first attempt waits somewhere under 100 milliseconds, the second under 200, and so on.
There is also a header you should read rather than guess at:
retry-after. The /flaky route sends retry-after: 1 on
every 503, and the loop above ignores it. When the header is present it is the server telling
you when it expects to be ready, and using it, taking the larger of it and your own backoff,
beats guessing. Respecting it also stops you from hammering a service that has told you to
wait.
An idempotency key is what makes a POST retryable
Payment and order APIs solve this by letting the client supply a key for the operation. The server records the key with the result and returns the original result on a repeat, so a retry cannot create a second charge. The demonstration needs a server that does the work and then fails to answer, which is the situation a client cannot distinguish from "nothing happened":
Show the charging server
// idem-server.mjs — run with: node idem-server.mjs
import { createServer } from "node:http";
const PORT = 8788;
const charges = []; // the ledger that must not end up with duplicates
const stalls = new Map(); // how many times each request shape has been seen
createServer((req, res) => {
if (req.method === "GET" && req.url === "/ledger") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(charges, null, 2));
return;
}
if (req.method !== "POST" || req.url !== "/charge") {
res.writeHead(404).end("nope");
return;
}
let body = "";
req.on("data", (c) => (body += c));
req.on("end", async () => {
const payload = JSON.parse(body);
const key = payload.idempotencyKey ?? null;
// The ledger write happens once per idempotency key, once per request without one.
let charge = key ? charges.find((c) => c.key === key) : undefined;
if (!charge) {
charge = { key, amount: payload.amount, id: charges.length + 1 };
charges.push(charge);
}
// The first request of each shape stalls for 400ms; the client gave up at 150ms.
const signature = `${payload.amount}:${key ?? "none"}`;
const seen = stalls.get(signature) ?? 0;
stalls.set(signature, seen + 1);
if (seen === 0) {
await new Promise((r) => setTimeout(r, 400));
return; // the charge stands even though nobody heard the answer
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(charge));
});
}).listen(PORT, () => console.log(`charging server on ${PORT}`));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function attempt(payload) {
try {
const res = await fetch("http://localhost:8788/charge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(150),
});
return await res.json();
} catch (err) {
return { timedOut: err.name };
}
}
async function chargeWithRetry(payload, attempts = 2) {
for (let i = 1; i <= attempts; i += 1) {
const result = await attempt(payload);
console.log(` attempt ${i}:`, result);
if (!result.timedOut) return result;
await sleep(50);
}
throw new Error("gave up after retrying");
}
console.log("retrying a POST with no idempotency key:");
await chargeWithRetry({ amount: 4200 }).catch((e) => console.log(" ", e.message));
console.log("retrying the same POST with an idempotency key:");
await chargeWithRetry({ amount: 4200, idempotencyKey: "order-9911" })
.catch((e) => console.log(" ", e.message));
retrying a POST with no idempotency key:
attempt 1: { timedOut: 'TimeoutError' }
attempt 2: { key: null, amount: 4200, id: 2 }
retrying the same POST with an idempotency key:
attempt 1: { timedOut: 'TimeoutError' }
attempt 2: { key: 'order-9911', amount: 4200, id: 3 }
Both runs time out on the first attempt, and both succeed on the second. The ledger is where
the difference shows, and GET /ledger prints it:
[
{ "key": null, "amount": 4200, "id": 1 },
{ "key": null, "amount": 4200, "id": 2 },
{ "key": "order-9911", "amount": 4200, "id": 3 }
]
Two entries for the request without a key: the first attempt charged and lost the answer, the second attempt charged again, and the customer paid twice. One entry for the keyed request, because the second attempt found the key and returned the charge it had already recorded. The client code is identical in both runs. The only difference is whether the server can recognise a repeat, and that recognition is what makes the retry safe.
Note that the key is generated by the client and must be stable across attempts: a fresh UUID per attempt is the same as no key at all. Generate it once, before the loop, and keep it with the operation for as long as the operation might be retried.
Write a function fetchWithBudget(url, { timeoutMs, attempts }) that returns
the parsed JSON body and throws when the budget is spent. It must apply a per-attempt
timeout rather than one shared deadline, retry only failures that could succeed later, stop
immediately on a 4xx, and wait with exponential backoff and jitter between attempts.
Run it against /flaky?key=<unique>&failUntil=4 and confirm from the
server log that it made exactly four requests. Then run it against /missing and
confirm it made exactly one.
Finally, answer: if the request is a POST to an endpoint that creates a
record, and the second attempt times out, what will your ledger contain, and what change to
the server would prevent it?
Show the solution and the reasoning
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
class FatalHttpError extends Error {
constructor(status, statusText) {
super(`${status} ${statusText}`);
this.status = status;
}
}
function backoffDelay(attempt, { baseMs = 100, capMs = 2000 } = {}) {
const ceiling = Math.min(capMs, baseMs * 2 ** (attempt - 1));
return Math.round(Math.random() * ceiling);
}
async function fetchWithBudget(url, { timeoutMs = 1000, attempts = 5 } = {}) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (response.ok) return await response.json();
if (response.status >= 400 && response.status < 500) {
throw new FatalHttpError(response.status, response.statusText);
}
lastError = new Error(`HTTP ${response.status}`);
} catch (err) {
if (err instanceof FatalHttpError) throw err;
lastError = err;
}
if (attempt < attempts) await sleep(backoffDelay(attempt));
}
throw lastError;
}
The class is the part worth copying. In the previous version I attached a
fatal property with Object.assign, which works and is quick to write,
but it is invisible to anyone reading the file and it cannot be caught specifically by type.
A small error class gives the branch a name and lets a caller write
if (err instanceof FatalHttpError) instead of checking a property that might not
exist on some other error that reached the same catch.
The per-attempt signal is not a detail. AbortSignal.timeout starts counting
when it is constructed, so the call is inside the loop and a fresh signal is created for each
attempt. Hoist it above the loop and the fourth attempt in a five-attempt run may find the
deadline already passed, which fails instantly and looks like the server refusing to
answer.
Against /flaky?failUntil=4, the endpoint returns 503 for the first three
attempts, the loop retries each one, and the fourth request succeeds: four lines in the server
log for that key. Against /missing, the 404 is fatal, so exactly one request is
made and the error carries status 404 for the caller to branch on. A 404 is a fact about the
URL, and no amount of waiting changes it.
For the POST question: the first attempt's record is created and the response
is lost, the retry creates a second record, and the ledger ends up with two rows for one
intended operation. The change that prevents it belongs on the server, not in the client: it
must accept an idempotency key with the request, store it alongside the record it created, and
return that same record instead of creating another when the key appears again. A client-side
fix, such as retrying only on connection errors or never retrying a POST, reduces the window
without closing it, because a timeout leaves you unable to tell whether the request arrived.
The client cannot solve this alone, which is why the contract has to be part of the API.
Progress
Mark this lesson done and it stays marked in this browser.