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
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:
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.
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
🚨 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
🚨 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.
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.
Filtering, sorting, pagination via query parameters:
GET /orders?status=shipped&sort=-createdAt&page[size]=20&page[after]=xyz
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 | 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
| 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 |
304
without a body, and (notably) 304s don’t count against your rate limit — a direct incentive to
cache correctly.200 with an error body, 500 for validation./getUser).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.