Skip to content

Kata: cancellable fetch with AbortController

A timeout is a timer you own: start it before the request, keep it over the body, and clear it on every way out.

Predict first

Before you read: predict

ExamHall's exam-catalog search uses a candidate's fetchJson. Three searches run through it, each with timeoutMs: 3000 and its own caller signal:

  • econ gets a 200 reply: headers at 120 ms, body at 150 ms;
  • econ-2 gets a 404 reply at 80 ms;
  • econ-20 is aborted by its caller at 50 ms, before any reply.

When all three calls have settled, how many of their three timers are still waiting to fire?

Code for this question
async function fetchJson(url, { signal, timeoutMs = 5000 } = {}) {  const budget = new AbortController();  const timer = setTimeout(() => budget.abort(new DOMException('no reply', 'TimeoutError')), timeoutMs);  const res = await fetch(url, { signal: AbortSignal.any([signal, budget.signal]) });  if (!res.ok) throw new HttpError(res.status, url);  const data = await res.json();  clearTimeout(timer);  return data;}
Pick one

The 30-second version

fetchJson owns a budget: a timer that aborts its own controller with a TimeoutError. It combines that signal with the caller's, awaits the headers and the body inside try, throws on a non-2xx status and clears the timer in finally, on every exit. latest() aborts the previous call before starting the next, so only the newest can resolve.

A timeout is a timer you own: start it before the request, keep it over the body, and clear it on every way out.

How it works

Step 1 of 11

Own the budget. A timer aborts the call's own controller with a TimeoutError reason, so a caller can tell a timeout from its own cancel.
In the kitchen
  • the attendant's own timer, started as the guest goes in → the budget timer, set before fetch (step 1)

Analogy: A spa's steam room

A guest books a 15-minute session. The attendant writes the guest on the board and starts a timer; if it rings, she knocks and calls time.

At the steam room In the code
the attendant's own timer, started as the guest goes in the budget timer, set before fetch (step 1)
a friend fetching the guest early the caller's own signal (step 2)
the cool-down shower, still part of the session the body, read after the headers (step 3)
stopping the timer and wiping the board, whichever way the guest leaves clearTimeout in finally (step 7)
a forgotten timer that later rings at an empty room a leaked timer aborting a finished call (step 8)
a guest who rebooks first has the old booking wiped latest() (step 9)

Breaks down when: a guest who hears the knock may stay a minute longer; an aborted fetch has no say, it rejects at once with the reason. Wrong inference: "a little late is fine".

Contrasting case: AbortSignal.timeout(ms) is a timer the platform owns. Nobody can clear it, and finishing early does not cancel it; MDN suggests setTimeout with clearTimeout when you need that.

In real terms: fetchJson is one request with a deadline it starts, shares with the caller and always clears; latest() holds the controller of the newest call only.

Examples, run for real

4.1 The reference answer (reference.mjs)

export class HttpError extends Error {
  constructor(status, url) {
    super(`HTTP ${status} for ${url}`);
    this.name = 'HttpError';
    this.status = status;
  }
}

export async function fetchJson(url, { signal, timeoutMs = 5000 } = {}) {
  const budget = new AbortController();
  const id = setTimeout(() => budget.abort(new DOMException(`no reply in ${timeoutMs} ms`, 'TimeoutError')), timeoutMs);
  const both = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;
  try {
    const res = await fetch(url, { signal: both });
    if (!res.ok) throw new HttpError(res.status, url);
    return await res.json();
  } finally {
    clearTimeout(id);
  }
}

export function latest(fn) {
  let current = null;
  return (...args) => {
    current?.abort();
    current = new AbortController();
    return fn(...args, { signal: current.signal });
  };
}
4.1 the pack's reference block equals reference.mjs: true (29 lines)

It is written in the order of the steps: the error class, the budget, the combined signal, both awaits inside try, finally, then latest().

4.2 The test list (kata.test.mjs)

Test What it checks
T1 a 200 reply resolves with the parsed body
T2 a 503 reply rejects with an HttpError whose status is 503
T3 with a caller signal, no reply rejects with a TimeoutError at the budget and closes the request
T4 the caller's abort rejects with the caller's own reason and closes the request
T5 after a 200, no timer is left waiting
T6 after a 503, and after a caller's abort, no timer is left waiting
T7 headers in time, then a body that stalls past the budget: rejects with a TimeoutError
T8 through latest(), three quick calls: only the newest resolves; the two older calls reject with an AbortError, and their requests close

Run node --test kata.test.mjs in the folder; KATA=./my-answer.mjs node --test kata.test.mjs runs the same list against your own file. Each test makes its own fake server and virtual clock, so no test waits:

4.2 node --test kata.test.mjs (reference.mjs): T1 pass · T2 pass · T3 pass · T4 pass · T5 pass · T6 pass · T7 pass · T8 pass · 8 of 8 pass

4.3 ExamHall: EXH-2810 and MR !3110

The exam-catalog search box debounced typing by 300 ms (kata-debounce) and called fetchJson for each query. "econ" matches 41 exams, so its reply is slow. MR !3110 wrapped the loader in latest():

const load = (q, opts) => fetchJson(`/exams?q=${q}`, { ...opts, timeoutMs: 3000 });
const search = latest(load);                       // EXH-2810 called load(q) directly
const onQuery = (q) => search(q).then(render, (err) => { if (err.name !== 'AbortError') throw err; });
const onInput = debounce(onQuery, 300);
4.3 the debounced box sends econ at 540, then econ-201 at 1240; econ's reply would start at 2040, econ-201's at 1390
4.3 EXH-2810 fetchJson per query: rendered econ-201 at 1410, then econ at 2070 · list shows econ · requests closed 0 · timers waiting 0
4.3 MR !3110 latest(fetchJson): econ closed at 1240 (its call rejects with an AbortError, which the handler skips) · rendered econ-201 at 1410 · list shows econ-201 · timers waiting 0

Without latest(), the slow "econ" reply lands last and the list shows it under the query "econ-201". With it, the second query aborts the first: its request closes at once, its call rejects with an AbortError that the handler skips (step 10), and no timer is left.

4.4 Payments ledger: one statement loader, four ways out

A ledger page loads an account's entries through the reference, with the page's own signal:

4.4 GET /ledger/acct-17/entries answers 200 · resolved with 3 entries at 120 · timers waiting 0
4.4 GET /ledger/acct-99/entries answers 404 · rejected HttpError 404 at 60 · timers waiting 0
4.4 GET /ledger/acct-17/export never answers (timeoutMs 2000) · rejected TimeoutError at 2000 · request closed · timers waiting 0
4.4 GET /ledger/acct-17/entries, the clerk leaves the page at 50 ms · rejected with "page closed" at 50 · request closed · timers waiting 0

Every way out leaves no timer waiting (step 7). The timeout and the caller's abort each reject with their own reason, so the page can say "slow network" for one and nothing for the other (steps 1–2).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 8

A payments ledger's statement page loads entries with fetchJson. Four engineers wrote one version each, and three of the four versions have one bug each.

Which version passes the whole test list?

Code for this question
// Version Afunction fetchJson(url, { signal, timeoutMs = 5000 } = {}) {  const budget = new AbortController();  const id = setTimeout(() => budget.abort(new DOMException('ledger timeout', 'TimeoutError')), timeoutMs);  const both = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;  return fetch(url, { signal: both })    .then((res) => {      if (!res.ok) throw new HttpError(res.status, url);      return res.json();    })    .finally(() => clearTimeout(id));} // Version Basync function fetchJson(url, { signal, timeoutMs = 5000 } = {}) {  const budget = new AbortController();  const id = setTimeout(() => budget.abort(new DOMException('ledger timeout', 'TimeoutError')), timeoutMs);  const both = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;  try {    const res = await fetch(url, { signal: both });    return await res.json();  } finally {    clearTimeout(id);  }} // Version Casync function fetchJson(url, { signal, timeoutMs = 5000 } = {}) {  const budget = new AbortController();  const id = setTimeout(() => budget.abort(new DOMException('ledger timeout', 'TimeoutError')), timeoutMs);  const both = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;  try {    const res = await fetch(url, { signal: both });    if (!res.ok) throw new HttpError(res.status, url);    return await res.json();  } catch (err) {    clearTimeout(id);    throw err;  }} // Version Dasync function fetchJson(url, { signal, timeoutMs = 5000 } = {}) {  const budget = new AbortController();  const id = setTimeout(() => budget.abort(), timeoutMs);  const both = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;  try {    const res = await fetch(url, { signal: both });    if (!res.ok) throw new HttpError(res.status, url);    return await res.json();  } finally {    clearTimeout(id);  }}
Pick one

Question 2 of 8

A chat app's fetchJson takes a list of caller signals, so that leaving a room and logging out can each cancel a load. It loads a room's history with fetchJson(url, { signals: [room.signal, session.signal], timeoutMs: 8000 }). Leaving a room does cancel the load. When the history server stalls, though, the load never settles.

Which one-line change makes fetchJson pass the whole list?

Code for this question
async function fetchJson(url, { signals = [], timeoutMs = 5000 } = {}) {  const budget = new AbortController();  const id = setTimeout(() => budget.abort(new DOMException('no reply', 'TimeoutError')), timeoutMs);  const both = signals.length ? AbortSignal.any(signals) : budget.signal;  try {    const res = await fetch(url, { signal: both });    if (!res.ok) throw new HttpError(res.status, url);    return await res.json();  } finally {    clearTimeout(id);  }}
Pick one

Question 3 of 8

A video player loads a film's manifest with fetchJson('/films/f7/manifest', { timeoutMs: 3000 }). The manifest server is slow to start: it sends the headers at 4000 ms, then the body slowly; its last byte arrives at 9000 ms.

What does the call do?

Code for this question
async function fetchJson(url, { signal, timeoutMs = 5000 } = {}) {  const budget = new AbortController();  const both = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;  const res = await fetch(url, { signal: both });  const id = setTimeout(() => budget.abort(new DOMException('no reply', 'TimeoutError')), timeoutMs);  try {    if (!res.ok) throw new HttpError(res.status, url);    return await res.json();  } finally {    clearTimeout(id);  }}
Pick one

Question 4 of 8

A ticket site's seat map loads with fetchJson('/events/ev-9/seats', { signal: view.signal, timeoutMs: 5000 }). The server sends the headers at 100 ms and the body's last byte at 900 ms. At 400 ms the fan closes the seat map, which calls view.abort('seat map closed').

What does the call do?

Code for this question
async function fetchJson(url, { signal, timeoutMs = 5000 } = {}) {  const budget = new AbortController();  const id = setTimeout(() => budget.abort(new DOMException('no reply', 'TimeoutError')), timeoutMs);  const both = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;  try {    const res = await fetch(url, { signal: both });    if (!res.ok) throw new HttpError(res.status, url);    return await res.json();  } finally {    clearTimeout(id);  }}
Pick one

Question 5 of 8

Time box: 20 minutes, a blank file, no lookups. ExamHall's exam-catalog search box needs both functions of this kata.

Say your test list aloud first, then write fetchJson(url, { signal, timeoutMs }) and latest(fn).

Question 6 of 8

A chat client loads each room's history through this latest(). When a user clicks three rooms quickly, the oldest room's history sometimes appears last.

Move one line so that T8 passes.

Code for this question
function latest(fn) {  return (...args) => {    let current = null;    current?.abort();    current = new AbortController();    return fn(...args, { signal: current.signal });  };}

Question 7 of 8

A shop imports its supplier's price list every night with fetchJson('/feeds/prices', { timeoutMs: 4000 }), with no caller signal. One night the supplier's server sends the headers at once and then stops sending. The next morning the import is still waiting.

Which is the bug?

Code for this question
async function fetchJson(url, { signal, timeoutMs = 5000 } = {}) {  const budget = new AbortController();  const id = setTimeout(() => budget.abort(new DOMException('no reply', 'TimeoutError')), timeoutMs);  const both = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;  let res;  try {    res = await fetch(url, { signal: both });  } finally {    clearTimeout(id);  }  if (!res.ok) throw new HttpError(res.status, url);  return await res.json();}
Pick one

Question 8 of 8

A ledger script exports a month's statement twice, as CSV and then as PDF. For each file it fetches the statement's 4 pages one after another with this fetchJson, and the PDF pass takes every page from the cache. Both files are written at once, but the Node process stays up for another 30 seconds before it exits.

Which one change makes the process exit as soon as both files are written, and keeps fetchJson passing the whole test list?

Code for this question
const cache = new Map(); async function fetchJson(url, { signal, timeoutMs = 30000 } = {}) {  const budget = new AbortController();  const id = setTimeout(() => budget.abort(new DOMException('no reply', 'TimeoutError')), timeoutMs);  if (cache.has(url)) return cache.get(url);  const both = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;  try {    const res = await fetch(url, { signal: both });    if (!res.ok) throw new HttpError(res.status, url);    const page = await res.json();    cache.set(url, page);    return page;  } finally {    clearTimeout(id);  }}
Pick one

Go deeper

Pro tip
  1. Count timers, not milliseconds. Give your fake clock a pending() count and assert it is 0 at the end of every test; that one line catches a leaked timer whichever exit leaked it. When: writing the kata's tests (step 11). Verify: node --test kata.test.mjs passes T5 and T6, which end on timers waiting 0. Status: RAN (Node 24.19.0).
Source

No item is keyed on a source; every key is executed. Valid as of 2026-10-04.

  • MDN, AbortController: https://developer.mozilla.org/en-US/docs/Web/API/AbortController (mdn-abortcontroller.txt). Quoted: "Aborts an asynchronous operation before it has completed. This is able to abort fetch requests, consumption of any response bodies, and streams." Supports step 3.
  • MDN, AbortSignal: any() static method: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static (mdn-abortsignal-any.txt). Quoted: "The returned abort signal is aborted when any of the input iterable abort signals are aborted. The abort reason will be set to the reason of the first signal that is aborted." Supports step 2; its example tells AbortError from TimeoutError in the catch, as step 10 does.
  • MDN, AbortSignal: timeout() static method: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static (mdn-abortsignal-timeout.txt). Quoted: "If resources are constrained and you want to definitively cancel timeouts early, use an AbortController with setTimeout() instead, and call clearTimeout() when the operation finishes." Supports steps 1 and 7 and the contrasting case.

Checked against the tools and docs as of 2026-10-04.