Skip to content
System nominalUTC --:--:--Articles 041
MODULE 04 Lesson 12 / 18 40 MIN

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.

Lab 40 minutes

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.