system-design

REST Done Properly

The most common API style, the most misunderstood one, and the one you’ll design in most interviews. Here’s what it actually means and how to do it well.

Prerequisites: API Design Principles, HTTP & TLS Time to read: ~20 minutes


What REST actually is

REST (Representational State Transfer, from Roy Fielding’s 2000 dissertation) is an architectural style, not a protocol. 🚨 Most “REST APIs” are actually “HTTP JSON APIs” — they use HTTP verbs and JSON but don’t follow all of REST’s constraints, and that’s completely fine. The industry has settled on a pragmatic subset, and pretending otherwise is pedantry.

The constraints that actually matter in practice:

  1. Client–server — separation of concerns.
  2. Stateless — 🚨 the important one. Each request contains everything needed to process it; the server holds no client session state between requests. This is what makes horizontal scaling trivial — any server can serve any request. → Scalability
  3. Cacheable — responses declare their cacheability. → Caching
  4. Uniform interface — resources, standard methods, self-descriptive messages.
  5. Layered system — proxies, gateways, and caches can sit between client and server transparently.

The constraint most APIs skip is HATEOAS (hypermedia — responses containing links to related actions). It’s part of “true” REST and almost nobody implements it fully. More on that below.


Resources and representations

The core idea: everything is a resource, identified by a URL, and you exchange representations of it (usually JSON).

Resource:          a user
Identifier:        /users/42
Representation:    {"id": 42, "name": "Bilal", ...}   (could also be XML, etc.)

The methods map to operations (from HTTP):

Method Operation Safe Idempotent
GET /users/42 Read
POST /users Create
PUT /users/42 Replace entirely
PATCH /users/42 Update partially usually ❌
DELETE /users/42 Remove

🚨 PUT vs PATCH is a common interview question. PUT replaces the entire resource — you send the whole thing, and omitted fields are removed. PATCH updates part of it — you send only the fields to change. Using PUT when you mean PATCH accidentally wipes fields the client didn’t send.

The safe/idempotent properties aren’t trivia — they determine what clients, proxies, and load balancers may do automatically. A crawler may issue any GET. A load balancer may retry a PUT on another server but not a POST. → The 8 Fallacies


Status codes, used correctly

🚨 Return the right status code — clients, proxies, caches, and monitoring all key off it.

Code Meaning Use for
200 OK Success with body Reads, updates
201 Created Resource created POST; include a Location header pointing to it
202 Accepted Queued, not done Async processing; poll or webhook for the result
204 No Content Success, no body DELETE, or a PUT with nothing to return
301/308 Moved permanently Cached hard; use carefully
304 Not Modified Cached copy valid Response to conditional GET — saves the whole body
400 Bad Request Malformed Validation failure
401 Unauthorized Not authenticated Missing/invalid credentials
403 Forbidden Authenticated, not allowed Wrong permissions
404 Not Found No such resource Also hides existence from unauthorized users
409 Conflict State conflict Version mismatch, duplicate
422 Unprocessable Semantically invalid Business rule violation
429 Too Many Requests Rate limited Include Retry-After
500 / 502 / 503 / 504 Server errors We broke / upstream broke / overloaded / upstream timeout

🚨 The most important rule: 4xx means the client’s fault (don’t retry), 5xx means the server’s fault (safe to retry). Returning 500 for a validation error makes clients retry forever a request that can never succeed. Returning 200 with {"error": ...} breaks every proxy, cache, and monitoring tool that reads the status line. → Error Handling


Statelessness in practice

🚨 The most consequential REST constraint, and the one that governs whether you can scale.

Stateless means the server keeps no per-client session between requests. Each request carries its own authentication (a token) and everything else it needs.

❌ Stateful:  server stores "user 42 is logged in, on step 3 of checkout" in memory
              → the user must hit the same server every time; that server dying loses their state

✅ Stateless: every request carries a token identifying the user and their permissions
              → any server serves any request; a server dying affects only in-flight requests

Where the state actually goes: to a shared store the server reads (a session in Redis), or into a self-contained token the client carries (a JWT). Either way, the app servers themselves hold nothing.

This is exactly the stateless-vs-stateful property that makes horizontal scaling work, and it’s why REST APIs scale so easily — the constraint is doing real work.


HATEOAS, honestly

The most theoretically-pure part of REST: responses include links to the actions available next, so clients navigate by following links rather than hard-coding URLs.

{
  "id": 991, "status": "pending", "total": 4500,
  "_links": {
    "self":    {"href": "/orders/991"},
    "cancel":  {"href": "/orders/991/cancel", "method": "POST"},
    "pay":     {"href": "/orders/991/payment", "method": "POST"}
  }
}

⚖️ In theory: clients discover capabilities dynamically and don’t break when URLs change. In practice: almost nobody implements it fully, most clients hard-code URLs anyway, and the added complexity rarely pays off.

🎙️ The honest interview position: “Full HATEOAS is rarely worth it — most clients hard-code URLs regardless. But the useful subset is including state-dependent links, like showing a ‘cancel’ link only when an order is actually cancellable, so the client doesn’t have to encode that business logic itself.” Knowing what it is and why it’s usually skipped is the signal; claiming you’d always implement it is not.


Practical REST design

Filtering, sorting, pagination via query parameters:

GET /orders?status=shipped&sort=-createdAt&page[size]=20&page[after]=xyz

Pagination

Field selection to reduce over-fetching (poor man’s GraphQL):

GET /users/42?fields=id,name,email

Expansion to reduce under-fetching (avoid N+1 round trips):

GET /orders/991?expand=customer,items

Nesting for relationships, but shallow:

GET /users/42/orders          ✅ one level
GET /users/42/orders/991/items/5/reviews    ❌ too deep — expose /items/5 directly

Bulk operations for efficiency:

POST /users/batch  {"users": [...]}

🚨 Design the partial-failure response carefully — if 8 of 10 succeed, the client needs to know which two failed and why, not a blanket 400.

Conditional requests for caching and concurrency:

GET  /users/42            → ETag: "a3f5"
GET  /users/42            If-None-Match: "a3f5"  → 304 (no body transferred)
PUT  /users/42            If-Match: "a3f5"       → 412 if someone else changed it (optimistic lock)

🚨 If-Match gives you optimistic concurrency for free — the update fails if the resource changed since the client read it.


REST vs the alternatives

  REST GraphQL gRPC
Transport HTTP/1.1+ HTTP HTTP/2
Format JSON (text) JSON Protobuf (binary)
Fetching Fixed per endpoint Client-specified Fixed per method
Over/under-fetching Common Solved Common
Caching ✅ HTTP-native Hard Hard
Browser support ✅ Native ❌ (needs gRPC-Web)
Discoverability OpenAPI Introspection Proto files
Best for Public APIs, CRUD Flexible clients Internal RPC

🎙️ “REST for the public API — it’s cacheable, debuggable with curl, and every client speaks it. I’d add field selection and expansion to mitigate over- and under-fetching, and reach for GraphQL only if clients needed genuinely flexible shapes.”GraphQL, gRPC


⚖️ Trade-offs

Choice Gain Cost
REST/JSON Universal, cacheable, debuggable, stateless Over/under-fetching; verbose; multiple round trips
Statelessness Trivial horizontal scaling State moves to a shared store or token
HTTP caching Free performance at every layer Cache invalidation complexity
Field selection / expansion Mitigates fetching problems More endpoint complexity
Full HATEOAS Decoupled clients Complexity clients rarely use
Conditional requests Cheap caching + optimistic concurrency Server must compute/store ETags

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Design a full REST API. For a blog: posts, comments, authors, tags. Write out every endpoint, method, status code, and the request/response shape. Then handle the hard cases: publish a draft, search across posts, a post’s comment count, bulk-delete. This is exactly what an interview asks for, and doing it cold is the practice.

2. Use conditional requests. Build an endpoint that returns an ETag. Fetch it, then fetch again with If-None-Match and confirm you get a 304 with no body. Then implement If-Match on the update and confirm a stale update gets a 412. You’ve now got caching and optimistic concurrency from HTTP alone.

3. Break statefulness, then fix it. Store login state in server memory, run two instances behind a round-robin load balancer, and watch users get randomly logged out. Move the session to Redis and watch it work. This is the statelessness constraint, made physical.

4. Get the status codes wrong on purpose. Return 200 for a validation failure and point a monitoring tool at it. Watch your error rate read as 0% while the API is broken. Then fix the codes.


Check yourself

1. Why is statelessness the most consequential REST constraint? Because it's what makes horizontal scaling trivial. If the server keeps no per-client state between requests — each request carries its own authentication and context — then *any* server instance can handle *any* request. You can add servers freely, remove them freely, and a server crash affects only its in-flight requests rather than losing user sessions. The state that would otherwise live in server memory moves either to a shared store the server reads per request (a session in Redis) or into a self-contained token the client carries (a JWT). Without statelessness you need sticky sessions, which break even load distribution, lose state on failure, and disrupt deploys.
2. What's the difference between PUT and PATCH? `PUT` replaces the resource *entirely*: you send the complete new representation, and any field you omit is removed or reset — so it's idempotent (sending the same full representation twice gives the same result). `PATCH` updates *partially*: you send only the fields you want to change, leaving the rest untouched, and it's generally not idempotent (a patch like "increment by 5" isn't). The common bug is using `PUT` when you mean `PATCH` — sending only the changed fields with `PUT` semantics wipes every field you didn't include, silently deleting data the client didn't intend to touch.
3. Why does returning the correct HTTP status code matter beyond correctness? Because the entire HTTP ecosystem reads the status line and acts on it automatically. Load balancers and clients retry 5xx and 429 but not 4xx; caches store or skip based on the code; monitoring computes error rates from it; circuit breakers trip on it. Returning `500` for a validation error makes well-behaved clients retry forever a request that can never succeed, and pollutes your error metrics. Returning `200` with `{"success": false}` in the body means every proxy, cache, and monitoring tool sees a successful request — so a completely broken API reads as 100% healthy, and clients that only check the status code silently proceed on failed operations.
4. What is HATEOAS, and why does almost nobody implement it fully? HATEOAS (Hypermedia as the Engine of Application State) means responses include links to the actions available next, so clients navigate the API by following links rather than hard-coding URLs — in theory letting the server change URLs and capabilities without breaking clients, and letting clients discover what they can do dynamically. Almost nobody implements it fully because in practice clients hard-code URLs regardless (it's simpler), the tooling and client-side benefit rarely materialize, and the added response complexity isn't repaid. The genuinely useful subset that people *do* adopt is state-dependent links — showing a "cancel" link only when an order is actually cancellable — which moves that business rule to the server instead of duplicating it in every client.
5. What are REST's main weaknesses and how do you mitigate them? Over-fetching (an endpoint returns more than the client needs) and under-fetching (a screen needs several resources, forcing multiple round trips — the network N+1 problem). Both stem from endpoints having a fixed response shape. Mitigations within REST: **field selection** (`?fields=id,name`) to return only wanted fields; **expansion/embedding** (`?expand=customer,items`) to include related resources in one response and avoid extra round trips; and a **BFF** or aggregation layer to shape responses per client. If clients genuinely need arbitrary, frequently-changing shapes, that's the signal to consider GraphQL, which solves both problems structurally by letting clients specify exactly what they want.

Further reading