cf.completefrontend
My progress
FUNDAMENTALS · MODULE 3

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. fetch only rejects on a network failure, never on a status code. If the server answers 200 with success: 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=3 can be prefetched by the browser or replayed by a proxy. Caches and crawlers assume GET changes 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.

Two panels side by side. Before: GET /getNotes?user=7, POST /notes/delete, POST /updateNoteTitle, and an error returned as 200 with success false. Four shapes that need four parsers. After: GET /notes?author=7, DELETE /notes/42, PATCH /notes/42, and an error returned as 422 with a problem+json body. One pattern that one helper handles.Before: a surprise per endpointAfter: one patternGET /getNotes?user=7POST /notes/deletePOST /updateNoteTitle200 { success: false }GET /notes?author=7DELETE /notes/42PATCH /notes/42422 problem+json4 shapes, 4 parsers1 pattern, 1 helper
Same notes app, same four operations. On the right, the URL names the thing, the method names the action, and the status code names the outcome, so one 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:

RequestMeansTypical success
GET /notesList notes (filtered, sorted, paged by query parameters)200 with an array and paging info
POST /notesCreate a note; the server picks the id201 with the new note and a Location header
GET /notes/42Read one note200 with the note
PATCH /notes/42Change some fields200 with the updated note
PUT /notes/42Replace the whole note200 or 204 (201 if it created the note)
DELETE /notes/42Remove it204 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.

A tree of resource URLs. At the top, the API root at /v1. Below it, three collections: /users, /notes, and /comments. Below each, one item: /users/7, /notes/42, and /comments/9. Below /notes/42, a nested collection /notes/42/comments, one level deep. Comment 9 is reachable both as /comments/9 and through the nested list.API root/v1/userscollection/users/7item/notescollection/notes/42item/commentscollection/comments/9item/notes/42/commentsnested: one levelsame comment, also/comments/9
Collections are plural nouns, items add an id, and nesting stops at one level. A comment with its own id also gets a top-level home, so reading it never needs the note id.

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/42 with { "status": "published" }. Best when the action really is a state change and the server validates the transition.
  • Create a sub-resource. POST /invoices/9/sends creates 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) or POST /posts/42/publish. Clear and common; just keep them rare and always on POST.

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, and OPTIONS are 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 PUT and DELETE. POST and PATCH are 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.

A matrix of six methods against four properties. GET: safe, idempotent, cacheable, retried automatically. HEAD: the same as GET. POST: not safe, not idempotent, rarely cacheable, retried only with an idempotency key. PUT: not safe, idempotent, not cacheable, retried automatically. PATCH: not safe, not idempotent, not cacheable, retried only with a key. DELETE: not safe, idempotent, not cacheable, retried automatically.safeidempotentcacheableauto-retryGETyesyesyesyesHEADyesyesyesyesPOSTnonorarelywith keyPUTnoyesnoyesPATCHnononowith keyDELETEnoyesnoyeslast column: what a client may do on its own
Idempotent is the column that decides retries. 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.

http
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" }]
The same title change three ways. The PUT must send every field, or the missing ones are wiped.

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.

A flowchart for a response arriving. Is it 2xx? Yes: render it and update the cache; for a 201, use the new item. No: is it 401? Yes: refresh the token once and replay the request; if that fails, sign in. No: is it another 4xx? Yes: explain the problem and keep the user's input, never retry automatically. No: it is a 5xx, a 429, or a network failure: retry with backoff; for a 500, or a write that is not safe to repeat, show a Retry button instead.a response arrives2xx?yesRender, update cache201: use the new itemno401?yesRefresh once, replayfails again: sign innoother 4xx?yesExplain, keep inputnever auto-retryno5xx, 429, offlineRetry with backoff500 or unsafe write: Retry button
The first digit decides most of it. A 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

CodeMeansWhat the UI does
200 OKHere is the resultRender it
201 CreatedMade it; Location says whereUse the returned item, navigate or insert it in the list
202 AcceptedQueued, not done yetShow "Processing" and poll the status URL it returns
204 No ContentDone, nothing to returnClose the dialog, update the cache from what you sent
304 Not ModifiedYour cached copy is still goodNothing: when the browser revalidated on its own, fetch sees a 200 with the cached body
400 Bad RequestMalformed requestA bug in the client: generic message, log it
401 UnauthorizedNot signed in, or the token expiredRefresh the token once and replay; if that fails, go to sign in and keep the draft
403 ForbiddenSigned in, but not allowedExplain and offer "request access"; never retry, never sign out
404 Not FoundNo such resourceA not-found screen with a way back; for a list item, remove it
409 ConflictClashes with current state (duplicate name, wrong state)Explain the clash, offer the fix (rename, reload)
412 Precondition FailedA condition such as If-Match failed: someone else changed it since you loaded itShow both versions, let the user choose
422 Unprocessable ContentWell formed, but fails validationShow each message next to its field
428 Precondition RequiredThis write must carry If-MatchA client bug: send the ETag you loaded
429 Too Many RequestsSlow downWait as long as Retry-After says (if sent), then retry; disable the button meanwhile
500 Internal Server ErrorThe 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, 504A gateway or upstream failed or timed outRetry 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.

Response:
A Save profile form after a 422 response. Banner: Fix 1 field. Under the email field: Enter an email like ana@site.com. Button: Save. Keeps input: yes. Retries: no. Focus moves to: email field.Save profileFix 1 field.Usernameana_kEmailana@siteEnter an email like ana@site.comSavekeeps inputyesretriesnofocus moves toemail field
The same form, the same input, a different response. Whatever the outcome, the values the user typed stay in the fields.

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.

An error response, one line per part, with notes. Status line HTTP/1.1 422 Unprocessable Content: the class says fix the input. Content-Type application/problem+json: parse it as a problem. type /problems/validation: the stable id code switches on. title Invalid input: the same every time. status 422: a copy of the status. detail 2 fields are invalid: this occurrence, for people. instance /requests/8f2c: look it up in the logs. errors, an array with pointer #/email: an extension member with one entry per field.HTTP/1.1 422 Unprocessable Contentclass: fix the inputContent-Type: application/problem+jsonparse it as a problem"type": "/problems/validation"stable id for code"title": "Invalid input"same every time"status": 422copy of the status"detail": "2 fields are invalid"this case, for people"instance": "/requests/8f2c"look it up in logs"errors": [{ "pointer": "#/email" }]extension: per field
A status line, a header, and the five standard members, plus one extension for forms. The 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 as https://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 to about: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.

ts
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);
}
One helper for every endpoint: check the status, parse the problem, keep the request id.

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.

  1. The server receives the request and looks the key up in a store such as Redis or a database table.
  2. If the key is new, it saves it as "in progress", runs the operation, then saves the response next to the key.
  3. If the key exists and has a saved response, it returns that response again without doing the work. Stripe replays even a saved error.
  4. If the key exists but is still in progress (two retries racing), it answers 409 Conflict; the client waits and tries again.
  5. 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.
A sequence between the browser, the API, and a key store. The browser sends POST /orders with key k-7f3c. The API sees a new key, runs the order, and stores the 201 response under the key. The API answers 201 order 1, but the response is lost on the way. The browser retries with the same key. The API asks the store whether the key is known. The store answers yes, with the stored 201 for order 1. The API replays 201 order 1 to the browser. One order was created.BrowserAPIKey storePOST /orders, key k-7f3cnew key: run, store 201201 order #1: lostretry, same key k-7f3ckey k-7f3c known?yes: stored 201 #1201 order #1, replayed
The retry reaches the server, but the work does not run twice. The server answers from what it stored under the key, so the client gets the same 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.

ts
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);
    }
  }
}
One key per intent, reused across every attempt; retry only failures that might succeed later.

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.

Client:
Responses lost:
A checkout with 2 attempts. Attempt 1: sends no idempotency key; the server makes new order #1; the client sees lost: timeout. Attempt 2: sends no idempotency key; the server makes new order #2; the client sees 201 order #2. Orders created: 2.attemptheader sentserver doesclient seesPOST #1(no key)new order #1lost: timeoutPOST #2(no key)new order #2201 order #2orders created: 2, charged 2x
The server can only spot a retry if the retry carries the same key as the first attempt.

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?

Offset and limit

GET /orders?offset=40&limit=20, or page=3&perPage=20.

The response often includes a total count.

Pros

  • Simple to build and to understand.
  • Jump to any page: perfect for numbered pages in an admin table.

Cons

  • Large offsets are slow: the database still walks past the skipped rows.
  • Rows shift when items are added or removed, so infinite scroll shows duplicates or skips.
  • An exact total can be expensive on big tables.

Cursor (keyset)

The response returns an opaque nextCursor; the client sends GET /orders?cursor=eyJpZCI6...&limit=20.

The cursor encodes the last row's sort key, so the server asks for rows after it.

Pros

  • Fast at any depth with the right index.
  • Stable while new rows arrive: no duplicates in a feed.

Cons

  • No jumping to page 37.
  • Cursors are tied to the sort order; changing the sort starts over.

Link header

Either style above, but the server sends ready-made URLs: Link: <...page=4>; rel="next" (RFC 8288). GitHub does this.

Pros

  • The client never builds page URLs, so the server can change the scheme freely.

Cons

  • Headers are less visible in tools, and cross-origin code must be allowed to read them with Access-Control-Expose-Headers.

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.

A feed paged four posts at a time, newest first. Page 1 shows posts 12, 11, 10, and 9. Then posts 15, 14, and 13 arrive at the top, so the list becomes 15 down to 5. Page 2 by offset 4 now returns 11, 10, 9, and 8: three posts the user already saw. Page 2 by a cursor after post 9 returns 8, 7, 6, and 5: exactly the next posts.page 1, shown to the user1211109then 3 new posts arrive at the top15141312111098765page 2 with offset=4&limit=4111098page 2 with a cursor after post 98765offset 4after 9
An offset counts positions, and positions move when rows are added. A cursor names the last row seen, so the next page starts after it no matter what arrived above.

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/7 sends 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.

A timeline on a mobile connection with a 300 millisecond round trip. Chained: GET /orders/91 from 0 to 300 ms, then GET /customers/c_19 from 300 to 600 ms, then GET /products from 600 to 900 ms, because each needs an id from the one before; the page paints at 900 ms. With expand: one call, GET /orders/91?expand=customer,items, takes 340 ms, slightly longer on the server, and the page paints at 340 ms.chained: each call needs an id from the lastorderGET /orders/91customerGET /customers/c_19productsGET /products?ids=paint at 900 msone call with expandall threeGET /orders/91?expand=customer,itemspaint at 340 ms0 ms300 ms600 ms900 ms1200 ms
The chained version is not slow because of bytes; it is slow because each call waits for the last one. Embedding with 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?

Includes / expand

The resource endpoint accepts a parameter naming related data to embed: GET /orders/91?expand=customer,items.product.

Stripe uses expand[]; JSON:API uses include=customer.

Pros

  • One round trip, still a general-purpose REST API.
  • Each client asks only for what it shows.

Cons

  • Deep expansions can be slow; servers cap the depth (Stripe allows 4 levels).
  • Harder to cache: many URL variants.

Backend for frontend (BFF)

A thin server owned by the frontend team, such as a Next.js route handler, calls the internal APIs over a fast data-center network and returns one response shaped for the screen: GET /bff/order-page/91.

Pros

  • The slow mobile link carries one request; the fan-out happens server-side, where a round trip takes about a millisecond.
  • Tokens and internal URLs stay on the server.

Cons

  • One more service to run and monitor.
  • Screen-shaped endpoints multiply and drift if nobody prunes them.

GraphQL

The client sends a query naming exactly the fields and relations the screen needs; one endpoint resolves it.

Pros

  • Solves both over- and under-fetching by design.
  • Strong typing and tooling across many screens.

Cons

  • Loses plain HTTP caching (queries are usually POST).
  • Needs care with expensive queries and N+1 problems in resolvers.

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.

  1. First paint gets one request. Everything visible above the fold should come from a single call (or a few parallel ones, never a chain).
  2. 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.
  3. 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.
  4. Writes return what the screen needs next. A POST /comments that returns the created comment, with its author embedded, lets the UI insert it without a refetch.
A wireframe of an order page with four regions, each linked to a request. The order summary with the customer, and the list of three items, both come from one call: GET /orders/91 with expand=customer,items. The shipment status comes from GET /shipments/s_4, cached for only 10 seconds. The reviews below the fold come later from GET /products/p_1/reviews with a cursor, loaded on scroll.Order pageorder #91, customer3 items with photosshipment statusreviews, below foldfirst paint: 1 requestGET /orders/91?expand=customer,itemsGET /shipments/s_4max-age=10: fresh dataGET .../reviewson scroll, with a cursor
Regions on screen at first paint share one request; data with its own freshness gets its own endpoint; data below the fold waits. Three requests, but only one on the critical path.

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:

DataHeaderEffect
Per-user data (my orders, my profile)Cache-Control: private, no-cache plus ETagBrowser 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=300Browser 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=30The 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-storeNever 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.

A bar chart of bytes for GET /me/orders, to scale. First visit: a 200 response with an ETag, 40 KB. Repeat visit when nothing changed: a 304 Not Modified, about 0.3 KB of headers. Repeat visit when the server sends no ETag: a full 200 again, 40 KB.GET /me/orders, bytes to scalefirst visit: 200 with ETag40 KBrepeat, unchanged: 3040.3 KB, headers onlyrepeat, no ETag: 200 again40 KB
With 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?

In the URL path

/v1/orders and /v2/orders run side by side.

Pros

  • Obvious in logs, docs, and the browser.
  • Trivial to route and to cache: different URLs.

Cons

  • Version bumps are big and rare, so changes pile up.
  • Clients move every endpoint at once.

In a header

The client sends a version header. GitHub uses X-GitHub-Api-Version: 2022-11-28; Stripe uses a dated Stripe-Version.

Pros

  • Small, dated versions; each account or client can move when ready.
  • URLs stay stable.

Cons

  • Invisible in a plain URL; easy to forget in a quick curl.
  • Shared caches need Vary on that header.

No versions, only additive change

Never break; add new fields, deprecate old ones, remove them only when traffic reaches zero.

Pros

  • Simplest for an internal API whose only client is your own web app.

Cons

  • Needs discipline and usage metrics; hard for a public API with unknown clients.

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:

http
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[] }
A feed's interface in six lines: list with cursor, create with a key, react idempotently.

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

  1. "Let me derive the endpoints from the screen." Name the regions and which call feeds each.
  2. "Lists use cursor pagination because data changes while scrolling."
  3. "Writes return the created resource so the UI can update optimistically and reconcile."
  4. "POSTs carry an idempotency key, so the client can retry safely."
  5. "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."
  6. If time allows: caching headers, ETag for concurrent edits, and why REST over GraphQL here (or the reverse).

How deep to go by level

LevelWhat a strong answer shows
JuniorCorrect resources and methods; GET never changes data; a paginated list; handles loading, empty, and error states in the UI.
Mid-levelCursor versus offset with a reason; embeds related data to avoid N+1; a consistent error shape mapped to field errors; retries only idempotent calls.
SeniorIdempotency 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.
StaffAPI 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?

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 GET change anything.
  • Safe methods can be prefetched and cached; idempotent ones (GET, PUT, DELETE) can be retried; POST needs an Idempotency-Key created 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 type for code and a per-field errors array 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 expand or include, then a BFF; count round trips on the critical path, not total requests.
  • Set Cache-Control explicitly on every API response, use ETag for cheap revalidation and If-Match against lost updates, and add Vary when 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?

Question 2

A checkout request times out. How do you make it safe for the client to retry?

Question 3

How should the UI react to 401, 403, 404, 409, 422, 429, and 500?

Question 4

Offset or cursor pagination for an infinite-scroll feed?

Question 5

A product page makes five dependent requests before it can render. How would you fix it?

Practice it

These design problems lean on rest for frontends.

References

Read next