Fetching from a stub API in a Vite React app HTTP errors, AbortController cancellation, and timeouts
Build a search box against a stub API you can make slow or broken on demand: check res.ok, validate unknown JSON, cancel with AbortController, combine a timeout with AbortSignal.any, and never let a stale answer win.
Versions: Vite 8.3.5, @vitejs/plugin-react 6.1.2, React 19.3.1, TypeScript 7.0.2, Bun 1.4.4 (macOS arm64) and 1.4.2 (Linux x64) for the stub server, tests and the browser check; Bun.WebView is marked experimental in Bun's docs.
The problem
"Cityfind" is a search-as-you-type box. The user types letters into a text field, and after each keystroke the page asks a server for the cities whose names start with those letters. It lives in a Vite + React app. Vite is a development server and build tool for front-end projects, and React is the library that draws the page.
The real backend API is not ready yet. It may be slow, or another team may own it. What is agreed are the paths (for example /api/cities?q=sa) and the shape of the JSON that comes back. The front end must be built now, and it has to behave on a slow network. Three behaviors are required:
- A 500 response ("the server broke") must show a message instead of crashing the page.
- A request that hangs must give up after a while instead of spinning forever.
- When the user types "s" and then "sa", the screen must never end on the results for "s" just because that answer arrived last.
None of this is easy to test against a real server, because a real server is usually fast and healthy exactly when you want it to fail. You need a server that is slow and broken on demand.
The idea in plain words
Build the front end against a stub: a tiny server that has the same paths and JSON shapes as the real API, returns fake data, and has latency you choose. Route the browser to it through Vite's dev proxy, so the page uses the same relative URLs (such as /api/cities) that it will use in production. Then treat every request as something you can stop. Give each request its own AbortController, abort it when the user moves on, combine it with a timeout, and never show an answer from a request you already gave up on.
Concept 1: a stub API behind Vite's dev proxy
A stub API is a small program that answers the same URLs with the same JSON shapes as the real API, but with fake data. You build against one for three reasons. The contract (paths and shapes) is fixed, so the front end code does not change when the real API arrives. You control latency and failures, so a slow or broken server is one URL away. And tests become deterministic, because the answers never depend on someone else's database.
The stub's data is a short list of cities. Eight is enough to make prefix searches interesting.
// stub/cities.ts
export const CITIES = [
{ id: 1, name: "Salvador", country: "Brazil" },
{ id: 2, name: "Santiago", country: "Chile" },
{ id: 3, name: "Sapporo", country: "Japan" },
{ id: 4, name: "Seoul", country: "South Korea" },
{ id: 5, name: "Stockholm", country: "Sweden" },
{ id: 6, name: "Sydney", country: "Australia" },
{ id: 7, name: "Lagos", country: "Nigeria" },
{ id: 8, name: "Lima", country: "Peru" },
];
Three cities start with "sa" and six start with "s". That difference matters later, because the "s" and "sa" searches return different lists, so you can see which one is on screen. Each city has a numeric id, a name and a country, which is exactly the shape the front end will validate in the next concept.
Now the server itself.
// stub/app.ts
// A stub of the real API: same paths and JSON shapes, fake data, and
// latency you control, so the front end can be built and tested first.
import { CITIES } from "./cities";
export type StubEvent = { q: string; outcome: "answered" | "client went away" };
// One-letter prefixes match many rows, so the stub makes them slow.
export const delayFor = (q: string) => (q.length <= 1 ? 800 : 100);
// Resolve true after ms, or false as soon as the client disconnects.
function waitOrAbort(ms: number, signal: AbortSignal): Promise<boolean> {
return new Promise((resolve) => {
if (signal.aborted) return resolve(false);
const onAbort = () => {
clearTimeout(timer);
resolve(false);
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve(true);
}, ms);
signal.addEventListener("abort", onAbort, { once: true });
});
}
export function startStub(port = 8787, quiet = false) {
const events: StubEvent[] = [];
const note = (e: StubEvent) => {
events.push(e);
if (!quiet) console.log(`stub: q=${JSON.stringify(e.q)} ${e.outcome}`);
};
const server = Bun.serve({
port,
hostname: "127.0.0.1",
routes: {
"/api/cities": async (req) => {
const url = new URL(req.url);
const q = (url.searchParams.get("q") ?? "").toLowerCase();
const delay = Number(url.searchParams.get("delay") ?? delayFor(q));
if (!(await waitOrAbort(delay, req.signal))) {
note({ q, outcome: "client went away" });
return new Response(null, { status: 499 });
}
note({ q, outcome: "answered" });
return Response.json(CITIES.filter((c) => c.name.toLowerCase().startsWith(q)));
},
"/api/cities/:id": (req) => {
const city = CITIES.find((c) => c.id === Number(req.params.id));
return city
? Response.json(city)
: Response.json({ error: "no such city" }, { status: 404 });
},
// Endpoints that misbehave on purpose.
"/api/boom": () => new Response("database is down", { status: 500 }),
"/api/wrong-shape": () => Response.json({ cities: "not an array" }),
},
fetch: () => Response.json({ error: "no such endpoint" }, { status: 404 }),
});
return { server, events };
}
This file does the work of the stub, so it is worth reading slowly. Bun.serve starts an HTTP server, and the routes object maps URL paths to handler functions. /api/cities filters the list by prefix. The delayFor rule makes one-letter queries wait 800 ms and longer queries wait 100 ms, which reproduces the real-world pattern that broad searches are slower. A delay query parameter overrides the rule, so you can make any request as slow as you like. /api/cities/:id returns one city or a JSON 404. /api/boom always answers 500 with a plain-text body, and /api/wrong-shape answers 200 with JSON that is the wrong shape. Those two exist so that the error paths can be tested. The final fetch handler is the fallback for any other path, and it answers with a JSON 404 so a typo in a URL produces a readable error instead of an empty page.
The most important detail is waitOrAbort. Each request carries req.signal, an AbortSignal that the lab observed firing when the client disconnects (MDN describes Request.signal as the AbortSignal associated with the request). The stub waits out its delay, but if the signal fires first, it stops waiting and records "client went away". Without that, the stub could never tell you whether the browser really cancelled a request, so you could not prove cancellation works. The events array and the stub: console lines make that visible.
The entry point is short:
// stub/main.ts
import { startStub } from "./app";
const { server } = startStub(Number(process.env.STUB_PORT ?? 8787));
console.log(`stub API on ${server.url}`);
It starts the stub on port 8787 unless STUB_PORT says otherwise. Tests call startStub(0, true) instead. Port 0 tells the operating system to pick any free port, as the Bun docs describe, and server.url reports which one was chosen, so test runs never collide with a stub you left running.
Next is the dev proxy. During development, Vite's server can forward some requests to another server. You configure that in vite.config.ts:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
// The browser only ever talks to Vite on :5173. Vite forwards every
// path that starts with /api to the stub API on :8787.
proxy: { "/api": "http://127.0.0.1:8787" },
},
});
The Vite docs say server.proxy takes { key: options } pairs, and any request whose path starts with the key is sent to the target. Here, http://127.0.0.1:5173/api/cities becomes http://127.0.0.1:8787/api/cities. Why bother? Because of the same-origin rule. An origin is protocol plus hostname plus port, and browsers generally refuse cross-origin network requests unless the server opts in through Cross-Origin Resource Sharing (CORS), as Flanagan explains. If the page on :5173 called the stub on :8787 directly, it would be cross-origin. Through the proxy the browser only ever talks to :5173, so the front end can use plain relative URLs like /api/cities and needs no CORS setup.
Here is the stub answering directly, as run on macOS. The run-lab.sh script (shown later) calls each path with curl and prints the status code and the first 70 characters of the body:
$ bun stub/main.ts &
/api/cities?q=sa 200 [{"id":1,"name":"Salvador","country":"Brazil"},{"id":2,"name":"Santiag
/api/cities/2 200 {"id":2,"name":"Santiago","country":"Chile"}
/api/cities/99 404 {"error":"no such city"}
/api/boom 500 database is down
/api/wrong-shape 200 {"cities":"not an array"}
/api/nope 404 {"error":"no such endpoint"}
Each line is a path, the HTTP status code, and the start of the body. Read the table against the routes. The prefix search returns 200 with a JSON array, the city lookup returns one object, and an unknown id gives the JSON 404 with no such city. The /api/boom body is plain text, not JSON, which will matter in the next concept. /api/wrong-shape is a successful 200 whose content is not an array of cities. The last line shows the fallback handler answering for a path that does not exist. The same stub, reached through Vite:
$ vite --host 127.0.0.1 --port 5173 --strictPort & # dev server, /api proxied
via proxy /api/cities/3 200 {"id":3,"name":"Sapporo","country":"Japan"}
The request went to port 5173, and the body that came back is the stub's city number 3. That is the proof that the proxy rule forwards /api paths.
Common mistake: the dev server and the client disagree about the address. In this lab's first Linux run, Vite was started without --host, so it printed http://localhost:5173/. The script's curl to 127.0.0.1:5173 never connected (the script printed not up and the status 000), and the browser check failed with net::ERR_CONNECTION_REFUSED. The Vite docs warn about this: with localhost, browsers may use a different resolved address than the one Vite listens on. The lab observed it on the Linux machine, and the Vite docs note is the only explanation this lesson relies on. You spot it when the banner says localhost but your tools use 127.0.0.1. The fix is to start Vite with --host 127.0.0.1 and use that same address everywhere.
Concept 2: fetch() resolves on 404 and 500, so check res.ok and parse unknown
fetch() is the browser's function for making HTTP requests, and it returns a Promise (an object that later delivers a result or an error). The surprise is when that Promise fails. MDN says a fetch() promise only rejects when the request fails, for example from a badly-formed URL or a network error. It does not reject when the server answers with an error status such as 404 or 504. Flanagan says the same: if a server responds at all, even with 404 Not Found or 500 Internal Server Error, the Promise is fulfilled with a Response object, and it rejects only if it cannot contact the server. The ok property of the Response is true for status 200 to 299 and false otherwise.
This matters because code written as "await fetch, then parse JSON" treats a broken server as a success. The error body then flows into your JSON parser, and the user sees a confusing parsing error instead of "the server is down". So you check res.ok yourself, and you turn failures into errors your code can name.
The second half of the problem is the data. Even a 200 response can contain JSON in the wrong shape, for example when the API changes. res.json() returns data whose type TypeScript cannot know. Cherny's advice in Programming TypeScript is to reach for unknown instead of any for values you cannot know ahead of time: it can hold any value, but TypeScript will not let you use it until you refine it by checking what it is. So the lab's client validates before trusting.
// src/api.ts
// A small typed JSON client on top of fetch().
export type City = { id: number; name: string; country: string };
// fetch() resolves for 404 and 500, so turn a non-2xx answer into an error.
export class HttpError extends Error {
constructor(
readonly status: number,
readonly body: string,
) {
super(`HTTP ${status}`);
this.name = "HttpError";
}
}
// res.json() returns data of unknown shape: check it before trusting it.
export function parseCities(data: unknown): City[] {
if (!Array.isArray(data)) throw new TypeError("expected an array of cities");
return data.map((item, i) => {
if (typeof item !== "object" || item === null) {
throw new TypeError(`city ${i} is not an object`);
}
const { id, name, country } = item as Record<string, unknown>;
if (typeof id !== "number" || typeof name !== "string" || typeof country !== "string") {
throw new TypeError(`city ${i} has the wrong shape`);
}
return { id, name, country };
});
}
export type GetOptions<T> = {
parse: (data: unknown) => T; // validates the decoded JSON
signal?: AbortSignal; // the caller's "stop" button
timeoutMs?: number; // give up after this many milliseconds
base?: string; // absolute origin when not running in a page
};
export async function getJson<T>(path: string, opts: GetOptions<T>): Promise<T> {
const signals: AbortSignal[] = [];
if (opts.signal) signals.push(opts.signal);
if (opts.timeoutMs !== undefined) signals.push(AbortSignal.timeout(opts.timeoutMs));
// One signal that aborts when the caller cancels OR the timer runs out.
const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined;
const url = opts.base ? new URL(path, opts.base) : path;
const res = await fetch(url, { signal, headers: { accept: "application/json" } });
if (!res.ok) throw new HttpError(res.status, await res.text());
const data: unknown = await res.json();
return opts.parse(data);
}
// Turn any rejection from getJson into one line a person can read.
export function describe(err: unknown): string {
if (err instanceof HttpError) return `the server answered ${err.status}`;
if (err instanceof DOMException && err.name === "TimeoutError") return "the server took too long";
if (err instanceof DOMException && err.name === "AbortError") return "cancelled";
if (err instanceof TypeError) return `bad response or network: ${err.message}`;
return String(err);
}
Walk through it from the bottom of getJson upward. After fetch resolves, if (!res.ok) throw new HttpError(...) is the line that fixes the problem of this section: it reads the body as text (the 500 body is not JSON, so res.text() is the safe call) and raises a typed error carrying the status. Only on success does the code call res.json(), assign the result to a variable of type unknown, and pass it to the caller's parse function. parseCities then checks that the value is an array and that each element is an object with a numeric id and string name and country. Any mismatch becomes a TypeError with a specific message. describe is the last step: it converts any failure into a single sentence that the page can show. The signal handling in the middle of getJson belongs to the next concept.
The tests below pin all of this down. The file is shown in full here; its first group is the subject of this concept, and the other two groups are explained in the next two.
// stub/api.test.ts
import { afterAll, describe as group, expect, test } from "bun:test";
import { getJson, HttpError, parseCities, type City } from "../src/api";
import { latestSearcher, naiveSearcher } from "../src/search";
import { startStub } from "./app";
// Port 0 asks the OS for any free port, so tests never collide.
const { server, events } = startStub(0, true);
const base = server.url.href;
afterAll(() => server.stop(true));
const cities = (q: string, opts: { signal?: AbortSignal; timeoutMs?: number } = {}) =>
getJson(`/api/cities?q=${q}`, { ...opts, base, parse: parseCities });
group("fetch and HTTP errors", () => {
test("plain fetch resolves on a 404; it does not reject", async () => {
const res = await fetch(new URL("/api/cities/99", base));
expect(res.ok).toBe(false);
expect(res.status).toBe(404);
});
test("getJson turns a 500 into an HttpError", async () => {
const err = await getJson("/api/boom", { base, parse: parseCities }).catch((e) => e);
expect(err).toBeInstanceOf(HttpError);
expect(err.status).toBe(500);
expect(err.body).toBe("database is down");
});
test("a 200 with the wrong JSON shape is rejected by the parser", async () => {
const err = await getJson("/api/wrong-shape", { base, parse: parseCities }).catch((e) => e);
expect(err).toBeInstanceOf(TypeError);
expect(err.message).toBe("expected an array of cities");
});
});
group("cancellation", () => {
test("abort() rejects the fetch with AbortError and the server sees it", async () => {
const controller = new AbortController();
const pending = cities("s", { signal: controller.signal }); // stub waits 800 ms
setTimeout(() => controller.abort(), 50);
const err = await pending.catch((e) => e);
expect(err.name).toBe("AbortError");
await Bun.sleep(50); // let the server notice the closed connection
expect(events.at(-1)).toEqual({ q: "s", outcome: "client went away" });
});
test("AbortSignal.timeout rejects with TimeoutError", async () => {
const err = await cities("s", { timeoutMs: 100 }).catch((e) => e);
expect(err.name).toBe("TimeoutError");
});
test("an already-aborted signal rejects before any request is sent", async () => {
const before = events.length;
const err = await cities("sa", { signal: AbortSignal.abort() }).catch((e) => e);
expect(err.name).toBe("AbortError");
await Bun.sleep(150);
expect(events.length).toBe(before);
});
test("with AbortSignal.any, whichever signal fires first sets the reason", async () => {
const controller = new AbortController();
setTimeout(() => controller.abort(), 20);
const err = await cities("s", { signal: controller.signal, timeoutMs: 5000 }).catch((e) => e);
expect(err.name).toBe("AbortError"); // the user won, not the 5 s timer
});
});
group("search as you type", () => {
// "s" takes 800 ms and "sa" takes 100 ms, so "sa" answers first.
const fetcher = (q: string, signal: AbortSignal) => cities(q, { signal });
test("naive: the slow, older answer lands last and wins", async () => {
const shown: string[] = [];
const search = naiveSearcher(fetcher, (q) => shown.push(q));
await Promise.all([search("s"), search("sa")]);
expect(shown).toEqual(["sa", "s"]); // screen ends on results for "s"
});
test("latest: the older request is aborted and never shown", async () => {
const shown: Array<[string, City[]]> = [];
const search = latestSearcher(fetcher, (q, c) => shown.push([q, c]));
const first = search("s");
await Bun.sleep(30); // the gap between two keystrokes
await Promise.all([first, search("sa")]);
expect(shown.map(([q]) => q)).toEqual(["sa"]);
expect(shown[0][1].map((c) => c.name)).toEqual(["Salvador", "Santiago", "Sapporo"]);
await Bun.sleep(50);
expect(events.slice(-2)).toEqual([
{ q: "s", outcome: "client went away" }, // at about 30 ms
{ q: "sa", outcome: "answered" }, // at about 130 ms
]);
});
});
The top of the file starts a stub on port 0 and builds a cities helper that calls getJson with an absolute base (outside a page there is no origin, so relative URLs would fail). afterAll stops the server with server.stop(true), which force-closes active connections. In the first group, test one calls plain fetch on a missing city and asserts res.ok is false and res.status is 404, which proves that the promise resolved instead of rejecting. Test two checks that getJson raises an HttpError whose status is 500 and whose body is the plain text database is down. Test three checks that a 200 with the wrong JSON is stopped by parseCities. The test output, from the macOS run:
stub/api.test.ts:
(pass) fetch and HTTP errors > plain fetch resolves on a 404; it does not reject [20.04ms]
(pass) fetch and HTTP errors > getJson turns a 500 into an HttpError [5.64ms]
(pass) fetch and HTTP errors > a 200 with the wrong JSON shape is rejected by the parser [1.01ms]
All three pass. To see what happens without the ok check, the lab has a small probe script, probes.ts, shown in full here because the next concept reuses it:
// probes.ts
import { startStub } from "./stub/app";
const { server, events } = startStub(0, true);
const u = (p: string) => new URL(p, server.url);
// 1. Skipping the res.ok check: parse an error page as JSON.
const res = await fetch(u("/api/boom"));
console.log("1. status", res.status, "ok", res.ok);
await res.json().catch((e: Error) => console.log(" res.json() ->", e.name + ":", e.message));
// 2. The three names a caller must tell apart.
const names = await Promise.all([
fetch(u("/api/cities?q=s"), { signal: AbortSignal.timeout(50) }).catch((e) => e.name),
fetch(u("/api/cities?q=s"), { signal: AbortSignal.abort() }).catch((e) => e.name),
fetch("http://127.0.0.1:1/").catch((e) => e.name),
]);
console.log("2. timeout / abort / refused ->", names.join(" / "));
const r = AbortSignal.timeout(10); await Bun.sleep(30);
console.log(" timeout signal reason:", (r.reason as DOMException).name, "|", (r.reason as DOMException).message);
server.stop(true);
Probe 1 fetches /api/boom, prints the status and ok, and then calls res.json() on the body without any check. Probe 2 provokes the three kinds of failure at once and prints their names, then inspects the reason of a signal whose timer has already fired. (events is destructured but never read; it does no harm.) The output for probe 1 on macOS:
1. status 500 ok false
res.json() -> SyntaxError: JSON Parse error: Unexpected identifier "database"
The server said 500, and ok was false, but because the code did not look, it handed the text database is down to a JSON parser. The parser complained about the word "database". A user or a log reader would think the JSON was malformed, when the real problem was that the server was broken.
Common mistake: skipping the ok check. You will see a SyntaxError about JSON on a page that works fine when the server is healthy. The way to spot it is the position of the error: it names the first word of an error message (database here) instead of a field in your data. Always branch on res.ok before res.json().
Concept 3: cancelling with AbortController, and timeouts with AbortSignal.timeout and AbortSignal.any
Sometimes you want to stop a request you have already sent: the user clicked Cancel, typed another letter, or the server is simply taking too long. Flanagan explains that the fetch API supports this through two classes. An AbortController is an object you create before the request. Its signal property is an AbortSignal, which you pass to fetch in the options object. Calling the controller's abort() method causes the promises related to the fetch to reject with an exception. The two are separate objects so that you can hand the signal to other code, which can then watch for an abort, without giving that code the power to cancel.
When you abort, the signal stores an abort reason. The DOM Standard says controller.abort(reason) stores reason on the signal, and if the reason is undefined, an "AbortError" DOMException is stored. A DOMException is the standard error type that browsers use for named failures, and you tell them apart by their name property. The fetch rejection carries that reason, which is why the test below checks err.name.
A timeout is another way for a request to end. AbortSignal.timeout(ms) returns a signal that aborts by itself after the given time, and it aborts with a TimeoutError DOMException, according to MDN. Two caveats from MDN apply. The timeout is based on active time, so it is effectively paused while a worker is suspended or while a document sits in the back-forward cache. And there is no way to cancel the timer: finishing early does not cancel it. If resources are constrained and you need to cancel timers definitively, MDN suggests an AbortController with setTimeout() and clearTimeout() instead. Flanagan's older fetchWithTimeout example does exactly that with setTimeout(() => controller.abort(), options.timeout), and notes that it never cancels the timer because calling abort() after the fetch is complete has no effect. The lab lets the timer run to the end, because a late abort on a finished request does nothing.
You need both the user's cancel and the timeout at once. AbortSignal.any(signals) takes several signals and returns one that aborts when any input aborts. MDN says its reason is that of the first signal to abort, and that if any input is already aborted, the result is too. That is exactly what getJson (shown in the last section) does: it collects the caller's signal and, if timeoutMs is set, a timeout signal; combines them; and passes the result to fetch. If neither is given, it passes undefined and the request is not cancellable. Passing only one of them would silently drop the other way out.
The cancellation group of the test file covers four cases, and the output on macOS is:
(pass) cancellation > abort() rejects the fetch with AbortError and the server sees it [108.31ms]
(pass) cancellation > AbortSignal.timeout rejects with TimeoutError [102.30ms]
(pass) cancellation > an already-aborted signal rejects before any request is sent [153.46ms]
(pass) cancellation > with AbortSignal.any, whichever signal fires first sets the reason [21.19ms]
Reading the test code for each: the first starts a request for "s" (which the stub delays by 800 ms), aborts it after 50 ms, and expects AbortError. Then it waits 50 ms more and checks that the stub's last event is { q: "s", outcome: "client went away" }. That second assertion proves the cancel reached the server as well as the client. The second test passes timeoutMs: 100 against the same slow request and expects TimeoutError, and it finishes in about 100 ms. The third passes AbortSignal.abort(), a signal that is already aborted, and expects AbortError. It then waits 150 ms and checks that the stub's event list has not grown, so the stub never saw a request. The fourth sets a 5000 ms timeout but aborts manually after 20 ms, and the name is AbortError, because the user's abort was first.
The probe output for this concept, from the macOS run:
2. timeout / abort / refused -> TimeoutError / AbortError / TypeError
timeout signal reason: TimeoutError | The operation timed out.
The three names are the three outcomes a caller must tell apart. The first fetch had a 50 ms timeout against a slow endpoint and rejected with TimeoutError. The second used an already-aborted signal and rejected with AbortError. The third fetched port 1, where nothing is listening, so the request could not reach a server and rejected with TypeError. The second line shows that the reason stored on a fired timeout signal is a TimeoutError with the message "The operation timed out." This follows MDN's own example, which branches on err.name for TimeoutError, AbortError and TypeError.
Common mistake: mixing up the timeout and the cancel. The DOM Standard's example code returns silently on AbortError, because aborting is something the caller chose and needs no error message. If you copy that pattern but write catch code that only knows about AbortError, a timeout falls into the generic branch and reports something like a network failure. The opposite error is checking the combined signal's aborted flag and printing "cancelled": that flag is also true when the timer fired. You can diagnose both by logging err.name. The three names above are different on purpose, so branch on them, as describe does.
One more trap sits in the same family: TypeError is shared. A refused connection raises it, and so does parseCities when the JSON has the wrong shape. That is why describe keeps the error's own message after bad response or network:, so that a log line still says which of the two happened.
Concept 4: search as you type without a race
A race condition is a bug where two asynchronous operations are "racing" with each other and they might arrive in an unexpected order, as the React docs put it. In Cityfind, the user types "s", which starts a slow request, then "sa", which starts a fast one. The fast "sa" answer arrives first and the slow "s" answer arrives last. If every answer is shown when it lands, the screen ends on the results for "s" even though the box says "sa". The server did nothing wrong, and no request failed, which is why this bug survives casual testing.
To test it without a browser, the lab pulls the logic out of React into src/search.ts:
// src/search.ts
// Two ways to run "search as you type", without React, so they can be tested.
import type { City } from "./api";
export type Fetcher = (q: string, signal: AbortSignal) => Promise<City[]>;
export type OnResult = (q: string, cities: City[]) => void;
// Every keystroke starts a request and every answer is shown when it lands.
export function naiveSearcher(fetcher: Fetcher, onResult: OnResult) {
return (q: string): Promise<void> =>
fetcher(q, new AbortController().signal).then((cities) => onResult(q, cities));
}
// A new keystroke aborts the request before it, and an aborted request
// never reaches onResult, even if its answer was already on the way.
export function latestSearcher(fetcher: Fetcher, onResult: OnResult) {
let current: AbortController | undefined;
return (q: string): Promise<void> => {
current?.abort();
const controller = new AbortController();
current = controller;
return fetcher(q, controller.signal).then(
(cities) => {
if (!controller.signal.aborted) onResult(q, cities);
},
(err: unknown) => {
if (!controller.signal.aborted) throw err;
},
);
};
}
naiveSearcher is the bug on purpose. It gives every call a fresh controller that nobody ever aborts and calls onResult for every answer. latestSearcher remembers the controller of the previous call in current and aborts it before starting the new request. When an answer arrives, it only calls onResult if its own controller was not aborted. The same check guards the error branch: an aborted request ends quietly, but a request that failed for another reason (a 500, or a timeout) still raises its error to the caller.
The two tests in the "search as you type" group, as they ran on macOS:
(pass) search as you type > naive: the slow, older answer lands last and wins [801.42ms]
(pass) search as you type > latest: the older request is aborted and never shown [186.46ms]
The naive test starts "s" and "sa" together and asserts that shown equals ["sa", "s"]: the fast answer lands first and the slow older one lands last, so the screen would end on "s". The test takes about 800 ms because it waits for the slow request. The latest test waits 30 ms between the two keystrokes, then asserts that only "sa" was shown, with Salvador, Santiago and Sapporo. It also checks the stub's events: "s" was recorded as "client went away" at about 30 ms, and "sa" as "answered" at about 130 ms. The whole test needs only about 186 ms, because the 800 ms request was cut short.
Now the React version. An Effect is React's tool for running code after rendering, to synchronize a component with something outside React, such as a server. A cleanup function is what an Effect returns to say how to stop or undo its work. The React docs say that "fetch" needs either "cancel" or "ignore", that React calls the cleanup before the Effect runs again and once more when the component unmounts (is removed), and that if the user ID changes from Alice to Bob, cleanup ensures the Alice response is ignored even if it arrives after Bob.
// src/useCitySearch.ts
import { useEffect, useState } from "react";
import { describe, getJson, parseCities, type City } from "./api";
export type SearchState =
| { status: "idle" }
| { status: "loading"; q: string }
| { status: "done"; q: string; cities: City[] }
| { status: "error"; q: string; message: string };
export function useCitySearch(q: string, timeoutMs = 2000): SearchState {
const [state, setState] = useState<SearchState>({ status: "idle" });
useEffect(() => {
if (q.trim() === "") {
setState({ status: "idle" });
return;
}
const controller = new AbortController();
setState({ status: "loading", q });
getJson(`/api/cities?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
timeoutMs,
parse: parseCities,
}).then(
(cities) => {
if (!controller.signal.aborted) setState({ status: "done", q, cities });
},
(err: unknown) => {
if (!controller.signal.aborted) setState({ status: "error", q, message: describe(err) });
},
);
// React runs this before the next effect for a new q, and on unmount.
return () => controller.abort();
}, [q, timeoutMs]);
return state;
}
The hook keeps a state value that is one of four shapes: idle, loading, done, or error. Each time q changes, React runs the cleanup of the previous Effect, which aborts the previous request, and then runs the Effect again, which creates a new controller and starts a new request. The URL uses encodeURIComponent so that characters like spaces or & cannot break the query string.
Why does the hook also check controller.signal.aborted before calling setState, when the cleanup already aborts? The React docs answer this: an AbortController by itself is not enough to protect against race conditions, because more asynchronous steps could be chained after the fetch. Here, getJson awaits res.json() and then runs the parser, and a response that is already mid-flight could finish those steps after the abort. The explicit check plays the role of the docs' ignore flag. It makes the rule simple: if my controller was aborted, I am out of date, so I do not touch the screen.
The timeout needs one more sentence. When the timer fires, only the combined signal inside getJson is aborted. Aborting the combined signal does not abort its inputs, and the timer is not one of the hook's own signals either, so controller.signal.aborted stays false. That is intended. The error branch runs, describe sees a TimeoutError, and the user reads "the server took too long".
React's StrictMode is a wrapper component that adds development-only checks. One of them remounts every component once right after the first mount, which runs setup, cleanup, setup one after another. The docs say this is done to expose Effects that are missing a cleanup; in production the extra cycle does not happen. A hook with a correct cleanup survives it without any special handling.
Common mistake: an Effect with no cleanup. The naive searcher above is that bug. You can spot it two ways. On screen, the results belong to an older query than the one in the text box. In the stub's log, every query is marked "answered" and none is marked "client went away", because nothing ever cancelled a request. A third option the React docs mention: if you use a framework, use its built-in data fetching, or consider a client-side cache such as TanStack Query or useSWR. This lesson builds the pieces by hand so you can see how they work.
Putting it together
The project files
The scaffolding is small. package.json pins exact versions, and bunfig.toml makes Bun install exact versions and look for tests next to the stub:
{
"name": "cityfind",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev:api": "bun stub/main.ts",
"build": "vite build",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.stub.json",
"test": "bun test"
},
"dependencies": {
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"typescript": "7.0.2",
"vite": "8.3.5"
}
}
The scripts give you the commands used in the rest of this lesson: dev starts Vite, dev:api starts the stub, test runs the tests, and typecheck runs the TypeScript compiler twice with two configurations (explained below). The dependencies are written without ^, so everyone installs the same versions. "type": "module" tells tools to treat the project's .js files as ES modules, which use import and export.
[install]
exact = true
[test]
# Test files live next to the stub server.
root = "./stub"
exact = true tells bun install to save exact versions instead of ranges. The [test] section points bun test at the stub folder, where api.test.ts lives.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src"]
}
This configuration is for code that runs in the browser. It includes only src, loads the DOM type definitions (so fetch, AbortSignal and DOMException have types), turns on React's JSX transform with "jsx": "react-jsx", and enables strict mode, which is what makes the unknown discipline in parseCities mean something. noEmit means TypeScript only checks the code; Vite does the building.
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"module": "Preserve",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["stub", "src/api.ts", "src/search.ts", "smoke.ts"]
}
The second configuration is for code that runs in Bun: the stub, the tests, and the browser check. It loads Bun's types instead of Vite's, and it also includes src/api.ts and src/search.ts, because the tests import them. Two configurations are needed because the two environments have different globals; one file cannot describe both honestly.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Cityfind</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Vite serves this page. It contains an empty div with the id root and a script tag that loads src/main.tsx; React draws the whole app inside that div.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
This is src/main.tsx. It finds the root element and renders App inside StrictMode, so the development checks from the last concept are on for everything the browser check below does.
The component
// src/App.tsx
import { useState } from "react";
import { useCitySearch } from "./useCitySearch";
export function App() {
const [q, setQ] = useState("");
const state = useCitySearch(q);
return (
<main>
<h1>Cityfind</h1>
<input
id="q"
value={q}
placeholder="Type a city"
onChange={(e) => setQ(e.target.value)}
/>
<p id="status">
{state.status === "idle" && "Type to search."}
{state.status === "loading" && `Searching for "${state.q}"…`}
{state.status === "done" && `${state.cities.length} result(s) for "${state.q}"`}
{state.status === "error" && `Search for "${state.q}" failed: ${state.message}`}
</p>
{state.status === "done" && (
<ul>
{state.cities.map((c) => (
<li key={c.id}>
{c.name}, {c.country}
</li>
))}
</ul>
)}
</main>
);
}
The component owns one piece of state, the text in the box, and passes it to useCitySearch. The <p id="status"> shows exactly one sentence for each of the four states. Because the status line includes the query it belongs to (for "sa"), a stale answer would be visible on screen as the wrong query name, which is also what the browser check reads. The id attributes q and status exist so that the browser check can find the elements.
The browser check
The unit tests prove the searcher logic. The browser check proves the real thing: React, StrictMode, the dev proxy and the stub together. It uses Bun.WebView, which the Bun docs mark as experimental (it uses WKWebView on macOS and Chrome on Linux).
// smoke.ts
// Browser check against the Vite dev server (StrictMode on, /api proxied).
// Usage: bun smoke.ts http://127.0.0.1:5173/
// Bun.WebView is experimental: WebKit on macOS, installed Chrome elsewhere.
export {}; // a module, so top-level await type-checks
const base = process.argv[2] ?? "http://127.0.0.1:5173/";
const status = () => view.evaluate(`document.querySelector("#status").textContent`);
const items = () =>
view.evaluate(`[...document.querySelectorAll("li")].map((li) => li.textContent).join(" | ")`);
// Type the way a person does: React listens for "input" events, so set the
// value through the native setter and dispatch one per keystroke.
const type = (text: string) =>
view.evaluate(`(() => {
const el = document.querySelector("#q");
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
set.call(el, ${JSON.stringify(text)});
el.dispatchEvent(new Event("input", { bubbles: true }));
})()`);
async function until(expr: string, ms = 5000) {
const end = Date.now() + ms;
while (!(await view.evaluate(`Boolean(${expr})`))) {
if (Date.now() > end) throw new Error(`timed out waiting for ${expr}`);
await Bun.sleep(25);
}
}
await using view = new Bun.WebView({ width: 800, height: 600 });
await view.navigate(base);
await until(`document.querySelector("#q")`);
await type("s"); // slow on the stub: 800 ms
await Bun.sleep(30);
await type("sa"); // fast on the stub: 100 ms
await until(`document.querySelector("#status").textContent.includes("result")`);
console.log("after typing s, sa:", await status(), "|", await items());
await Bun.sleep(1000); // long enough for a stale "s" answer to land, if one could
console.log("one second later: ", await status(), "|", await items());
Here is how the script works. new Bun.WebView opens a browser window, and await using closes it when the script ends. view.navigate loads the page and view.evaluate runs a string of JavaScript inside the page and returns the result. The type helper sets the input's value through the browser's native value setter and then dispatches an input event, because React listens for input events. until polls the page every 25 ms until an expression is true, and fails after 5 seconds instead of hanging forever. The test itself types "s", waits 30 ms (the gap between two keystrokes), types "sa", waits for a status that contains "result", and prints what is on the screen. Then it waits one more second, which is longer than the 800 ms "s" request would have needed, so a stale answer would have time to land, and prints the screen again. The output from the macOS run:
$ bun smoke.ts http://127.0.0.1:5173/
after typing s, sa: 3 result(s) for "sa" | Salvador, Brazil | Santiago, Chile | Sapporo, Japan
one second later: 3 result(s) for "sa" | Salvador, Brazil | Santiago, Chile | Sapporo, Japan
exit=0
Both lines are identical: the screen shows three results for "sa", and one second later it still does. The stub's own log from the same run explains why:
stub log during the browser check:
stub: q="s" client went away
stub: q="sa" answered
The stub never answered "s". Its request was cancelled when the second keystroke changed q and React ran the cleanup, and the stub saw the connection go away. Only "sa" was answered. So the page never had a stale answer to show.
The runner
#!/usr/bin/env bash
# Cityfind lab. Usage: bash run-lab.sh (needs bun and curl; the browser
# check needs Chrome on Linux, nothing extra on macOS)
set -u
cd "$(dirname "$0")"
step() { echo; echo "\$ $*"; "$@"; echo "exit=$?"; }
get() { rm -f /tmp/cityfind-body; curl -s -o /tmp/cityfind-body -w "%{http_code}" "$1"; echo " $(head -c 70 /tmp/cityfind-body)"; }
wait_up() { for _ in $(seq 100); do curl -s -o /dev/null "$1" && return 0; sleep 0.1; done; echo "not up: $1"; }
bun --version
step bun install --frozen-lockfile
step bun test
step bun run typecheck
step bun run build
: > /tmp/cityfind-stub.log; echo; echo "\$ bun stub/main.ts &"
bun stub/main.ts >> /tmp/cityfind-stub.log 2>&1 & STUB=$!
wait_up http://127.0.0.1:8787/api/cities/1
for p in "/api/cities?q=sa" /api/cities/2 /api/cities/99 /api/boom /api/wrong-shape /api/nope; do
printf '%-22s ' "$p"; get "http://127.0.0.1:8787$p"
done
echo; echo "\$ vite --host 127.0.0.1 --port 5173 --strictPort & # dev server, /api proxied"
./node_modules/.bin/vite --host 127.0.0.1 --port 5173 --strictPort > /tmp/cityfind-vite.log 2>&1 & VITE=$!
wait_up http://127.0.0.1:5173/
printf '%-22s ' "via proxy /api/cities/3"; get "http://127.0.0.1:5173/api/cities/3"
: > /tmp/cityfind-stub.log
step bun smoke.ts http://127.0.0.1:5173/
echo; echo "stub log during the browser check:"; cat /tmp/cityfind-stub.log
kill "$VITE" "$STUB"; wait "$VITE" "$STUB" 2>/dev/null
The script runs the whole lab in order. It installs with a frozen lock file, runs the tests, type-checks both configurations, and builds for production. Then it starts the stub in the background, waits until the stub answers, and calls the six paths with curl. Next it starts Vite with --host 127.0.0.1 --port 5173 --strictPort (the fix from Concept 1; --strictPort makes Vite exit if the port is taken instead of quietly moving to the next one), checks one path through the proxy, empties the stub log, runs the browser check, prints the stub log, and stops both servers. The wait_up helper polls for up to ten seconds so that nothing is tested before it is listening. Every step prints exit= so a failure is visible.
The build step is part of the check too. On macOS it printed this, which confirms that the production build works with the same code:
$ bun run build
$ vite build
vite v8.3.5 building client environment for production...
transforming...
✓ 17 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.24 kB │ gzip: 0.18 kB
dist/assets/index-CeY5sjAx.js 221.60 kB │ gzip: 69.47 kB
✓ built in 53ms
exit=0
Now tie it back to the problem. A 500 becomes an HttpError, describe turns it into "the server answered 500", and the page shows a sentence instead of crashing. A hanging request is cut off by AbortSignal.timeout after two seconds and shows "the server took too long". Typing "s" then "sa" never ends on the "s" results, because the cleanup function aborts the older request and the signal.aborted check stops any late answer from touching the screen. Both Linux and macOS runs produced the same test results and the same browser output.
Pitfalls
Vite dev server on localhost, script and browser on 127.0.0.1
Symptom:
$ bunx --bun vite --port 5173 --strictPort & # dev server, /api proxied
not up: http://127.0.0.1:5173/
error: net::ERR_CONNECTION_REFUSED
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
Cause: Vite listened on whatever localhost resolved to, but the script and the browser connected to 127.0.0.1. The Vite docs say that with localhost, browsers may use a different resolved address than the one Vite listens on. The lab saw this on the Linux machine, and this lesson claims nothing about why at the operating-system level beyond that note. The proxy rule was fine, but nothing could reach the dev server to use it.
Fix: Start Vite with --host 127.0.0.1 (as run-lab.sh does) and use 127.0.0.1 in every URL, script and browser.
SyntaxError from res.json() on a 500
Symptom:
1. status 500 ok false
res.json() -> SyntaxError: JSON Parse error: Unexpected identifier "database"
Cause: fetch() resolves for 404 and 500, so the code reached res.json() with a plain-text error body (database is down). The parser's complaint describes the body, not your data, so the real failure, a server error, is hidden behind a parsing error that points you the wrong way.
Fix: Check res.ok before parsing, and raise a typed error with the status, as getJson does with HttpError. Then show a message based on the error type, as describe does.
Timeout reported as a cancel
Symptom:
2. timeout / abort / refused -> TimeoutError / AbortError / TypeError
timeout signal reason: TimeoutError | The operation timed out.
Cause: A timeout and a user cancel are different failures with different name values: TimeoutError and AbortError. Code that only knows AbortError, or that reads the combined signal's aborted flag and calls it "cancelled", reports a timer firing as something the user did (or as a network error), so the user never learns that the server was slow.
Fix: Branch on err.name: TimeoutError, AbortError, and TypeError, in that order, as describe does. In the hook, check the hook's own controller.signal.aborted to know whether the user moved on, since that signal is not aborted by the timer.
Stale results after fast typing
Symptom: The naive test shows what the screen would do. Its name in the test output:
(pass) search as you type > naive: the slow, older answer lands last and wins [801.42ms]
and its assertion in the test file:
expect(shown).toEqual(["sa", "s"]); // screen ends on results for "s"
Cause: Nothing cancelled the "s" request and nothing ignored its late answer, so the slow older answer arrived after the newer one and overwrote it. In the stub's log this shows up as every query being "answered" and none being "client went away".
Fix: Return () => controller.abort() from the Effect, and also check controller.signal.aborted before calling setState, so that a response already in flight cannot update the screen after the abort.
Shipping the stub
Symptom: Search works in development, but after deployment every /api/... request fails, because nothing in the production files answers those paths.
Cause: The stub and the dev proxy are for development only. The Vite docs say that unless noted, the options in the server section are only applied to dev, and server.proxy is one of them. A production build is static files, and nothing forwards /api anywhere.
Fix: In production, serve the real API at the same /api paths, or put a server in front that forwards those paths to it. The front end code does not change, because it always used relative URLs.
Run it yourself
Create the files from this lesson (all paths are relative to the project folder), and run bun install once to create the lock file. Then run bash run-lab.sh. The order is: print the Bun version, frozen install, bun test, bun run typecheck, bun run build, start the stub, call the six stub paths with curl, start Vite on 127.0.0.1:5173, call one path through the proxy, run the browser check against Vite, print the stub log, and stop both servers. The script needs Bun and curl; the browser check needs Chrome on Linux and nothing extra on macOS.
To work on the app by hand, use two terminals. In the first, run bun run dev:api to start the stub on port 8787. In the second, start Vite the same way the script does so the address matches, by running ./node_modules/.bin/vite --host 127.0.0.1 --port 5173 --strictPort, and open http://127.0.0.1:5173/. (bun run dev runs plain vite, which uses localhost by default, so see the first pitfall if you use it.) Type "s" and then "sa" quickly and watch the stub terminal.
For an experiment, lower the default timeoutMs in src/useCitySearch.ts from 2000 to 500 and type a single letter. The stub holds one-letter queries for 800 ms, so the combined signal fires first. The second cancellation test already proves the getJson half of this (a 100 ms timeout against the same query rejects with TimeoutError), and describe maps that name to "the server took too long", so the status line should read Search for "s" failed: the server took too long.
Check yourself
**Why does await fetch("/api/boom") not raise an error when the server answers 500, and what should the code check?**
Because fetch() only rejects when it cannot get an answer at all, such as a network failure or a badly-formed URL. A response with status 404 or 500 is still a response, so the promise resolves. The code should check res.ok (true for 200 to 299) or res.status before reading the body.
**What names do a timeout, a call to controller.abort() with no reason, and an unreachable server produce, and why does it matter?**
A fired AbortSignal.timeout rejects with a TimeoutError, an abort() with no reason stores an AbortError, and an unreachable server gives a TypeError. They need different handling: a cancel needs no message, a timeout deserves "the server took too long", and a TypeError means a network or response problem. Logging err.name tells you which one you have.
**The hook already aborts in its cleanup function. Why does it also check controller.signal.aborted before setState?**
Aborting the controller does not guarantee that no more code runs after the fetch. The hook chains more asynchronous steps, res.json() and the parser, so a response already in progress could still finish and call setState. The check ignores any result whose controller was aborted, which is the role the React docs give to an ignore flag.
Why does the browser only talk to port 5173 in this lab, and what happens to that setup in production?
The page is served from :5173, and Vite's server.proxy forwards every /api path to the stub on :8787. Because the browser sees only one origin, no cross-origin permission (CORS) is needed and the code can use relative URLs. The proxy only applies to the dev server, so in production the real API must answer at the same paths, or a server must forward them.
**When the timeout fires inside getJson, is the hook's controller.signal aborted, and what does the user see?**
No. Only the combined signal inside getJson is aborted, and aborting it does not abort its inputs, so the hook's own signal stays unaborted. The error branch therefore runs, describe sees a TimeoutError, and the page shows that the search failed because the server took too long.
Sources
- MDN, "Window: fetch() method", https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch (rejection behavior,
Response.ok, exceptions). - David Flanagan, JavaScript: The Definitive Guide, 7th ed. (O'Reilly), section 15.11.1 "fetch()", PDF pp. 900-901 (status codes and rejection) and pp. 911-912 (same-origin, CORS, aborting a request,
fetchWithTimeout). - WHATWG DOM Standard, "Aborting ongoing activities", https://dom.spec.whatwg.org/#aborting-ongoing-activities (
abort(reason), defaultAbortError,AbortSignal.any,AbortSignal.timeout). - MDN, "AbortSignal: timeout() static method", https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static (
TimeoutError, active time, timer cannot be cancelled). - MDN, "AbortSignal: any() static method", https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static (combined signals, reason of the first to abort).
- MDN, "Request: signal property", https://developer.mozilla.org/en-US/docs/Web/API/Request/signal.
- Vite docs, "Server Options", https://vite.dev/config/server-options (
server.proxy,server.host,server.strictPort, dev-only options). - Boris Cherny, Programming TypeScript (O'Reilly, 2019), chapter 3 "All About Types", section "unknown", PDF p. 35.
- React docs, "Synchronizing with Effects", https://react.dev/learn/synchronizing-with-effects (cleanup, StrictMode remount, fetching data, race conditions, alternatives).
- Bun docs, "Server", https://bun.com/docs/runtime/http/server (port 0,
server.url,server.stop(true)). - Bun docs, "Writing tests", https://bun.com/docs/test/writing-tests, and Bun docs, "WebView", https://bun.com/docs/runtime/webview (experimental).