REST for frontends
A 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.
The problem: every endpoint is a surprise
Picture a team building a small notes app. The first backend developer adds GET /getNotes?user=7, and the second adds POST /notes/delete with the id in the body. The third adds POST /updateNoteTitle, which answers 200 OK with { "success": false, "msg": "too long" } when the title is invalid.
Each endpoint works. Together they are a mess.
The frontend pays for that mess. Every call needs its own code: how to send the input, how to tell success from failure, what to do on a timeout. A generic data layer is impossible, because nothing is generic. The browser cache, the CDN, and libraries like TanStack Query cannot help either, because they rely on HTTP meaning what it says.
What goes wrong in practice
- Errors hide inside 200s.
fetchonly rejects on a network failure, never on a status code. If the server answers200withsuccess: false, monitoring sees a healthy API while users see a broken form. - Retries become dangerous. A phone on a train sends
POST /updateNoteTitle, the connection drops, and the app has no idea whether it landed. Without rules about which calls are safe to repeat, the app either never retries (and loses work) or always retries (and duplicates it). - Caching breaks. A delete sent as
GET /deleteNote?id=3can be prefetched by the browser or replayed by a proxy. Caches and crawlers assumeGETchanges nothing. - Screens make too many calls. Endpoints shaped around database tables, not screens, force a page to make five calls in a chain before it can paint.
REST (Representational State Transfer, a style described by Roy Fielding in 2000) is the cure most web APIs reach for. In practice it means a short list of conventions: name things as resources with URLs, act on them with the standard HTTP methods, report the outcome with real status codes, and let HTTP features such as caching headers do their jobs.
request() helper covers them all.Why this is a frontend topic
The API is the contract the UI is built on. A frontend engineer reads it, often helps design it, and is the first to suffer when it is wrong. In a frontend system design interview, the Interface part of the RADIO framework (Requirements, Architecture, Data model, Interface, Optimizations) is where you design it out loud: which endpoints the screen needs, what they return, how they page, and how errors come back.
This lesson walks through each part from the client's side: resources and verbs, the status codes a UI must handle, a single error shape, safe retries, query parameters for lists, caching headers, versioning, and shaping endpoints around a screen. It ends with how to present all of it in an interview.
Check your understanding · Exercise 1
An endpoint answers 200 OK with the body { "success": false, "error": "Card declined" }. The app calls it with fetch inside a try/catch. What happens? Pick one.
The mental model: nouns with addresses, a few verbs
Keep one picture in your head: the API is a set of things, each with an address, and you can only do a handful of standard actions to any of them. The things are called resources. The address is the URL. The actions are the HTTP methods. What travels over the wire is a representation of the resource, usually JSON.
A library catalog is a fair comparison. Every book has a shelf number, and you can look a book up, add a new book, replace its catalog card, correct one line on the card, or remove it. The verbs are the same for every book; only the shelf number changes. Nobody invents a new verb called "borrowTheBlueBook".
Collections and items
Resources come in two shapes. A collection is a list, like /notes, and an item is one member of it, like /notes/42. Use plural nouns for collections and put the id after them. The pair gives you almost every endpoint a screen needs:
| Request | Means | Typical success |
|---|---|---|
GET /notes | List notes (filtered, sorted, paged by query parameters) | 200 with an array and paging info |
POST /notes | Create a note; the server picks the id | 201 with the new note and a Location header |
GET /notes/42 | Read one note | 200 with the note |
PATCH /notes/42 | Change some fields | 200 with the updated note |
PUT /notes/42 | Replace the whole note | 200 or 204 (201 if it created the note) |
DELETE /notes/42 | Remove it | 204 with no body |
Nesting, and when to stop
When a resource only makes sense inside another, nest it once: /notes/42/comments lists the comments on note 42. Stop at one level. A URL like /users/7/notes/42/comments/9 forces the client to know three ids just to read one comment. Once a child has its own global id, give it a top-level home too: /comments/9.
Actions that are not plain edits
Some operations do not fit the five verbs neatly: publish a draft, archive a project, send an invoice. There are three honest ways to model them, and teams pick one and stay with it:
- Change a field.
PATCH /posts/42with{ "status": "published" }. Best when the action really is a state change and the server validates the transition. - Create a sub-resource.
POST /invoices/9/sendscreates a "send" record. It reads oddly at first, but it gives the action an id, a history, and a status you can poll. - Use a named action.
POST /posts/42:publish(Google's API guide calls these custom methods) orPOST /posts/42/publish. Clear and common; just keep them rare and always onPOST.
What you never do is hide a change behind GET. A GET can be prefetched by the browser, replayed by a proxy, crawled by a bot, or fired twice by a React effect in development. All of those are fine for a read and a disaster for a delete.
Representations, not database rows
The JSON you return is a view of the resource, shaped for its readers. It does not have to match a table. A note might come back with authorName copied in, createdAt as an ISO 8601 string (2026-10-19T09:30:00Z), and money as integer cents with a currency ({ "amount": 1999, "currency": "EUR" }) so JavaScript floats never round it. Ids should be strings, even when they are numbers in the database, because JavaScript loses precision past 2 to the power 53.
Check your understanding · Exercise 2
You need an endpoint to archive a project. Which design fits REST conventions worst? Pick one.
The verbs: safe, idempotent, and what each one promises
Each HTTP method is a promise about what happens on the server. HTTP Semantics (RFC 9110) defines two properties that matter more to a frontend than any other, because they decide what the browser, the caches, and your own code may do on their own.
- Safe: the request does not ask the server to change anything. The client can send it any number of times, ahead of time, or by accident.
GET,HEAD, andOPTIONSare safe. - Idempotent: sending the same request twice leaves the server in the same state as sending it once. All safe methods are idempotent, and so are
PUTandDELETE.POSTandPATCHare not, by default.
Idempotent is about the state on the server, not the response. Deleting note 42 twice may answer 204 and then 404, but either way note 42 is gone, exactly as after one delete. That is what makes the request safe to retry after a timeout.
PUT and DELETE change data yet are safe to repeat; POST and PATCH need an idempotency key first.GET and HEAD
GET reads. It has no body (servers may reject or ignore one), so all inputs go into the path and query string. Its responses are cacheable, which is the whole point: the browser cache, a CDN, and your client-side cache can all reuse them. HEAD is a GET without the body, handy to check whether a file exists or how large it is before downloading it.
POST
POST sends data for the server to process, most often to create a new item in a collection. The server picks the id and answers 201 Created with a Location: /notes/43 header and, ideally, the new note in the body so the client does not need a second request. POST is the one method you cannot blindly retry: two identical POST /orders calls make two orders. The idempotency chapter shows how to fix that.
PUT versus PATCH
PUT replaces the resource at that URL with the body you send. Any field you leave out is gone. Because the result does not depend on what was there before, PUT is idempotent. It also allows creating at a client-chosen URL, such as PUT /drafts/{clientId}, which is a neat trick for offline-first apps.
PATCH (RFC 5789) changes part of a resource, and there are two standard body formats. JSON Merge Patch (RFC 7396, Content-Type: application/merge-patch+json) sends only the changed fields, and null means "remove this field". JSON Patch (RFC 6902, application/json-patch+json) sends a list of operations such as add, remove, replace, and test. Most product APIs accept a plain partial JSON object and treat it like a merge patch.
PUT /notes/42
Content-Type: application/json
{ "title": "Groceries", "body": "milk, eggs", "pinned": false }
PATCH /notes/42
Content-Type: application/merge-patch+json
{ "title": "Groceries" }
PATCH /notes/42
Content-Type: application/json-patch+json
[{ "op": "replace", "path": "/title", "value": "Groceries" }]
A merge patch that sets a field to a value is idempotent in practice: applying { "title": "Groceries" } twice gives the same note. A patch like [{ "op": "add", "path": "/tags/-", "value": "food" }], which appends to an array, is not: run it twice and the tag appears twice. That is why HTTP does not promise PATCH is idempotent.
DELETE
DELETE removes the resource. Answer 204 No Content. If a second delete arrives (a retry), answering 404 is correct, but many APIs answer 204 again so the client sees the same outcome both times; either is fine as long as the client treats "already gone" as success. Product UIs often soft-delete, keeping the row with a deletedAt date so "Undo" can restore it.
The hidden OPTIONS request
When your app calls an API on another origin, the browser sends a CORS preflight, an OPTIONS request asking "may this origin send this method and these headers?", before any request that is not "simple". PUT, PATCH, DELETE, a Content-Type: application/json body, or an Authorization header all trigger it. Each preflight the browser has not cached adds a full round trip, 150 ms on a typical mobile link, before the real request leaves.
The server can let the browser cache the answer with Access-Control-Max-Age (browsers cap it: Chrome at 2 hours, Firefox at 24 hours). Or you can avoid the cross-origin call by serving the API from the same origin, for example through app.example.com/api.
Check your understanding · Exercise 3
The note is { "title": "Trip", "body": "pack", "pinned": true }. The client sends PUT /notes/42 with { "title": "Holiday" }. What should the note look like afterwards? Pick one.
Status codes the UI must handle, and what to show for each
A status code is the server telling the client, in three digits, what kind of outcome this is. The first digit is the class: 2xx worked, 3xx look elsewhere (or use your copy), 4xx the request was wrong, 5xx the server failed. The class alone already tells the UI the most important thing: whether trying again could help.
That one question splits the codes into groups the UI treats differently. A 4xx will fail again if sent unchanged, so the user (or the app) must change something. A 5xx, a 429, or a network error might succeed later, so a retry, automatic or manual, makes sense.
4xx fails again unless something changes; a 5xx, a 429, or a dropped connection may work if you wait and try again, automatically when the request is safe to repeat, otherwise only when the user asks.The codes worth handling, one by one
| Code | Means | What the UI does |
|---|---|---|
200 OK | Here is the result | Render it |
201 Created | Made it; Location says where | Use the returned item, navigate or insert it in the list |
202 Accepted | Queued, not done yet | Show "Processing" and poll the status URL it returns |
204 No Content | Done, nothing to return | Close the dialog, update the cache from what you sent |
304 Not Modified | Your cached copy is still good | Nothing: when the browser revalidated on its own, fetch sees a 200 with the cached body |
400 Bad Request | Malformed request | A bug in the client: generic message, log it |
401 Unauthorized | Not signed in, or the token expired | Refresh the token once and replay; if that fails, go to sign in and keep the draft |
403 Forbidden | Signed in, but not allowed | Explain and offer "request access"; never retry, never sign out |
404 Not Found | No such resource | A not-found screen with a way back; for a list item, remove it |
409 Conflict | Clashes with current state (duplicate name, wrong state) | Explain the clash, offer the fix (rename, reload) |
412 Precondition Failed | A condition such as If-Match failed: someone else changed it since you loaded it | Show both versions, let the user choose |
422 Unprocessable Content | Well formed, but fails validation | Show each message next to its field |
428 Precondition Required | This write must carry If-Match | A client bug: send the ETag you loaded |
429 Too Many Requests | Slow down | Wait as long as Retry-After says (if sent), then retry; disable the button meanwhile |
500 Internal Server Error | The server crashed | "Something went wrong" with a Retry button and a request id; a crash often repeats, so don't retry a write on your own |
502, 503, 504 | A gateway or upstream failed or timed out | Retry with backoff; 503 may carry Retry-After |
401 and 403 are not the same
The names are confusing ("Unauthorized" really means unauthenticated), so the meanings get swapped. 401 says "I don't know who you are": the fix is to sign in or refresh the token. 403 says "I know who you are, and the answer is no": signing in again changes nothing. An app that signs the user out on 403 loses their work for no reason; an app that loops refreshing tokens on 403 hammers the auth server.
Some APIs answer 404 instead of 403 for private resources, so outsiders cannot even learn that a private repository exists. GitHub does this. The UI should then phrase its not-found screen gently: "This page does not exist, or you don't have access."
400 versus 422 versus 409
These three all mean "your request was wrong" but call for different screens. 400 means the server could not even parse it: broken JSON, a missing required header. Users cannot fix that, so it is a client bug.
422 means the request was understood but the values break a rule: an email without an @, a date in the past. Users can fix it, so show the message next to the field. 409 means the values are fine but clash with the current state: the username is taken, the order was already shipped.
Plenty of APIs use 400 for all three. If yours does, the error body must say which kind it is.
Try it: one form, eight responses
Pick a response in the simulator below, a status code or a network error, and watch what a well-built "Save profile" screen does with it: what it shows, whether it keeps the user's input, and whether it retries.
422 with a problem details body whose errors array points at #/email. The app maps the pointer to the field, shows the message under it, and moves focus there so a screen reader announces it.
Check your understanding · Exercise 4
A user opens a shared document and the API answers 403 Forbidden. The app currently refreshes the access token and retries, forever. What should it do? Pick one.
One error shape for every endpoint: problem details
A status code tells the UI the kind of failure. The body must tell it the details: which field, what message, what the user can do. If every endpoint invents its own body ({ error } here, { message, code } there, { errors: [] } somewhere else), the frontend needs a parser per endpoint. Pick one shape and use it everywhere.
There is a standard one. Problem Details for HTTP APIs (RFC 9457, published in 2023, replacing RFC 7807) defines a JSON object sent with Content-Type: application/problem+json. Spring and ASP.NET Core have built-in support for it, so choosing it is rarely extra work.
type is shortened here; in practice use a full URI such as https://api.example.com/problems/validation.The five standard members
type: a URI that names the kind of problem, such ashttps://api.example.com/problems/validation. It is the stable key your code switches on. It may point to a docs page. If absent, it defaults toabout:blank, meaning "nothing beyond the status code".title: a short human summary of that kind of problem. It should not change from one occurrence to the next, except when translated.status: the HTTP status code, copied into the body for convenience. It must match the real status, and it lets the client recover that status if a proxy rewrote it.detail: a human explanation of this particular occurrence: "Your balance is 30, but the item costs 50."instance: a URI for this occurrence, often a request or trace id that support can look up.
Anything else is an extension member, and this is where APIs put what the UI needs. RFC 9457's own example of several validation errors uses an errors array whose items carry a detail and a pointer, a JSON Pointer (RFC 6901) like #/email saying which field of the request is wrong. That maps directly onto a form.
What the frontend does with it
With one shape, a single request() helper turns every failure into the same error object. Components never parse bodies; they read error.fieldErrors to place messages and error.type to pick a screen.
class ApiError extends Error {
constructor(public status: number, public problem: Problem) {
super(problem.title ?? `HTTP ${status}`);
}
get fieldErrors(): Record<string, string> {
const out: Record<string, string> = {};
for (const e of this.problem.errors ?? []) {
out[e.pointer.replace("#/", "")] = e.detail;
}
return out;
}
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (res.ok) return res.status === 204 ? (undefined as T) : res.json();
const isProblem = res.headers.get("content-type")?.includes("problem+json");
const problem: Problem = isProblem
? await res.json()
: { type: "about:blank", title: res.statusText, status: res.status };
throw new ApiError(res.status, problem);
}
Note the fallback. Not every error comes from your API: a load balancer may answer 502 with an HTML page, and a CDN may answer 503 with plain text. The helper must never assume the body is JSON just because the status is an error.
Who writes the message the user reads?
There are two schools. In the first, the server's detail is shown as is; it must then be written for end users and translated, usually by honoring the request's Accept-Language. In the second, the client maps type (plus a machine code per field, such as too_short) to its own translated copy and treats detail as a fallback. The second keeps the tone of the UI in one place and works better for apps in many languages.
Either way, never show stack traces, SQL, or internal ids in detail: they leak information to attackers and mean nothing to users.
Check your understanding · Exercise 5
Which member of a problem details object should the frontend's code branch on to decide what screen to show? Pick one.
Safe retries: idempotency keys and conditional writes
Here is the hardest moment in any client: the user taps "Pay", the request leaves, and then nothing. After 10 seconds the request times out. Did the payment go through? There are three possibilities, and the client cannot tell them apart: the request never arrived, it arrived and failed, or it arrived, succeeded, and the response was lost on the way back.
For an idempotent request, the answer does not matter: send it again. For POST /payments, retrying in the third case charges the card twice. Not retrying in the first case loses the order. Users also retry by themselves, by double-clicking or pressing the button again after a spinner that seems stuck.
The Idempotency-Key header
The fix is to make the POST idempotent by giving each logical operation a name. The client generates a unique key, for example with crypto.randomUUID(), when the user starts the action. It sends the key as Idempotency-Key: 6f1c... on the first attempt and on every retry.
Stripe made this pattern famous. An IETF working-group draft (draft-ietf-httpapi-idempotency-key-header) describes the same header, but it has not become an RFC, so treat the server behavior below as a widely copied convention that follows the draft, not a finished standard.
- The server receives the request and looks the key up in a store such as Redis or a database table.
- If the key is new, it saves it as "in progress", runs the operation, then saves the response next to the key.
- If the key exists and has a saved response, it returns that response again without doing the work. Stripe replays even a saved error.
- If the key exists but is still in progress (two retries racing), it answers
409 Conflict; the client waits and tries again. - If the key arrives with a different body than the first time, it rejects the request (the draft suggests
422): that is a client bug, not a retry.
201 it would have seen the first time.Keys do not live forever. Stripe may remove them once they are at least 24 hours old, which covers every realistic retry. The important detail on the client is when the key is created: once per user intent (when the checkout screen opens, or on the first tap), never once per attempt. A key made inside the retry loop is a new key every time, which protects nothing.
async function placeOrder(cart: Cart) {
const key = crypto.randomUUID(); // once, before the loop
for (let attempt = 0; attempt < 4; attempt++) {
try {
return await request<Order>("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(cart),
});
} catch (err) {
// 409 here means "same key still in progress"; 500 is not retried, since a crash often repeats
const retryable = !(err instanceof ApiError) || [409, 429, 502, 503, 504].includes(err.status);
if (!retryable || attempt === 3) throw err;
await sleep(2 ** attempt * 500 + Math.random() * 250);
}
}
}
Try the simulator: drop the response on the first attempt and compare a client that retries without a key against one that reuses its key.
2 orders for one click. Every retry looked new to the server, so the card was charged 2 times.
The other retry problem: lost updates
Idempotency stops duplicates, but it does not stop two people from overwriting each other. Ana and Ben both open the same note, and Ana saves a new title. Ben, still looking at the old version, saves a new body with PUT, and Ana's title silently disappears. This is the lost update problem.
HTTP solves it with conditional requests. Each GET returns an ETag, a version tag such as ETag: "v7". When saving, the client sends If-Match: "v7", and if the note is still at v7, the server applies the change and returns the new tag "v8".
If someone saved in between, the server refuses with 412 Precondition Failed, and the UI can show both versions and let the user merge. A server can insist on this by answering 428 Precondition Required (RFC 6585) to writes without If-Match.
Check your understanding · Exercise 6
Where should the client generate the idempotency key for a "Send message" action? Pick one.
Lists: filtering, sorting, sparse fieldsets, and pagination
Most screens are lists: an inbox, search results, a product grid, an admin table. A list endpoint takes its options from the query string, so the URL alone describes the exact data you get. That matters for the frontend twice over: the same URL can be cached, and the same parameters can be mirrored in the page's own URL so a filtered view can be bookmarked and shared.
Filtering
Plain equality filters use the field name: GET /orders?status=shipped&customerId=c_19. For ranges and other operators, pick one convention and keep it: a suffix (createdAfter=2026-10-01), brackets (filter[total][gte]=100, common on JSON:API servers), or a small filter language as Google APIs do (filter=total >= 100). Repeat a key for "any of": status=shipped&status=delivered. Free-text search usually gets its own parameter, q=blue shoes.
Sorting
A widely used convention, from JSON:API, is a comma-separated list with a minus sign for descending: sort=-createdAt,name means newest first, then by name. Always add a unique field (usually id) as the last tie-breaker on the server. Without it, two rows with the same createdAt can swap places between pages, and the user sees one row twice and misses another.
Sparse fieldsets
A list of 50 products does not need each product's 4 KB description. Sparse fieldsets let the client ask for only the fields it renders: fields=id,name,price,thumbnailUrl. Google APIs call this a partial response (fields=), and JSON:API scopes it per type (fields[products]=name,price). On a phone with a slow connection, trimming a 200 KB list response to 20 KB is a visible win.
Pagination: offset or cursor
Never return an unbounded list. Cap the page size on the server (100 is a common maximum; GitHub and Stripe both use it) and let the client ask for less with limit or per_page. Then pick how the client asks for the next page.
Decision How should a list endpoint let the client fetch the next page?
What we pick: Use cursors for feeds, chats, timelines, and infinite scroll, where data changes while the user scrolls. Use offsets for admin tables where users jump to a page number and the data is fairly still. In both cases return hasMore (or a null nextCursor) so the client knows when to stop.
Check your understanding · Exercise 7
An infinite-scroll feed uses ?offset=20&limit=20. While the user reads page 1, three new posts are published. What does page 2 look like? Pick one.
Over-fetching, under-fetching, and designing endpoints for a screen
Resource-shaped endpoints are tidy for the server, but a screen rarely maps to exactly one resource. That mismatch fails in two opposite ways, and every REST frontend runs into both.
- Over-fetching: an endpoint returns far more than the screen needs.
GET /users/7sends 60 fields, including the bio and settings, just to draw an avatar and a name. The cost is bytes, parse time, and memory, which hurt most on low-end phones. - Under-fetching: one endpoint returns too little, so the screen makes more calls. An order page loads the order, then the customer, then each of its 8 products: the classic N+1 pattern (one request for the list plus one per item).
Under-fetching is usually the more expensive of the two, because the calls often depend on each other. You cannot ask for the customer until the order tells you the customer id. Each dependent step costs a full round trip, and HTTP/2 multiplexing does not help, because the requests are not ready to send at the same time.
expand costs the server a few extra milliseconds and saves the user two round trips.How frontends cope
Decision The order page needs the order, its customer, and its products. How should the client get them?
What we pick: For a REST API, start with expand or include on the few relations screens actually need: it fixes most waterfalls with no new infrastructure. Add a BFF when several backends must be combined, when mobile and web need very different shapes, or when you want tokens off the browser. Reach for GraphQL when many teams build many screens over a large, connected data model.
Designing endpoints for a screen
The reliable method is to start from the screen, not the database. Draw the screen, mark each region, and ask three questions of every region: what data does it show, when is it needed (first paint, on scroll, on click), and how fresh must it be. The answers group regions into requests.
- First paint gets one request. Everything visible above the fold should come from a single call (or a few parallel ones, never a chain).
- Below the fold and on demand get their own requests. Comments, recommendations, and the second page load later and must not block the first paint.
- Different freshness, different endpoints. A product's description can be cached for an hour; its stock count for seconds. Splitting them lets each carry its own caching headers.
- Writes return what the screen needs next. A
POST /commentsthat returns the created comment, with its author embedded, lets the UI insert it without a refetch.
Check your understanding · Exercise 8
A team page lists 30 members. The client calls GET /teams/4/members for ids, then GET /users/{id} for each member's name and avatar. What is the best first fix? Pick one.
Caching headers and versioning: an API that is fast and can change
Two HTTP features decide whether a REST API stays fast and stays usable as it grows: caching headers, which let clients and CDNs skip work, and a versioning plan, which lets the API change without breaking apps already in users' hands.
Caching headers on API responses
API responses are cached by the same rules as any HTTP response, so be explicit. Without a Cache-Control header, browsers may apply heuristic caching to a GET response that has a Last-Modified date and serve stale data with no request at all. The common choices for JSON:
| Data | Header | Effect |
|---|---|---|
| Per-user data (my orders, my profile) | Cache-Control: private, no-cache plus ETag | Browser may store it, but must ask the server every time; usually gets a tiny 304 |
| Public, slow-changing data (product catalog, country list) | Cache-Control: public, max-age=60, s-maxage=300 | Browser reuses it for 60 s; the CDN for 5 minutes |
| Fast-changing public data (stock, scores) | Cache-Control: public, max-age=0, s-maxage=5, stale-while-revalidate=30 | The CDN absorbs traffic spikes; data is usually seconds old, at most about 35 s (5 s fresh plus 30 s stale while it refreshes) |
| Secrets (tokens, payment details) | Cache-Control: no-store | Never written to any cache |
Revalidation is the big win for personal data. The first GET /me/orders returns the list with ETag: "a1b2", and the next time, the browser sends If-None-Match: "a1b2" on its own. If nothing changed, the server answers 304 Not Modified with no body: a few hundred bytes instead of 40 KB, and the server can often skip rendering the JSON. Your fetch still sees a 200 with the full cached body.
Cache-Control: private, no-cache and an ETag, a repeat costs one round trip and almost no bytes. Without a validator, every repeat downloads and parses the whole list again.Two traps. First, if a response depends on a request header, say so with Vary: an API that returns different JSON for Accept-Language must send Vary: Accept-Language, or a CDN will serve French to everyone after one French visitor. Second, never mark per-user data public: a shared cache would hand one user's orders to the next. Client libraries such as TanStack Query add their own in-memory cache on top; HTTP caching still helps on reloads, new tabs, and the CDN.
Versioning
Web apps redeploy in minutes, but old copies keep running: a tab left open since last week, a cached service worker, a mobile app that users never update. The API must keep serving the old shape for a while, so the first rule is to avoid breaking changes at all.
Adding a field, an endpoint, or an optional parameter is safe if clients ignore what they do not know (the tolerant reader rule). Renaming or removing a field, changing a type, or making an optional parameter required is breaking.
Decision When a breaking change is unavoidable, where should the version live?
What we pick: For an internal API used only by your web app, prefer additive change plus a /v1 prefix kept in reserve. For a public API or one used by mobile apps you cannot force to update, use URL versions for big generations and dated header versions for small changes. Announce removals with the Deprecation and Sunset response headers (RFC 9745 and RFC 8594) and watch traffic before turning anything off.
Check your understanding · Exercise 9
GET /products/9 returns English or German text depending on Accept-Language, with Cache-Control: public, s-maxage=600. German users complain about English pages. What is missing? Pick one.
Using it in a system design interview
In a frontend system design interview, REST design shows up in the Interface step of RADIO, after you have agreed on requirements, drawn the architecture, and listed the data model's entities. The interviewer wants to see that you can turn a screen into a small, sensible set of endpoints and that you think about what happens when the network misbehaves.
What to write on the board
Spend about 5 to 8 minutes of a 45 to 60 minute interview here. Write each endpoint on one line, with its key parameters and the shape of the response, and only the endpoints this screen needs. For a news feed it might look like this:
GET /feed?cursor=&limit=20 -> { items: Post[], nextCursor }
Post = { id, author: { id, name, avatarUrl }, text,
media[], likeCount, likedByMe, createdAt }
POST /posts Idempotency-Key -> 201 Post
PUT /posts/:id/like -> 204 (idempotent: like twice = liked)
DELETE /posts/:id/like -> 204
GET /posts/:id/comments?cursor= -> { items: Comment[], nextCursor }
Errors: application/problem+json { type, title, status, detail, errors[] }
Notice the small decisions that earn points. The post embeds its author, so the feed never makes N+1 calls, and the feed uses a cursor because new posts arrive constantly. Liking is modeled as PUT and DELETE on a sub-resource rather than a POST /toggleLike, so a double tap or a retry cannot flip it back. Creating a post carries an idempotency key, so a flaky connection cannot publish it twice.
What to say, in order
- "Let me derive the endpoints from the screen." Name the regions and which call feeds each.
- "Lists use cursor pagination because data changes while scrolling."
- "Writes return the created resource so the UI can update optimistically and reconcile."
- "POSTs carry an idempotency key, so the client can retry safely."
- "Errors share one shape, problem details, and the UI maps status classes to behavior: fix input on 4xx, retry on 429, 502, 503, 504, and network errors."
- If time allows: caching headers,
ETagfor concurrent edits, and why REST over GraphQL here (or the reverse).
How deep to go by level
| Level | What a strong answer shows |
|---|---|
| Junior | Correct resources and methods; GET never changes data; a paginated list; handles loading, empty, and error states in the UI. |
| Mid-level | Cursor versus offset with a reason; embeds related data to avoid N+1; a consistent error shape mapped to field errors; retries only idempotent calls. |
| Senior | Idempotency keys and If-Match for concurrency; caching headers per endpoint; versioning and deprecation with old clients in mind; when a BFF or GraphQL beats plain REST. |
| Staff | API as a product across teams: contract-first design with OpenAPI, generated typed clients, usage metrics driving deprecation, rate limits and their UI, and the cost of chatty APIs on low-end devices in emerging markets. |
Think first
The interviewer asks: "Why REST and not GraphQL for this feed?" What is a good answer?
Start from the requirements, not taste. The feed has one main screen with a known shape, so a single GET /feed that embeds authors already avoids over- and under-fetching. REST also keeps plain HTTP caching at the CDN for public data, simple ETag revalidation, and no query-cost controls to build.
Then name when the answer flips: many teams building many screens over a connected graph (profiles, groups, events, comments), or mobile and web needing very different shapes. At that point GraphQL, or a BFF in front of REST services, earns its extra complexity.
Check your understanding · Exercise 10
Which like endpoint design survives a double tap and a network retry without ending in the wrong state? Pick one.
Summary
- Model the API as resources with URLs (plural collections, items by id) and act on them with standard methods; never let a
GETchange anything. - Safe methods can be prefetched and cached; idempotent ones (
GET,PUT,DELETE) can be retried;POSTneeds anIdempotency-Keycreated once per user intent before it can be retried. - Use real status codes and map each class to UI behavior: fix input on 4xx, retry with backoff on 429, 502, 503, 504, and network errors, offer a Retry button on 500, refresh once on 401, never retry 403. Always keep the user's input.
- Return every error in one shape, RFC 9457 problem details, with a stable
typefor code and a per-fielderrorsarray for forms. - Lists take filter, sort,
fields, and paging parameters; use cursors for live feeds, offsets for jump-to-page tables, and always add a unique tie-breaker to the sort. - Fix under-fetching waterfalls with
expandorinclude, then a BFF; count round trips on the critical path, not total requests. - Set
Cache-Controlexplicitly on every API response, useETagfor cheap revalidation andIf-Matchagainst lost updates, and addVarywhen the response depends on a header. - Prefer additive changes; when you must break, version (URL or dated header) and keep old endpoints alive as long as old clients can.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What is the difference between safe and idempotent methods, and why does a frontend care?
A safe method asks the server to change nothing: GET, HEAD, OPTIONS. An idempotent method may change state, but repeating it leaves the same state as doing it once: all safe methods plus PUT and DELETE. POST and PATCH are neither by default.
The frontend cares because these properties decide what may happen automatically. Safe requests can be prefetched and cached. Idempotent requests can be retried after a timeout without asking the user. Non-idempotent writes need an idempotency key before any automatic retry.
Question 2
A checkout request times out. How do you make it safe for the client to retry?
Generate an idempotency key (a UUID) when the user starts checkout, send it in an Idempotency-Key header, and reuse it on every retry. The server stores the key with the first result and returns that same result for repeats, so a lost response never causes a second charge.
Retry only failures that may succeed later (network errors, 502, 503, 504, 429, and 409 for a key still in progress) with exponential backoff and jitter, and disable the button while a request is in flight so the user cannot create a second intent.
Question 3
How should the UI react to 401, 403, 404, 409, 422, 429, and 500?
401: refresh the token once and replay; if that fails, send the user to sign in and keep their draft. 403: explain they lack access and offer a way to request it; never retry or sign out. 404: a not-found view with a way back, or remove the stale item from a list.
409: explain the conflict and offer the fix. 422: show each validation message next to its field. 429: wait for Retry-After, then retry. 500 and other 5xx: a generic message with a Retry button and a request id. In every case, keep the user's input.
Question 4
Offset or cursor pagination for an infinite-scroll feed?
Cursor. New posts arrive while the user scrolls, which shifts offsets and produces duplicates or gaps. A cursor encodes the last item's sort key, so the next page is "items after this one", which stays correct as data changes and stays fast deep into the list.
Offsets are right for admin tables where users jump to page 37 and the data barely moves. Either way, cap the page size and return a nextCursor or hasMore so the client knows when to stop.
Question 5
A product page makes five dependent requests before it can render. How would you fix it?
Measure the critical path first: dependent requests cost one round trip each, so five in a chain on a 300 ms mobile link is 1.5 seconds before data. Then remove the chain: add expand or include so one call returns the product with the related data it shows, and fire independent calls in parallel.
If the data comes from several backend services, put a backend for frontend in front of them: it makes the fan-out calls inside the data center and returns one screen-shaped response. Load below-the-fold data such as reviews in a later, separate request.
Practice it
These design problems lean on rest for frontends.
References
- RFC 9110: HTTP Semantics Methods, safe and idempotent, status codes, conditional requests.
- RFC 9457: Problem Details for HTTP APIs The standard error shape.
- MDN: HTTP request methods
- MDN: HTTP response status codes
- MDN: HTTP conditional requests ETag, If-None-Match, and If-Match against lost updates.
- Stripe API reference: Idempotent requests A production implementation of idempotency keys.
- IETF draft: The Idempotency-Key HTTP Header Field A working-group draft, not yet an RFC.
- JSON:API specification Conventions for include, sparse fieldsets, sorting, and pagination.
- GitHub REST API: Using pagination
Read next
- API contracts, versioning, and errorsThe 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.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- 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.
- 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.
- 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.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.