API contracts, versioning, and errors
The written agreement between the frontend and the API: a typed schema both sides are checked against, rules for changing it without breaking old clients, and one error shape the UI can always understand, including for partial failures and safe retries.
The problem: two teams, one shape, no agreement
Picture a food delivery app. The web team renders an order page from GET /orders/42. The response has a field called total, a number of cents, and the page shows it as "$24.50". It has worked for a year.
On a Tuesday the backend team ships a small clean-up. They rename total to totalCents, because two other fields already follow that naming. Their tests pass, and the mobile app, which goes through a mapping layer that was updated too, keeps working.
The web page now shows "$NaN" on every order, because undefined / 100 is NaN.
Nobody did anything strange. The backend changed its own code; the frontend trusted a shape it had never written down. The bug lives in the gap between them, and no single test suite covers that gap. This lesson is about closing it.
What an API contract is
An API contract is the written agreement about what a request may contain and what each response will contain: field names, types, which fields are required, which values an enum can take, which status codes can come back, and what an error looks like. "Written" is the key word. A contract that lives only in people's heads, or in a wiki page that nobody updates, is a guess.
A useful contract has three properties. It is machine-readable, so tools can generate types and tests from it. It is the single source both teams work from, so there is nothing to keep in sync by hand. And it is checked, at build time and in CI, so a change that breaks it fails a pipeline instead of a user's page.
Three questions every API must answer
The contract is the first of three linked questions. Most production incidents between frontend and backend come from one of them being left unanswered.
- What is the shape? The contract: types for every request and response, ideally generated rather than typed by hand on both sides.
- How does the shape change? Versioning and evolution: which changes are safe for clients already in the wild, which are breaking, and how you retire old fields without surprising anyone.
- What happens when it fails? Errors: one predictable error shape, honest status codes, per-item results when a batch half works, and a retry story that never charges a card twice.
The basics of REST status codes, error bodies, and idempotency keys are covered in [REST API design](/system-design/concepts/rest-api-design), and the timing of retries in [Retries and backoff](/system-design/concepts/retries-and-backoff). This lesson goes deeper on the contract itself: how to write it, how to generate code from it, how to check it at runtime, how to change it safely, and how errors fit into it.
Why this is a frontend topic
It can look like backend work. It isn't. The frontend is the client that cannot be redeployed on demand: a browser tab opened yesterday still runs yesterday's JavaScript, and a mobile build from six months ago still runs on thousands of phones. The client is also where a bad response turns into a broken screen in front of a user.
So the frontend engineer is the one who most needs the contract to be typed, checked, and changed carefully. In a system design interview, saying "the API is defined in OpenAPI, we generate a typed client, we validate responses at the boundary, and every error has the same shape" takes about ten seconds and signals that you have shipped real products.
Check your understanding · Exercise 1
The backend renamed total to totalCents and the web page broke. Which of these would have caught it before users did? Select all that apply.
The mental model: one schema, many generated things
Think of a wall socket. The power company can switch from coal to wind and your lamp keeps working, because the shape of the socket never changed. A new socket type can be added next to the old one without harm. Change the shape of the existing socket and every lamp in the country goes dark.
The contract is the socket shape. Everything behind it (databases, services, languages) can change freely. The shape itself changes rarely, carefully, and in ways old plugs still fit. Keep that picture: most of the rules in this lesson are just "don't change the socket under a plug that's already in it".
One file, many outputs
The second half of the model is about where the shape lives. In a healthy setup there is exactly one source of truth: an OpenAPI file, a GraphQL schema, a set of .proto files, or a tRPC router. Everything else is generated from it or checked against it. Nobody writes the same type twice.
From that one source, tools produce everything both teams need:
- Server stubs or checks. The backend either generates route handlers from the schema, or generates the schema from its code and CI checks the output.
- A typed client. The frontend gets a function per operation with typed parameters and a typed result, so
order.totalfails to compile once the field is gone. - Runtime validators. Zod or similar schemas that check real responses as they arrive, because the type system can't see the network (chapter 5).
- Mocks. Fake responses for tests and for building UI before the endpoint exists, in the exact shape of the real thing.
- Docs. A browsable reference that is never out of date, because it is built from the same file.
- A breaking-change check. A diff of the schema between the main branch and a pull request that fails when a change would break existing clients (chapter 7).
Schema first or code first
There are two ways to get the source file. Schema first (also called design first): people write the OpenAPI or GraphQL schema by hand, review it like code, then both sides implement it. Code first: the backend writes typed handlers (for example with NestJS decorators, FastAPI models, or Pothos for GraphQL) and a tool emits the schema from the code.
| Question | Schema first | Code first |
|---|---|---|
| Who reviews the shape? | Frontend and backend, before code exists | Mostly backend, as part of a code review |
| Can the frontend start early? | Yes, with generated mocks on day one | Only once the handler exists, or with a stub |
| Risk of drift? | Server code can disagree with the file unless tested | Low: the schema comes from the code |
| Typical fit | Public APIs, many client teams | One team owning both sides, fast iteration |
Either way, commit the generated schema file to the repository. A schema that only exists at a running /openapi.json URL can't be diffed in a pull request, and the diff is where breaking changes get caught.
Check your understanding · Exercise 2
A team keeps an OpenAPI file for docs, and separately hand-writes TypeScript types in the web app. What is the main risk? Pick one.
Four ways to write the contract
Each API style has its own contract format. They all do the same job: list every operation, what it takes, and what it returns, precisely enough that a tool can check code against it. Here is the same operation, "get an order", in each.
OpenAPI for REST
OpenAPI (3.1 is the version most tools support; 3.2 came out in 2025) is a YAML or JSON file that lists each path, its methods, parameters, request body, and the responses for each status code. Shapes are written in JSON Schema. It works with any server language, which is why it is the default for REST APIs and public APIs.
paths:
/orders/{id}:
get:
operationId: getOrder
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/Order" }
"404":
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
components:
schemas:
Order:
type: object
required: [id, status, totalCents]
properties:
id: { type: string }
status: { type: string, enum: [placed, cooking, on_the_way, delivered] }
totalCents: { type: integer }
note: { type: string }
required decides which fields are optional in the generated types.A GraphQL schema
A GraphQL API always has a schema; the server can't run without one. It is written in SDL, the schema definition language, where ! marks a field that is never null.
The contract for a given screen is the schema plus the query that screen sends: the client only gets the fields it asked for. See [GraphQL](/system-design/concepts/graphql) for how queries and fragments work.
enum OrderStatus { PLACED COOKING ON_THE_WAY DELIVERED }
type Order {
id: ID!
status: OrderStatus!
totalCents: Int!
note: String
}
type Query {
order(id: ID!): Order
}
Protobuf for gRPC and Connect
gRPC APIs are defined in .proto files. Every field has a number, and in the binary encoding the number, not the name, is what goes over the wire. That makes renames safe for binary clients (not for JSON ones, which see names) but makes reusing a number a silent disaster. [gRPC and gRPC-Web](/system-design/concepts/grpc-and-grpc-web) covers how a browser talks to these services.
tRPC: the TypeScript code is the contract
tRPC skips the schema file. The server defines procedures in TypeScript, usually with a zod schema for the input, and exports only the router's type. The client imports that type and gets full autocomplete for every procedure, its input, and its output. No generation step, no YAML.
// server/router.ts
export const appRouter = router({
getOrder: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => db.orders.find(input.id)),
});
export type AppRouter = typeof appRouter;
// web/api.ts
import type { AppRouter } from "../server/router";
const trpc = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: "/api/trpc" })] });
const order = await trpc.getOrder.query({ id: "42" }); // fully typed
import type.The catch is in the word "import". tRPC needs the client and server in the same TypeScript project, almost always a monorepo, deployed together. It has no language-neutral schema, so a native mobile app or a partner can't use it. And because the client and server are typed from the same commit, it says nothing about an old browser tab still running last week's client.
oasdiff, GraphQL Inspector, and buf breaking.Decision Which contract format should a new product pick?
What we pick: A TypeScript web app and its own backend in one repo: tRPC is fine, as long as you still treat deploys as possibly breaking open tabs. Several clients (web, iOS, Android) or a public API: OpenAPI, or GraphQL if screens need very different slices of a rich graph. Many internal services already on gRPC: protobuf with Connect. The format matters less than having one, committing it, and diffing it.
Generated clients, step by step
A contract only pays off when the frontend code is tied to it. The tie is a generated client: code produced from the schema by a tool, never edited by hand, that gives you a typed function or hook per operation. Let's walk through the common setups.
Where we start: a hand-written fetch
type Order = { id: string; total: number }; // written by hand, months ago
async function getOrder(id: string): Promise<Order> {
const res = await fetch(`/api/order/${id}`); // typo in the path: compiles
return res.json(); // res.json() is Promise of any: compiles
}
Three things can be wrong and TypeScript reports none of them: the path (it should be /orders/), the field name, and the claim that the body is an Order at all. res.json() returns any, and any fits every type. The interface is a comment that the compiler happens to read.
openapi-typescript and openapi-fetch
The lightest setup is openapi-typescript, which turns an OpenAPI file into one .ts file of types, with no runtime code at all. Its companion openapi-fetch is a tiny wrapper around fetch that uses those types to check the path, the parameters, and the body, and to type the result.
import createClient from "openapi-fetch";
import type { paths } from "./api/schema";
const api = createClient<paths>({ baseUrl: "/api" });
const { data, error } = await api.GET("/orders/{id}", {
params: { path: { id: "42" } }, // a missing or misspelled param fails to compile
});
if (error) showProblem(error); // typed from the 404 response schema
else render(data.totalCents); // data.total would fail to compile
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.d.ts in CI or a pre-build script.Notice that success and error come back as separate, typed values. The error type is generated from the error responses in the schema, which is one more reason to describe errors in the contract (chapter 8).
Orval: hooks, mocks, and validators
Orval generates more. From the same OpenAPI file it can produce a TanStack Query hook per operation (useGetOrder(id)), Mock Service Worker handlers that return fake data in the right shape, and zod schemas for runtime checks. It costs more generated code in the repository, and saves a lot of hand-written glue.
GraphQL Code Generator
For GraphQL the types depend on the query, not only the schema: a component that asks for id and status should get a type with exactly those two fields. GraphQL Code Generator's client preset reads every graphql(...) call in your source, checks it against the schema, and generates a typed document for each. Pass the document to Apollo Client or urql and the result is typed to the fields you asked for. (Relay has its own compiler that does the same job.)
Rules that keep generated code healthy
- Never edit generated files. Put them in their own folder with a header comment, exclude them from lint, and regenerate on every schema change.
- Regenerate in CI. A check that runs the generator and fails if the output differs from the committed files catches anyone who changed the schema and forgot to regenerate.
- Wrap, don't fork. If you need auth headers, retries, or logging, add them in a small wrapper or a middleware hook (
openapi-fetchhas one), not inside the generated code. - Keep one client instance. One place sets the base URL, credentials, and headers, so every call behaves the same.
Check your understanding · Exercise 3
Why does const order: Order = await res.json() compile even when the server sends something completely different? Pick one.
Why types alone don't protect you: validate at the boundary
Generated types are a big step, but they describe what the server promised, not what arrived. TypeScript types are erased when the code is compiled. At runtime, the bytes from the network are parsed by JSON.parse and handed to your code with no checks at all.
So the generated client still trusts the server. Many things can make the real response differ from the schema: a backend bug, a deploy where the server is ahead of the schema file, a proxy or CDN returning an HTML error page with status 200, a third-party API that changed without notice, or an old cached response from before a migration.
The boundary is where trust changes
A trust boundary is the line where data enters your code from somewhere you don't control. For a frontend, the main ones are API responses, localStorage, URL parameters, postMessage events, and messages from a WebSocket. Inside the boundary, types can be trusted. At the boundary, you check.
order.totalCents is really a number.The tool most teams use is zod: you describe the shape once as a schema object, get the TypeScript type from it with z.infer, and call safeParse on real data to find out whether it matches. Orval and other generators can emit zod schemas from OpenAPI, so you don't write them twice.
const Order = z.object({
id: z.string(),
status: z.enum(["placed", "cooking", "on_the_way", "delivered"]),
totalCents: z.number().int(),
note: z.string().optional(),
});
type Order = z.infer<typeof Order>;
async function getOrder(id: string): Promise<Order> {
const res = await fetch(`/api/orders/${id}`);
const result = Order.safeParse(await res.json());
if (!result.success) {
reportContractError("getOrder", result.error.issues); // to your error tracker
throw new ContractError("getOrder");
}
return result.data; // now the type is earned, not assumed
}
totalCents. Without a check, undefined + 299 is NaN and users see it. With a check, this widget shows its error state and the contract error reaches your tracker within minutes of the bad deploy.How strict should the check be?
A validator that rejects too much breaks the app on harmless changes; one that accepts too much lets real bugs through. The usual rule is a version of Postel's law: be strict about what the screen actually needs, lenient about everything else.
- Unknown extra fields: ignore them. zod's
z.objectstrips keys it doesn't know by default. A server that adds a field must never break a client. - Missing required fields: fail. If the page needs
totalCentsand it's absent, rendering "$NaN" is worse than an error state. - Unknown enum values: map to a fallback. A new status like
refundedshould show as "Unknown status" rather than crash. In zod, addunknownto the enum and call.catch()with a function that reports the bad value and returns"unknown". - Wrong types: fail. A string where a number belongs means the contract is broken, not stretched.
Failing well, and what it costs
When a check fails, contain the damage. Throw a specific error so the nearest error boundary shows a fallback for that widget, not a blank page. Always report the failure, with the operation name and the zod issue paths (never the full body, which may contain personal data). A spike in contract errors right after a backend deploy is the fastest signal you'll get that something shipped broken.
Validation also costs CPU. Checking a 20-item order list is negligible; checking a 5,000-row table on every poll can take tens of milliseconds on a slow phone. For big, hot responses, validate the first page or a sample, or validate fully in development and staging only. And watch bundle size: a validator library plus hundreds of generated schemas can add real kilobytes, so load them with the feature that needs them.
Check your understanding · Exercise 4
The server starts sending status: "refunded", a value the client's enum doesn't list. What should a well-built client do? Pick one.
Changing the contract without breaking anyone
Every API changes. The question is whether a change is backward compatible: clients written against the old contract keep working against the new server. If yes, ship it any time. If no, it is a breaking change, and it needs a plan.
Remember who the clients are. A browser tab opened before a deploy runs old JavaScript until it reloads, sometimes for days. A mobile app version can stay installed for years, and a partner integration may be written once and never touched again. So "we'll update the frontend in the same release" only covers the newest tabs.
The rule depends on the direction
The trick to judging a change is to ask which way the data flows. For responses (server to client) the server may give more, never less: old clients ignore what they don't know, but miss what they relied on. For requests (client to server) the server may accept more, never demand more: old clients won't send a new required field.
| Change | In a response | In a request |
|---|---|---|
| Add an optional field | Safe: old clients ignore it | Safe: old clients don't send it |
| Add a required field | Safe: it is always present | Breaking: old clients don't send it |
| Remove a field | Breaking: clients may read it | Safe if the server stops needing it |
| Rename a field | Breaking (a remove plus an add) | Breaking (a remove plus an add) |
| Make an optional field required | Safe | Breaking |
| Make a required field optional (nullable) | Breaking: clients assume it's there | Safe |
| Add an enum value | Breaking for clients without a fallback | Safe: old clients just never send it |
| Change a type (string to number) | Breaking | Breaking |
| Tighten validation (max length 200 to 100) | Not applicable | Breaking: old valid input now fails |
Two rows surprise people. Adding an enum value to a response is technically additive, but a client with an exhaustive switch or a strict validator has no branch for it. That is why the previous chapter said to map unknown values to a fallback: it turns a breaking change into a safe one. And changing behavior without changing shape (a default sort order, a unit from cents to dollars, rounding) breaks clients just as hard while every type check stays green.
Renames and removals: expand, migrate, contract
Breaking changes are still possible; they just can't happen in one step. The pattern is called expand and contract, and it has three phases.
First expand: add the new field next to the old one and send both. Then migrate: move every client to the new field and watch usage of the old one fall. Only then contract: remove the old field once nothing reads it.
The middle phase is the long one, and you need data to know when it's over. GraphQL makes this easier: @deprecated(reason: "Use totalCents") marks the old field in the schema so tools warn about it, and schema registries such as GraphOS or Hive can count which clients still request it. For REST, log which client versions call the endpoint (a User-Agent or a custom X-Client-Version header helps), and keep the old field until those versions are gone.
When you do need a new version
Sometimes a change is too large for expand and contract: a new pagination model, a different resource layout. Then you version. [REST API design](/system-design/concepts/rest-api-design) covers the basics; here are the three styles side by side.
Decision How should the API carry a version?
What we pick: For an app's own frontend, avoid versions: use additive changes and expand and contract, and keep one living API. For a public API with many integrators, URL major versions are the simple default, and date-based versions are the most flexible option if you can afford them. GraphQL APIs usually never version at all; they evolve field by field with @deprecated.
Telling clients: Deprecation and Sunset headers
Changelogs get missed, so HTTP has headers for this. They work per resource, so they suit a whole endpoint or version going away; a single field is deprecated in the schema instead. Deprecation (RFC 9745) says when the resource was, or will be, deprecated, as a structured date in Unix seconds such as Deprecation: @1767225600 (1 January 2026).
Sunset (RFC 8594) says when the resource will stop working, as an HTTP date. A Link header with rel="deprecation" or rel="sunset" points at documentation such as the migration guide.
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1767225600
Sunset: Wed, 30 Jun 2027 23:59:59 GMT
Link: <https://api.example.com/docs/migrate-v2>; rel="deprecation"
Make them useful on the client: have the API wrapper log a warning in development, and count deprecated calls by client version in production. A dashboard of "calls to sunset endpoints" is how you know when it is safe to switch one off.
Catching breaking changes in CI: schema diffs and contract tests
Knowing the rules is not enough; people forget them on a Friday afternoon. The goal is a pipeline that refuses a breaking change before it merges. There are two complementary tools: a diff of the schema, and contract tests that check real client expectations.
Schema diffs: compare the file with main
Each contract format has a tool that compares two versions of the schema and classifies every difference as safe or breaking, using the same rules as the table in the previous chapter.
- OpenAPI:
oasdiff breaking main.yaml pr.yamllists changes such as a removed response property or a new required request parameter. - GraphQL: GraphQL Inspector's
diffcommand, or the schema checks in GraphOS or Hive, which can also check changes against real recorded client operations. - Protobuf:
buf breaking --againstthe main branch, which knows wire rules like "never reuse a field number".
A diff is cheap and catches most mistakes. Its limit is that it only knows the schema. It can't tell whether any client actually reads the field you removed, so it is often too strict, and it can't see behavior changes at all, so it is sometimes too lenient.
Consumer-driven contract tests with Pact
Contract testing checks the agreement from the client's side. The best-known tool, Pact, works in three steps. First, the consumer (the web app) writes tests against a Pact mock server that records each request it makes and the response it expects.
Second, those recordings become a pact file, published to a shared Pact Broker. Third, in the provider's (the API's) pipeline, Pact replays each recorded request against the real provider and checks that the response satisfies what the consumer expected.
can-i-deploy blocks the release.The power is in what the pact contains: only the fields the consumer actually uses. If the web app reads id, status, and total, the backend is free to remove note, but renaming total fails the provider's build with a message naming the consumer and the field. Before a deploy, pact-broker can-i-deploy asks the broker whether this exact version has been verified against the versions of the other side already running in the target environment.
| Question | Schema diff | Contract tests (Pact) |
|---|---|---|
| What does it compare? | Old schema against new schema | Each client's real expectations against the running provider |
| Knows who uses a field? | No | Yes, per consumer and version |
| Catches behavior changes? | No | Some: the expected values in a recorded example |
| Setup cost | Minutes: one CI step | Real: a broker, tests on both sides, provider states |
| Best for | Every API with a schema | Many teams and services deploying independently |
Contract tests are not end-to-end tests. They don't need both systems running together, which is exactly why they are fast and reliable. They also don't replace the provider's own functional tests: a pact checks the shape and the example values, not that the business logic is right.
Check your understanding · Exercise 5
The backend wants to remove note from the order response. The schema diff flags it as breaking. The web app's pact doesn't mention note. What do you know? Pick one.
Errors are part of the contract: one shape everywhere
Teams describe success responses carefully and leave errors to chance. The result is an app where one endpoint returns { "error": "Not found" }, another { "message": "...", "code": 12 }, a third plain text, and the gateway an HTML page. The frontend ends up with a pile of if statements guessing which shape it got, and users see "Something went wrong" for everything.
The fix is one error shape for every endpoint, written into the contract like any other response. Then a single function on the client can turn any error into the right UI.
RFC 9457: problem details
You don't need to invent the shape. RFC 9457, "Problem Details for HTTP APIs" (it replaced RFC 7807 in 2023), defines a JSON error body with the media type application/problem+json. Many frameworks support it out of the box, including Spring Framework 6 (its ProblemDetail class) and ASP.NET Core (AddProblemDetails).
Content-Type: application/problem+json. Code branches on type; people read title and detail; support searches for instance. The type is shortened here; in practice it is a full URI, ideally one that serves a page explaining the problem.type: a URI that identifies the kind of problem. This is the machine-readable code the client switches on. If omitted it meansabout:blank, which says "nothing more than the status code".title: a short, human summary of the problem type. The same for every occurrence of that type.status: the HTTP status code, repeated in the body so it survives proxies and logging.detail: a human explanation of this particular occurrence.instance: a URI for this occurrence, often a request or trace ID support can look up.- Extension members: any extra fields your API needs. A common one is an
errorsarray of field errors, each with a JSON pointer or field name and a message, for forms.
Two habits make the shape work. Switch on type, never on title or detail, because text gets reworded and translated. And never put stack traces, SQL, or internal hostnames in detail: it is shown to users and readable by attackers.
From error to UI
With one shape, the client can map every failure to one of a few UI treatments in one place. Known type values get their specific treatment first; for everything else, the status code picks the family.
const OUT_OF_STOCK = "https://api.example.com/problems/out-of-stock";
function toUiError(p: Problem): UiError {
if (p.type === OUT_OF_STOCK) return { kind: "inline", message: "That item just sold out." };
if (p.status === 401) return { kind: "reauth" };
if (p.status === 409) return { kind: "inline", message: p.detail ?? "This changed. Refresh and try again." };
if (p.status === 422 && p.errors) return { kind: "fields", fields: p.errors };
if (p.status === 429 || p.status >= 500) return { kind: "retry", traceId: p.instance };
return { kind: "toast", message: p.title ?? "Something went wrong" };
}
Don't forget errors that never reach your API: a network failure (fetch rejects with a TypeError), a timeout you triggered with AbortSignal.timeout, or an HTML 502 page from a load balancer. The client wrapper should turn all of these into the same Problem shape, so the rest of the app has exactly one thing to handle.
Errors in GraphQL
GraphQL usually answers 200 OK even when something failed, and puts problems in a top-level errors array next to data. Each error has a message, a path to the field that failed, and an extensions object where servers put a machine-readable code. Many teams go further and model expected business errors as data: a mutation returns a union like PlaceOrderResult = Order | OutOfStock | PaymentDeclined, so the client gets typed, exhaustive error handling from codegen.
Check your understanding · Exercise 6
Which field of a problem details body should client code branch on to pick an error message? Pick one.
Partial failures and safe retries
So far each request either worked or failed. Two situations break that tidy picture: a request that did several things and only some worked, and a request whose answer never arrived so you can't tell whether it worked at all. Both need to be in the contract.
Batch endpoints that half work
Say a user selects 50 emails and clicks "Archive". The client sends one batch request. 47 archive fine, 1 was already deleted by another device, 1 belongs to a shared mailbox the user can't edit, and 1 hits a service that is briefly overloaded. What should the response be?
Decision How should a batch endpoint report mixed results?
What we pick: If items are independent, return per-item results, and make the client read them. Use 200 with a clear per-item status, or 207 if your team wants mixed outcomes visible in metrics; either way, document the body in the schema. Use all or nothing when a partial result would leave the data in a state the user can't make sense of.
503 is worth retrying; a 403 will fail again.On the client, per-item results should drive per-item UI. Remove the 47 archived rows and the one that was already gone. Keep the other 2 selected with a short reason each, and offer a retry only for the one that can succeed later (the 503, not the 403). A toast saying "Archived 47 of 50" is honest; a green "Done" is a lie.
GraphQL has the same idea built in: a query can return data for most fields and errors with a path for the ones that failed, so a dashboard renders every widget except the broken one.
The unknown outcome: retries need idempotency
Now the harder case. A user taps "Pay $24.50". The request reaches the server and the card is charged, but the phone's connection drops before the response arrives. The client sees a timeout and can't know whether the payment happened.
Retrying is only safe for idempotent operations: doing them twice has the same effect as doing them once. GET, PUT, and DELETE are defined as idempotent in HTTP. POST is not, so a blind retry of "create a payment" may charge twice. [Retries and backoff](/system-design/concepts/retries-and-backoff) covers when and how fast to retry; this is about making the retry safe.
The fix is an idempotency key: a unique ID the client generates once per user action (for example with crypto.randomUUID()) and sends in an Idempotency-Key header. The server stores the key with the result of the first request. If the same key arrives again, it returns the stored result instead of doing the work again. Stripe has done this for years, and an IETF draft aims to standardize the header.
- One key per intent, not per attempt. Create the key when the user clicks Pay and reuse it for every retry of that click. A new key per attempt defeats the whole point.
- Same key, different body: reject. If a request reuses a key with different parameters, the server should return an error instead of the old result, because it's a client bug.
- Concurrent duplicates. If the retry arrives while the first attempt is still running, the server should answer
409 Conflict(or wait) rather than start a second charge. - Keys expire. Servers keep them for a limited window (Stripe may remove a key once it is at least 24 hours old), so all retries of one action must finish well inside that window.
- Put it in the contract. Document which operations accept or require the header, and what the replay and conflict responses look like.
Check your understanding · Exercise 7
A checkout page creates the idempotency key inside the function that sends the request, so each retry gets a fresh UUID. What happens on a timeout after the server charged the card? Pick one.
In real products and in the interview
None of this is theory. The companies with the most API clients have converged on the same handful of habits, and interviewers listen for them. First, what real teams do; then how to fit it into a 45-minute design round.
What real teams do
- Stripe pins each account to a dated API version and keeps old versions working by translating responses on the server. It also popularized the
Idempotency-Keyheader, which it accepts on everyPOST. - GitHub publishes an OpenAPI description of its REST API and selects REST versions with the
X-GitHub-Api-Versionheader. Its GraphQL API evolves without versions, marking fields with@deprecatedbefore removing them. - Typical TypeScript product teams commit an OpenAPI file or GraphQL schema, generate a typed client and TanStack Query hooks with Orval or GraphQL Code Generator, and validate responses with zod at the edges that matter.
- Teams with a BFF (see [Backend for frontend](/system-design/concepts/backend-for-frontend)) often use tRPC or GraphQL between the web app and its BFF, and OpenAPI or protobuf between the BFF and the services behind it.
When to bring it up
In a frontend system design interview, the API section usually comes after requirements and the high-level architecture. That is where contracts and errors belong. You don't need a long speech; a few precise sentences show that you have run an API in production.
- Name the contract. "The endpoints are defined in OpenAPI. We generate types and query hooks from it, so the client fails to compile when the shape changes."
- Name the boundary. "Responses are validated with zod in the API layer. Unknown enum values map to a fallback; a missing required field shows the widget's error state and is reported."
- Name the error shape. "Every error is a problem details body. One function maps status and
typeto a UI treatment: field errors, inline message, retry, or re-login." - Name the evolution rule. "Changes are additive. Renames go through expand and contract, and CI runs a schema diff on every pull request."
- Name the risky writes. "Submit and pay send an idempotency key per user action, so retries after a timeout are safe."
How deep to go by level
Match the depth to the role. Every level should handle loading and error states; the higher the level, the more the answer is about change over time and across teams rather than about one request.
| Level | What a strong answer includes |
|---|---|
| Junior | Typed responses, handling loading and error states, knowing which status codes mean what to the UI |
| Mid | A generated client from a schema, one error shape, additive changes, idempotency keys for submits |
| Senior | Runtime validation and its trade-offs, expand and contract with usage data, old tabs and mobile versions, per-item batch results |
| Staff | Org-wide contract governance: schema review, diff checks or Pact across teams, deprecation policy with Sunset dates, versioning strategy for public clients |
Think first
The interviewer says: "Our iOS app has users on versions up to a year old. How does that change your API plan?"
Every response change must be safe for a client built a year ago, so no field can be removed or renamed until analytics show that version is gone. The rule becomes additive changes only, with expand and contract measured by app version.
I'd send the app version in a header on every request and log it, so we know exactly who still reads an old field. I'd add a minimum supported version the server can return, so the app can show an "update required" screen as a last resort instead of breaking silently.
And new enum values need a fallback in the app from day one, because we can't ship a fix to old versions.
Summary
- An API contract is a committed, machine-readable schema that both sides are checked against. A wiki page or hand-written types on each side is not a contract.
- Generate the client from the schema (openapi-typescript, Orval, GraphQL Code Generator, or tRPC's shared types) so shape changes fail the build instead of the page.
- Types are erased at runtime: validate responses at the boundary with zod, strict on what the screen needs, lenient on extra fields, with fallbacks for unknown enum values.
- Responses may grow and requests may loosen; everything else is breaking. Renames and removals go through expand, migrate, contract, guided by real usage data.
- Catch breaking changes in CI with a schema diff on every pull request, and add Pact contract tests when many teams deploy independently.
- Use one error shape everywhere, ideally RFC 9457 problem details, and map
statusplustypeto a UI treatment in one function. - Batch endpoints return per-item results, and the UI reports exactly what succeeded. Risky writes carry an idempotency key per user action so retries can't double-charge.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
How do you keep the frontend and backend in sync on the shape of an API?
One committed schema is the source of truth: OpenAPI for REST, the GraphQL schema, protobuf, or a tRPC router in a TypeScript monorepo. The frontend never hand-writes response types; it generates a typed client (openapi-typescript, Orval, GraphQL Code Generator) so a rename breaks the build, not the page.
CI regenerates the client and fails if it differs from what's committed, and runs a schema diff (oasdiff, GraphQL Inspector, buf breaking) on every pull request. With several teams deploying independently, I'd add consumer-driven contract tests with Pact.
Question 2
If the client is fully typed, why validate responses at runtime?
Types are erased at compile time and res.json() returns any, so the compiler never sees what actually arrives. Backend bugs, deploy skew, a proxy's HTML error page, or an old open tab against a new server all produce data that doesn't match the type.
So I validate with zod at the API layer: strict on fields the screen needs, lenient on extra fields, with a fallback for unknown enum values. Failures show the widget's error state and are reported with the operation name and issue paths, which also makes broken backend deploys visible within minutes. For huge, hot responses I'd sample or validate only in staging to save CPU.
Question 3
The backend wants to rename userName to username. Walk me through it.
A rename is a remove plus an add, so it's breaking for any client reading the old name, including open tabs and old mobile builds. I'd use expand and contract.
Expand: the server sends both fields. Migrate: update clients to read username, mark userName deprecated (@deprecated in GraphQL, deprecated: true in OpenAPI, Deprecation and Sunset headers if it's a whole endpoint), and watch usage by client version. Contract: remove userName only once usage is at zero or below an agreed threshold, with the schema diff confirming nothing else changed.
Question 4
What should an error response look like, and how does the UI use it?
One shape for every endpoint, ideally RFC 9457 problem details: type as a stable machine-readable identifier, title, status, detail, instance for a trace ID, plus extensions like an errors array of field errors for 422s. It's in the contract, so the client's error type is generated too.
The client wrapper turns everything, including network failures, timeouts, and gateway HTML pages, into that shape. One function maps it to a UI treatment: field errors next to inputs, inline messages for known business problems, a retry option for 429 and 5xx, a re-login flow for 401. Code switches on type and status, never on human text.
Question 5
A user clicks "Pay" and the request times out. What do you do?
The outcome is unknown: the server may have charged the card. A blind POST retry could charge twice, so the request carries an Idempotency-Key generated once when the user clicked, and every retry reuses it. The server returns the stored result for a repeated key, rejects a reused key with a different body, and answers 409 if the first attempt is still running.
The UI shows "Confirming your payment" rather than an error, retries with backoff within the key's lifetime, and if it still can't confirm, checks order status before offering to try again. It never generates a new key for the same click.
Practice it
These design problems lean on api contracts, versioning, and errors.
References
- OpenAPI Specification v3.1 The REST contract format.
- RFC 9457: Problem Details for HTTP APIs The standard error body.
- RFC 9745: The Deprecation HTTP Response Header Field
- RFC 8594: The Sunset HTTP Header Field
- openapi-typescript and openapi-fetch Types and a typed fetch client from OpenAPI.
- Orval Query hooks, MSW mocks, and zod schemas from OpenAPI.
- Zod documentation Runtime validation with inferred TypeScript types.
- Pact documentation Consumer-driven contract testing.
- Stripe API: idempotent requests
- GraphQL Code Generator
Read next
- REST for frontendsA way to shape an HTTP API around resources and a few standard verbs, so the frontend can fetch, cache, retry, and handle errors in the same predictable way everywhere.
- GraphQLA typed query language for APIs: the client asks one endpoint for exactly the fields a screen needs, across many related objects, in one round trip, and pays for it with harder caching and more work on the server.
- gRPC and gRPC-WebA fast way for services to call each other's functions using compact binary messages and a shared contract; browsers reach it through gRPC-Web or the Connect protocol.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Backend for frontend (BFF)A small server, usually owned by the frontend team, that gathers data from many backend services and returns exactly what one screen of one kind of client needs.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.