A typed API client is a thin wrapper over fetch that every request in the project goes through: it builds the URL from the base address, sets shared headers, limits the waiting time, checks the response with a Zod schema and turns any failure into a single ApiError class with parsed Problem Details. This recipe gives you a ready-made api-client.ts file of about 180 lines, an example API module and tests against a local HTTP server.
After this you will be able to
- plug in a single client on top of
fetchwith a base URL, shared headers and a per-attempt timeout - type responses with Zod schemas so that the type and the runtime check never diverge
- parse
application/problem+jsonerrors (RFC 9457) into a singleApiErrorclass and tell them apart from a cancelled request - retry on failure only idempotent requests and a
POSTwith an idempotency key
Know beforehand
Verified with
TypeScript 7.0.2 (strict)Zod 4.6Fetch API, AbortSignal.timeout / AbortSignal.anyNode.js 24 LTS (24.18)Vitest 5.0.3
Checked against docs: October 12, 2026
Contents
The task: fetch calls scattered across the project
In a growing project fetch is called from dozens of places, and each has its own bugs. MDN points out the main one: fetch() rejects only on network failures, while a 404 or 500 arrives as a normal Response — forget to check response.ok, and a server error reaches the UI as data. fetch has no timeout option of its own, and (await res.json()) as User is a promise to the compiler that nobody checks: if the backend renames a field, the code breaks far away from the request.
The base address is copied around the project
- Solution in the client
baseUrlis set once, and the path is joined to it without losing/v1
The token and shared headers in every call
- Solution in the client
headersis an object or a function called before every attempt
A forgotten response.ok
- Solution in the client
- Any non-
2xxresponse becomes anApiErrorwith the status and Problem Details
A response type taken on trust
- Solution in the client
- The response is checked with a Zod schema, and the type is inferred from the same schema
A request hangs forever
- Solution in the client
AbortSignal.timeout()per attempt andAbortSignal.any()for cancellation by the caller
A retry creates duplicates
- Solution in the client
- Only idempotent methods and
POST/PATCHwith anIdempotency-Keyare retried
The client is deliberately thin: no cache, no state and no dependencies other than Zod. Caching and loading state belong to the UI data layer; where that layer lives is covered in the frontend architecture article.
The code: api-client.ts
npm init -y
npm pkg set type=module
npm install zod@4
npm install -D typescript @types/node@24 vitest{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test"]
}strict: trueimport { z } from "zod";
// RFC 9457: a member of the wrong type is ignored, unknown members are extensions
const ProblemDetailsSchema = z.looseObject({
type: z.string().catch("about:blank").default("about:blank"),
title: z.string().optional().catch(undefined),
status: z.number().int().optional().catch(undefined),
detail: z.string().optional().catch(undefined),
instance: z.string().optional().catch(undefined),
});
export type ProblemDetails = z.output<typeof ProblemDetailsSchema>;
export type ApiErrorKind = "http" | "timeout" | "network" | "invalid-response";
// One error class for everything that can go wrong with a call.
// Cancellation by the caller is not an ApiError: the AbortError is rethrown as is.
export class ApiError extends Error {
readonly kind: ApiErrorKind;
readonly method: string;
readonly url: string;
readonly status: number | undefined;
readonly problem: ProblemDetails | undefined;
constructor(
message: string,
details: { kind: ApiErrorKind; method: string; url: string; status?: number; problem?: ProblemDetails; cause?: unknown },
) {
super(message, { cause: details.cause });
this.name = "ApiError";
this.kind = details.kind;
this.method = details.method;
this.url = details.url;
this.status = details.status;
this.problem = details.problem;
}
}
type HeadersProvider = HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
type Query = Record<string, string | number | boolean | undefined>;
export type ApiClientOptions = {
baseUrl: string; // "https://api.example.com/v1"
headers?: HeadersProvider; // a function is called before every attempt: fresh token
timeoutMs?: number; // per attempt
retries?: number; // extra attempts, only for idempotent requests
retryDelayMs?: number;
fetch?: typeof fetch;
};
export type RequestOptions<S extends z.ZodType> = {
schema: S; // checks the response body and gives it a type
query?: Query;
body?: unknown; // sent as JSON
headers?: HeadersInit;
signal?: AbortSignal; // cancellation by the caller
timeoutMs?: number;
idempotencyKey?: string; // makes POST and PATCH retryable if the API supports it
};
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]);
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
function buildUrl(baseUrl: string, path: string, query: Query = {}): URL {
// new URL("users", "https://x/v1") drops "/v1": keep the base path with a trailing slash
const url = new URL(path.replace(/^\/+/, ""), baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) url.searchParams.set(key, String(value));
}
return url;
}
async function readProblem(res: Response): Promise<ProblemDetails | undefined> {
if (!res.headers.get("content-type")?.includes("application/problem+json")) {
await res.body?.cancel();
return undefined;
}
const parsed = ProblemDetailsSchema.safeParse(await res.json().catch(() => null));
return parsed.success ? parsed.data : undefined;
}
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const timer = setTimeout(done, ms);
function done() {
signal?.removeEventListener("abort", onAbort);
resolve();
}
function onAbort() {
clearTimeout(timer);
reject(signal?.reason);
}
signal?.addEventListener("abort", onAbort, { once: true });
});
}
export function createApiClient(options: ApiClientOptions) {
const { baseUrl, timeoutMs = 10_000, retries = 2, retryDelayMs = 200, fetch: doFetch = fetch } = options;
async function attempt<S extends z.ZodType>(method: string, url: URL, opts: RequestOptions<S>): Promise<z.output<S>> {
const shared = typeof options.headers === "function" ? await options.headers() : options.headers;
const headers = new Headers(shared);
new Headers(opts.headers).forEach((value, key) => headers.set(key, value));
headers.set("Accept", "application/json, application/problem+json");
if (opts.idempotencyKey) headers.set("Idempotency-Key", opts.idempotencyKey);
if (opts.body !== undefined) headers.set("Content-Type", "application/json");
const timeout = AbortSignal.timeout(opts.timeoutMs ?? timeoutMs);
const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
const where = { method, url: url.toString() };
try {
const res = await doFetch(url, {
method,
headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
signal,
});
if (!res.ok) {
const problem = await readProblem(res);
const reason = problem?.title ?? res.statusText;
throw new ApiError(`${method} ${url.pathname} answered ${res.status}${reason ? `: ${reason}` : ""}`, {
kind: "http", status: res.status, problem, ...where,
});
}
const data: unknown = res.status === 204 ? undefined : await res.json();
const parsed = opts.schema.safeParse(data);
if (!parsed.success) {
throw new ApiError(`${method} ${url.pathname}: unexpected response\n${z.prettifyError(parsed.error)}`, {
kind: "invalid-response", status: res.status, cause: parsed.error, ...where,
});
}
return parsed.data;
} catch (error) {
if (error instanceof ApiError) throw error;
if (opts.signal?.aborted) throw opts.signal.reason; // the caller cancelled: not our failure
if (timeout.aborted) {
throw new ApiError(`${method} ${url.pathname}: no response in ${opts.timeoutMs ?? timeoutMs} ms`, {
kind: "timeout", cause: error, ...where,
});
}
if (error instanceof SyntaxError) {
throw new ApiError(`${method} ${url.pathname}: response is not JSON`, { kind: "invalid-response", cause: error, ...where });
}
throw new ApiError(`${method} ${url.pathname}: network error`, { kind: "network", cause: error, ...where });
}
}
async function request<S extends z.ZodType>(method: string, path: string, opts: RequestOptions<S>): Promise<z.output<S>> {
const url = buildUrl(baseUrl, path, opts.query);
// RFC 9110: repeat automatically only idempotent requests (or ones with an idempotency key)
const maxAttempts = IDEMPOTENT_METHODS.has(method) || opts.idempotencyKey ? retries + 1 : 1;
for (let n = 1; ; n++) {
try {
return await attempt(method, url, opts);
} catch (error) {
const transient =
error instanceof ApiError &&
(error.kind === "network" || error.kind === "timeout" || RETRYABLE_STATUS.has(error.status ?? 0));
if (!transient || n >= maxAttempts) throw error;
// Simplified pause; Retry-After, jitter and an overall deadline are in the external API lesson
await sleep(retryDelayMs * 2 ** (n - 1), opts.signal);
}
}
}
return {
get: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("GET", path, opts),
post: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("POST", path, opts),
put: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("PUT", path, opts),
patch: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("PATCH", path, opts),
delete: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("DELETE", path, opts),
};
}
export type ApiClient = ReturnType<typeof createApiClient>;buildUrl:new URL("users", "https://api.example.com/v1")giveshttps://api.example.com/users— a relative path replaces the last segment of the base. So the base gets a trailing/and the path loses its leading one. Parameters set toundefineddo not reach the query string.Accept: application/json, application/problem+jsonis the same header as in the RFC 9457 example. The client parses an error only when the server answers withapplication/problem+json; the body of other errors is not read.ProblemDetailsSchemafollows RFC 9457: a member of the wrong type is ignored (.catch(undefined)), a missingtypeis treated asabout:blank, and unknown members — extensions such asbalanceorerrors— are kept thanks toz.looseObject.- The timeout is
AbortSignal.timeout(), which aborts the request with aTimeoutErrorwhen it expires;AbortSignal.any()combines it with the caller's signal. The signal also covers reading the body: a stalled JSON transfer is interrupted too. - Cancellation by the caller is not an API error: the original
AbortErroris rethrown, without retries. The UI can tell «the user left the page» from «the server did not answer». - The response is checked with
schema.safeParse; on a mismatch, anApiErrorof kindinvalid-responsecarries the readablez.prettifyErrortext — handy for logs. - Retries are simplified: an exponential pause without jitter. RFC 9110 allows automatic retries of idempotent methods, and of others only when the retry is known to be safe — which is what an idempotency key provides.
Retry-After, jitter, an overall deadline and a circuit breaker are covered in «External APIs: timeouts, retries, rate limits and graceful degradation» — do not duplicate them here; if you need them, pass that wrapper in thefetchoption.
How to plug it in: API modules and error messages
Schemas next to the calls
One module per resource with Zod schemas and functions:
users.get,users.list. The type is inferred from the schema withz.output; no separateinterfaceis needed.One client per API
The composition point creates the client with the base URL from configuration and a token getter, and exposes typed modules.
Components call modules
The UI calls
api.users.get(id, signal)and gets aUserwhosecreatedAtis aDate— the schema did the conversion.Errors become UI texts
catchchecksinstanceof ApiErrorand picks a message by error kind, status andtype. AnAbortErroris not shown to the user.
import { z } from "zod";
import type { ApiClient } from "./api-client.ts";
// The schema is both the runtime check and the TypeScript type
export const User = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
createdAt: z.iso.datetime().transform((value) => new Date(value)),
});
export type User = z.output<typeof User>;
const UserPage = z.object({
items: z.array(User),
nextCursor: z.string().nullable(),
});
export function makeUsersApi(api: ApiClient) {
return {
get: (id: string, signal?: AbortSignal) =>
api.get(`users/${encodeURIComponent(id)}`, { schema: User, signal }),
list: (cursor?: string) =>
api.get("users", { schema: UserPage, query: { cursor, limit: 20 } }),
// One key per operation: a retry of the same POST does not create a second user
create: (input: { name: string; email: string }) =>
api.post("users", { schema: User, body: input, idempotencyKey: crypto.randomUUID() }),
remove: (id: string) =>
api.delete(`users/${encodeURIComponent(id)}`, { schema: z.undefined() }), // 204 No Content
};
}POST gets an idempotency keyimport { createApiClient } from "./api-client.ts";
import { makeUsersApi } from "./users.ts";
// Composition point: one client per API, typed modules on top of it.
// Where the token comes from (session, refresh flow) is the app's decision.
export function createApi(baseUrl: string, getToken: () => Promise<string | null>) {
const client = createApiClient({
baseUrl,
timeoutMs: 8_000,
headers: async (): Promise<HeadersInit> => {
const token = await getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
},
});
return { users: makeUsersApi(client) };
}import type { ApiError } from "./api-client.ts";
// User texts come from your own translations, keyed by kind, status and the problem "type" URI.
// "detail" is for logs and debugging: do not parse it and do not show it as is.
export function errorMessage(error: ApiError): string {
if (error.kind === "timeout" || error.kind === "network") {
return "The server is not responding. Check the connection and try again.";
}
if (error.kind === "invalid-response" || (error.status ?? 500) >= 500) {
return "Something went wrong on our side. We are already looking into it.";
}
if (error.status === 401) return "Your session has expired. Please sign in again.";
if (error.problem?.type === "https://api.example.com/problems/email-taken") {
return "This email is already registered.";
}
return "The request could not be completed.";
}The client and the modules use only the standard fetch, URL, Headers and AbortSignal, so they work both in the browser and on the server. While checking the recipe, the src folder also passed tsc with the dom libraries and without Node.js types. In the browser CORS applies on top: requests to another domain have to be allowed by the server.
How to check it
The tests run against a real node:http server on a random port: the client goes through the whole network path, and each test sets its own server scenario. The server records incoming requests, which is how URLs, headers, bodies and the number of attempts are checked.
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
export type Handler = (req: IncomingMessage, res: ServerResponse, n: number) => void;
export type Recorded = { method: string; url: string; headers: IncomingHttpHeaders; body: string };
// A real HTTP server on a random local port: the client goes through the network stack
export async function startServer() {
let handler: Handler = (_req, res) => void res.writeHead(404).end();
const requests: Recorded[] = [];
const server = createServer(async (req, res) => {
let body = "";
for await (const chunk of req) body += chunk;
requests.push({ method: req.method ?? "", url: req.url ?? "", headers: req.headers, body });
handler(req, res, requests.length);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return {
baseUrl: `http://127.0.0.1:${port}/v1`,
requests,
handle(next: Handler) {
handler = next;
requests.length = 0;
},
close: () =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
}),
};
}
export function send(res: ServerResponse, status: number, data: unknown, type = "application/json") {
if (res.destroyed) return; // the client has already given up
res.writeHead(status, { "Content-Type": type }).end(typeof data === "string" ? data : JSON.stringify(data));
}import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { z } from "zod";
import { ApiError, createApiClient } from "../src/api-client.ts";
import { createApi } from "../src/api.ts";
import { errorMessage } from "../src/error-message.ts";
import { send, startServer } from "./server.ts";
let server: Awaited<ReturnType<typeof startServer>>;
beforeAll(async () => {
server = await startServer();
});
afterAll(() => server.close());
const anna = { id: "u1", name: "Anna", email: "anna@example.com", createdAt: "2026-10-12T09:00:00Z" };
const api = () => createApi(server.baseUrl, async () => "test-token");
const fail = (promise: Promise<unknown>) => promise.then(() => expect.fail("should reject"), (error: unknown) => error);
describe("typed API client", () => {
it("joins the base URL, sends headers and returns typed data", async () => {
server.handle((_req, res) => send(res, 200, anna));
const user = await api().users.get("u1");
expect(user.createdAt).toBeInstanceOf(Date);
expect(user.name).toBe("Anna");
expect(server.requests[0]).toMatchObject({ method: "GET", url: "/v1/users/u1" });
expect(server.requests[0]!.headers.authorization).toBe("Bearer test-token");
expect(server.requests[0]!.headers.accept).toBe("application/json, application/problem+json");
});
it("puts query parameters into the URL and skips undefined ones", async () => {
server.handle((_req, res) => send(res, 200, { items: [anna], nextCursor: null }));
const page = await api().users.list();
expect(page.items[0]!.email).toBe("anna@example.com");
expect(server.requests[0]!.url).toBe("/v1/users?limit=20");
});
it("rejects a body that does not match the schema and does not retry it", async () => {
server.handle((_req, res) => send(res, 200, { ...anna, email: "not-an-email" }));
const error = await fail(api().users.get("u1"));
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({ kind: "invalid-response", status: 200 });
expect(server.requests).toHaveLength(1);
});
it("turns application/problem+json into ApiError and keeps extensions", async () => {
const problem = {
type: "https://example.com/probs/out-of-credit",
title: "You do not have enough credit.",
detail: "Your current balance is 30, but that costs 50.",
instance: "/account/12345/msgs/abc",
balance: 30,
accounts: ["/account/12345", "/account/67890"],
};
server.handle((_req, res) => send(res, 403, problem, "application/problem+json"));
const client = createApiClient({ baseUrl: server.baseUrl });
const error = await fail(client.post("purchase", { schema: z.object({}), body: { item: 123456, quantity: 2 } }));
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({ kind: "http", status: 403, problem });
expect((error as ApiError).message).toBe("POST /v1/purchase answered 403: You do not have enough credit.");
expect(server.requests[0]!.headers["content-type"]).toBe("application/json");
expect(JSON.parse(server.requests[0]!.body)).toEqual({ item: 123456, quantity: 2 });
});
it("ignores problem members of the wrong type (RFC 9457, section 3.1)", async () => {
server.handle((_req, res) => send(res, 404, { title: 42, status: "404", detail: "No user u9" }, "application/problem+json"));
const error = (await fail(api().users.get("u9"))) as ApiError;
expect(error.problem).toEqual({ type: "about:blank", detail: "No user u9" });
expect(error.message).toBe("GET /v1/users/u9 answered 404: Not Found");
});
it("retries an idempotent GET after 503", async () => {
server.handle((_req, res, n) => (n === 1 ? send(res, 503, "busy", "text/plain") : send(res, 200, anna)));
expect((await api().users.get("u1")).id).toBe("u1");
expect(server.requests).toHaveLength(2);
});
it("does not retry POST without an idempotency key", async () => {
server.handle((_req, res) => send(res, 503, "busy", "text/plain"));
const client = createApiClient({ baseUrl: server.baseUrl, retryDelayMs: 1 });
const error = await fail(client.post("orders", { schema: z.object({}), body: {} }));
expect(error).toMatchObject({ kind: "http", status: 503, problem: undefined });
expect(server.requests).toHaveLength(1);
});
it("retries POST with an idempotency key and sends the same key every time", async () => {
server.handle((_req, res, n) => (n === 1 ? send(res, 503, "busy", "text/plain") : send(res, 201, anna)));
const user = await api().users.create({ name: "Anna", email: "anna@example.com" });
expect(user.id).toBe("u1");
const keys = server.requests.map((r) => r.headers["idempotency-key"]);
expect(keys).toHaveLength(2);
expect(keys[0]).toBeTruthy();
expect(keys[1]).toBe(keys[0]);
});
it("returns undefined for 204 No Content", async () => {
server.handle((_req, res) => void res.writeHead(204).end());
await expect(api().users.remove("u1")).resolves.toBeUndefined();
});
it("gives up after timeoutMs with kind timeout", async () => {
server.handle((_req, res) => setTimeout(() => send(res, 200, anna), 300));
const client = createApiClient({ baseUrl: server.baseUrl, timeoutMs: 50, retries: 0 });
const error = await fail(client.get("users/u1", { schema: z.unknown() }));
expect(error).toMatchObject({ kind: "timeout" });
});
it("passes the caller's cancellation through as AbortError, without retries", async () => {
server.handle((_req, res) => setTimeout(() => send(res, 200, anna), 300));
for (const retries of [0, 2]) {
const client = createApiClient({ baseUrl: server.baseUrl, retries });
const controller = new AbortController();
setTimeout(() => controller.abort(), 30);
const error = await fail(client.get("users/u1", { schema: z.unknown(), signal: controller.signal }));
expect(error).not.toBeInstanceOf(ApiError);
expect((error as Error).name).toBe("AbortError");
}
expect(server.requests).toHaveLength(2); // one request per call
});
it("reports a non-JSON success body as invalid-response", async () => {
server.handle((_req, res) => send(res, 200, "<html>maintenance</html>", "text/html"));
const error = await fail(api().users.get("u1"));
expect(error).toMatchObject({ kind: "invalid-response" });
});
it("maps a problem type to a user message", async () => {
server.handle((_req, res) =>
send(res, 409, { type: "https://api.example.com/problems/email-taken", title: "Email taken" }, "application/problem+json"),
);
const error = (await fail(api().users.create({ name: "Anna", email: "anna@example.com" }))) as ApiError;
expect(errorMessage(error)).toBe("This email is already registered.");
});
it("reports a refused connection as a network error", async () => {
const probe = createServer();
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
const { port } = probe.address() as AddressInfo;
await new Promise<void>((resolve) => probe.close(() => resolve()));
const client = createApiClient({ baseUrl: `http://127.0.0.1:${port}`, retries: 1, retryDelayMs: 1 });
const error = await fail(client.get("health", { schema: z.unknown() }));
expect(error).toMatchObject({ kind: "network" });
});
});$ npx tsc -p .
$ npx vitest run --reporter=verbose
RUN v5.0.3 …
✓ test/api-client.test.ts > typed API client > joins the base URL, sends headers and returns typed data 18ms
✓ test/api-client.test.ts > typed API client > puts query parameters into the URL and skips undefined ones 3ms
✓ test/api-client.test.ts > typed API client > rejects a body that does not match the schema and does not retry it 3ms
✓ test/api-client.test.ts > typed API client > turns application/problem+json into ApiError and keeps extensions 5ms
✓ test/api-client.test.ts > typed API client > ignores problem members of the wrong type (RFC 9457, section 3.1) 17ms
✓ test/api-client.test.ts > typed API client > retries an idempotent GET after 503 231ms
✓ test/api-client.test.ts > typed API client > does not retry POST without an idempotency key 16ms
✓ test/api-client.test.ts > typed API client > retries POST with an idempotency key and sends the same key every time 246ms
✓ test/api-client.test.ts > typed API client > returns undefined for 204 No Content 2ms
✓ test/api-client.test.ts > typed API client > gives up after timeoutMs with kind timeout 62ms
✓ test/api-client.test.ts > typed API client > passes the caller's cancellation through as AbortError, without retries 76ms
✓ test/api-client.test.ts > typed API client > reports a non-JSON success body as invalid-response 2ms
✓ test/api-client.test.ts > typed API client > maps a problem type to a user message 2ms
✓ test/api-client.test.ts > typed API client > reports a refused connection as a network error 13ms
Test Files 1 passed (1)
Tests 14 passed (14)The tests were checked the other way round: one at a time we broke Problem Details parsing, limiting retries to idempotent methods, passing AbortError through, ignoring members of the wrong type, URL joining, the Idempotency-Key header and skipping undefined in the query. Each time at least one relevant test failed.
Limitations
- The timeout is per attempt. With
retries: 2andtimeoutMs: 8000, the worst case is three attempts of 8 seconds plus pauses. Add an overall deadline with anAbortSignal.timeout()in the call'ssignalor with the wrapper from the external API lesson - The request body is JSON only.
FormData, file uploads and streamed responses need a separate method: the browser or runtime sets theContent-Typefor them - Only a
204response is treated as empty. If the API returns200without a body, it ends up asinvalid-response— agree on this with the backend or extend the condition - Schema checks cost time on large lists. If a response with thousands of records arrives often, check it selectively or only in development — as a deliberate decision, not by default
- Zod adds code to the frontend bundle. For size-critical pages there is Zod Mini — the same functionality with a functional API that tree-shakes better
- If the API has an OpenAPI contract, it is more convenient to generate schemas and types from it and use this recipe as the transport layer
Related materials and sources
- «External APIs: timeouts, retries, rate limits and graceful degradation» — full retries: jitter,
Retry-After, an overall deadline, a circuit breaker - «API design: resources, versioning, errors, pagination» — how the server should return Problem Details and support
Idempotency-Key - «Frontend architecture: project structure, layers and boundaries» — where API code lives in a project
- «Asynchronous JavaScript: promises, async/await and fetch» — the basics of
fetch,AbortControllerandAbortSignal.timeout
Statements about fetch, AbortSignal, Zod and the RFCs were checked against the official documentation on the date in the header; the links are below.
How this was verified2 · passedchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.