REST principles
REST is an architectural style, not a protocol or a spec: the constraints Roy Fielding set out in chapter 5 of his 2000 dissertation to explain why the web scales. Each constraint induces a property, and together they buy one thing worth remembering: a component that knows nothing about your application - a CDN, a proxy, a gateway, a client library - can still act correctly on your messages.
“RESTful” in industry means less: resources, HTTP methods and status codes, usually without hypermedia. That is a defensible place to stop. Not knowing which constraints you kept, and what the dropped ones would have bought, is not.
The six constraints
Fielding adds them one at a time, in this order, and names what each buys and what it costs. The costs are what separates a senior answer from a list.
Client-server
Separate the user interface from data storage.
- Buys: both sides evolve independently, so one API serves web, mobile and partners on their own release cycles.
- Violated: an API that returns layout - which widgets, in what order - has taken on the UI’s concerns, so every redesign becomes an API release. A client that needs its own shape gets a backend-for-frontend its team owns.
Stateless
“Each request from client to server must contain all of the information necessary to understand the request, and cannot take advantage of any stored context on the server. Session state is therefore kept entirely on the client.”
- Buys: visibility (one request can be understood alone), reliability (partial failures are easier to recover from) and scalability (no per-client memory, so any node serves any request).
- Costs: every request repeats its context: token, ids, filters.
- Violated:
POST /select-accountthenGET /transactionsworks until a retry or a deploy lands the second call on a node that never saw the first.
It concerns session state - what this client did before - not the database, which is full of state and fine. Depth, and why revocation forces a lookup anyway: Stateful and stateless.
Cacheable
Every response is labelled, implicitly or explicitly, as cacheable or not.
- Buys: some interactions disappear entirely: lower latency, less load.
- Costs: a cache can serve data staler than the origin’s.
- Violated: an unlabelled response can still be cached by heuristic -
typically for 10% of its age since
Last-Modified(RFC 9111). Per-user data needsprivate: by default a shared cache refuses to reuse a response only when the request carriedAuthorization- a cookie does not count - andpublicors-maxagelifts even that. A body that varies byAccept-LanguageorAccept-EncodingneedsVarynaming the header, or one client’s variant is served to another.
Uniform interface
One generic interface between all components; Fielding calls it “the central feature that distinguishes the REST architectural style”. It has four parts:
- Identification of resources. Anything nameable is a resource with a URI,
and a resource is a concept, not a row - Fielding’s example is “today’s
weather in Los Angeles”.
/users/42,/meand/search?q=kettleall count. - Manipulation through representations. Clients GET a representation,
change it and send it back; the server decides what that means for state.
JSON is convention, not a rule:
Acceptasks,Content-Typelabels. - Self-descriptive messages. Method, URI, media type, cache directives and status code carry everything needed to process a message - no out-of-band rules like “on this endpoint a 200 can be a failure”.
- Hypermedia as the engine of application state (HATEOAS). Responses link to the next valid transitions, so clients follow links rather than build URLs. The least implemented part - see Richardson Maturity Model and HATEOAS.
- Buys: generic components. A cache or client library works with every API without knowing any of them, and implementations evolve behind it.
- Costs: efficiency. Data moves “in a standardized form rather than one which is specific to an application’s needs” - the gap GraphQL and gRPC fill.
- Violated: everything tunnelled through
POST /apiwith the action in the body, which blinds every intermediary (next section).
The methods belong to the interface: GET is safe, PUT and DELETE idempotent, POST neither, and PATCH idempotent only if its body makes it so. The grid is in HTTP semantics and caching, PUT versus PATCH in PUT vs PATCH: which writes are safe to retry.
Layered system
Each component sees only the layer it talks to; a client cannot tell the origin from a CDN, a gateway or a load balancer.
- Buys: intermediaries insertable at will: caches, load balancers, WAFs, adapters in front of legacy systems.
- Costs: latency per hop, which shared caching can repay.
- Violated: state bound to a TCP connection. NTLM authenticates the
connection, not the request, so every proxy must pin a client to one
upstream connection - nginx’s commercial
ntlmdirective exists for that.
Code on demand (optional)
Servers may extend clients by sending code: applets in 2000, JavaScript now. It buys simpler clients and costs visibility - no intermediary can know what the code will do - which is why it is the one optional constraint.
The mechanism: intermediaries that know nothing about you
Fielding’s summary: “REST enables intermediate processing by constraining messages to be self-descriptive: interaction is stateless between requests, standard methods and media types are used to indicate semantics and exchange information, and responses explicitly indicate cacheability.” Three intermediaries, each deciding from the message alone, never from your JSON:
CDN GET /products/42, answered max-age=60
-> served from the edge for 60 s, keyed by URL
nginx PUT /carts/7, upstream connection reset
-> retried on the next upstream: idempotent
POST /orders, same failure
-> 502, not retried: not idempotent
gateway DELETE /users/42 with a read-only API key
-> 403 before your code runsnginx has behaved like that since 1.9.13: proxy_next_upstream never passes a
POST, LOCK or PATCH to another server once sent, unless you add
non_idempotent. urllib3’s Retry agrees; its default allowed_methods are
DELETE, GET, HEAD, OPTIONS, PUT and TRACE.
Caches go further. RFC 9111 makes a cache invalidate a URI when an unsafe
method on it succeeds, so PUT /products/42 evicts the cached
GET /products/42 - because the write and the read name the same resource.
Only caches on the PUT’s path hear about it; every other copy lives until it
goes stale.
Rewrite it all as POST /api {"action": ...} and nothing is cacheable,
retryable or invalidated, and the gateway must parse your body to authorize.
The cache constraint in practice is Cache-Control plus an ETag: a CDN
answers from its copy while it is fresh, then revalidates with
If-None-Match and pays for a body only when the resource changed. The
mechanics - validators, 304, what may be stored - are in
HTTP semantics and caching.
Resource modelling and URL design
Resources are nouns; the methods are the verbs.
- Plural collections, items by id:
/orders,/orders/42. Ids are opaque and stable - never an email or a name that can change. - Model what clients need, not your tables: a join (
/users/42/feed), a computed view (/orders/42/receipt), a process (/jobs/7f3a). - Filters, sorting and paging go in the query string (REST Versioning and Pagination); a new path segment means a new resource.
- Nest one level, for ownership. Once a child has its own identity, give it a top-level URI.
| Before | After | Why |
|---|---|---|
POST /getUser |
GET /users/42 |
reads are GET: cacheable, retryable |
POST /createOrder |
POST /orders |
create in the collection: 201 + Location |
GET /deleteOrder?id=42 |
DELETE /orders/42 |
prefetchers follow GET links |
GET /users/42/openOrders |
GET /users/42/orders?status=open |
a filter, not a new resource |
GET /users/42/orders/7/items/3 |
GET /order-items/3 |
one item should not need two parent ids |
POST /selectAccount |
GET /accounts/9/balance |
the next call must not depend on the last |
Actions that are not CRUD
Cancel, capture, merge. Four conventions, all in production use:
| Convention | Example |
|---|---|
| action as a resource | POST /orders/42/cancellation |
| custom method (AIP-136) | POST /v1/orders/42:cancel |
| verb sub-path (Stripe) | POST /v1/payment_intents/pi_123/capture |
| idempotent verb (GitHub) | PUT /repos/acme/api/pulls/7/merge |
The first is the most RESTful: the cancellation is a thing with its own id,
reason, author and time, and GET /orders/42/cancellation answers “who
cancelled this, and why” without an audit-log dig. The other three are
RPC-shaped and honest about it; AIP-136 itself says to prefer standard methods
where they fit.
GitHub’s PUT is the instructive one: merging twice leaves the same state as
merging once, so the method alone tells any retry policy that a resend is
harmless. The first row can borrow that: an order has at most one
cancellation, so PUT /orders/42/cancellation is idempotent too.
Avoid PATCH /orders/42 {"status": "cancelled"} when cancelling refunds money
or sends email: a field write that secretly runs a workflow hides the
transition from logs, permissions and reviewers.
Status codes the interface leans on
| Code | Means | Carries |
|---|---|---|
| 201 | created in a collection | Location of the new resource |
| 202 | accepted, not done | Location of a status resource |
| 204 | done, nothing to return | no body |
| 303 | the answer lives elsewhere | Location of the result |
| 304 | your copy is current | ETag, no body |
| 405 | resource exists, method does not | Allow - a MUST in RFC 9110 |
| 406, 415 | no acceptable representation / cannot read yours | Accept on a 415 |
| 409, 412 | conflicts with state / stale If-Match |
what conflicted; client refetches |
| 422 | well-formed but invalid | an RFC 9457 problem body |
400 versus 422, 401 versus 403 and the Retry-After codes are in
Status codes. One error body everywhere:
application/problem+json, RFC 9457, which obsoleted RFC 7807 in 2023.
GET must be safe
RFC 9110 defines safe as “the client does not request, and does not expect, any
state change on the origin server”, so spiders and prefetchers can work
“without fear of causing harm”; a URI that selects an action, like
page?do=delete, MUST refuse it on a safe method. Who sends the GETs nobody
clicked:
- Prefetchers. In May 2005 Google Web Accelerator prefetched every link on
a page, “delete” links included and JavaScript confirmations skipped, and
37signals’ Backpack users watched their pages disappear. Today a site’s
Speculation Rules make Chrome prefetch links on hover or sooner, sent with
Sec-Purpose: prefetch. - Mail scanners. Microsoft Defender Safe Links and similar scanners fetch links in mail to check them, so one-time sign-in links arrive already used. RFC 8058 exists because anti-spam software fetched unsubscribe URLs “without any action by the user”.
- Unfurlers. Slack (
Slackbot-LinkExpanding), Teams and most messengers fetch a pasted URL to build its preview. - Link checkers, often with HEAD - which Flask, Django’s views and
Starlette’s
Routeanswer by running your GET handler, side effects included.
The fix: the GET shows, the POST acts
GET /unsubscribe?token=8f3c HTTP/1.1
HTTP/1.1 200 OK
Content-Type: text/html
<form method="post"><button>Unsubscribe</button></form>A person clicks and the browser POSTs to the same URL; a scanner stops at the
page. Magic sign-in links work the same way, and RFC 8058 makes mail’s one-click
unsubscribe a POST (List-Unsubscribe-Post: List-Unsubscribe=One-Click), which
Gmail and Yahoo have required of bulk senders since 2024. To catch stragglers,
log method, user agent and Sec-Purpose on state-changing routes.
Long-running work: 202, a status resource, 303
A ten-minute report cannot be one request. An AWS ALB and nginx give up on a silent upstream after 60 s by default, Cloudflare after 125 s (its documented default, as of 2026-10). The client gets a 504 (or 524) while the first build may still be running, retries the POST, and the report is built twice. Make the job a resource:
POST /reportsvalidates, enqueues and answers202 AcceptedwithLocationpointing at a status resource and aRetry-Afterhint. Both are convention on a 202: RFC 9110 definesLocationfor 201 and 3xx, and givesRetry-Afterspecific meaning only on 503 and 3xx.GET /jobs/{id}answers 200 with the job’s state while it runs - the “status monitor” RFC 9110 says a 202 ought to point to.- When done it answers
303 See Otherto the result: a separate resource, bookmarkable, and cacheable because it never changes. - On failure it answers 200 with
"status": "failed"and an RFC 9457 problem object. Azure’s async request-reply pattern answers that poll with a matching 4xx instead; pick one and document it.
On the wire
POST /reports {"month": "2026-09"}
-> 202 Accepted, Location: /jobs/efc002c4, Retry-After: 5
GET /jobs/efc002c4
-> 200 {"status": "running"}, Retry-After: 5
GET /jobs/efc002c4 (later)
-> 303 See Other, Location: /reports/efc002c4What production adds
BackgroundTasksruns inside the web process, so a deploy kills the job; enqueue to a durable queue instead - see Async I/O and background work.- An
Idempotency-Keyon the POST makes a retried submit return the existing job (Idempotency). DELETE /jobs/{id}cancels; a completion webhook spares the client the polling (Webhooks).- httpx does not follow redirects unless told to (
follow_redirects=True); requests and browsers do, so document what the status URL returns.
Batches and partial failure
POST /orders/batch HTTP/1.1
Content-Type: application/json
{"items": [{"sku": "KETTLE-1", "qty": 2}, {"sku": "NOPE", "qty": 1}]}
HTTP/1.1 200 OK
Content-Type: application/json
{"results": [
{"index": 0, "status": 201, "location": "/orders/881"},
{"index": 1, "status": 422, "error": {"title": "Unknown SKU"}}
]}- Say atomic or best-effort in the contract. Atomic is one transaction and one honest status. Best-effort returns per-item results, and the client retries only the failures, so each item needs its own idempotency key.
- The top-level code only says the batch parsed. 207 Multi-Status is
WebDAV’s (RFC 4918), with an XML body by default; Microsoft Graph’s
$batchanswers 200 with a status per item. - A batch gives up caching, a status that monitoring and gateways can read (a 200 can hide twenty failures, so emit per-item metrics), and per-resource authorization and rate limits.
Chattiness, in round trips
An order screen needs the order, its 20 line items and each item’s product, modelled as separate resources. At a 100 ms round trip, warm connections, server time ignored:
| Fetch strategy | Waves | At 100 ms |
|---|---|---|
| per-resource, HTTP/1.1 | 1 + 4 + 4 | ~900 ms |
| per-resource, HTTP/2 | 1 + 1 + 1 | ~300 ms |
?include=items.product |
1 | ~100 ms |
Browsers open about six HTTP/1.1 connections per origin, so 20 requests leave in four waves. HTTP/2 multiplexes them on one connection (HTTP versions: 1.1, 2 and 3) but cannot break the dependency chain: products cannot be requested before the items naming them arrive.
A compound document (include is JSON:API’s syntax), embedding the items, or a
BFF collapses the chain to one round trip. When every screen wants a different
shape, that is GraphQL’s case; when the chatter is between services, gRPC’s.
Where REST stops
Richardson maturity, as a pointer
Level 0 is one endpoint with the action in the body, level 1 adds resources, level 2 adds HTTP methods and status codes, level 3 adds hypermedia. Leonard Richardson presented it at QCon in 2008, and Martin Fowler’s 2010 article popularised it.
Industry “REST” is level 2. Fielding’s 2008 post “REST APIs must be hypertext-driven” says an API not driven by hypertext is not REST - level 3 or nothing. Level 2 plus an OpenAPI contract is the usual stopping point, and the case for it is in Richardson Maturity Model and HATEOAS.
RPC over HTTP, labelled or not
Slack’s Web API calls itself “HTTP RPC-style”: POST /api/chat.postMessage,
and a failure is HTTP 200 with {"ok": false, "error": "channel_not_found"}.
That is a legitimate, labelled design. The trouble is RPC that calls itself
REST, and the fix is the same work done through the uniform interface:
before POST /api/v1/getOrderStatus {"orderId": 42}
-> 200 {"success": false, "error": "NOT_FOUND"}
after GET /api/v1/orders/42
-> 404, Content-Type: application/problem+jsonThe first is a read no cache can store, a request no client may retry on its own, and a failure every dashboard counts as success.
REST vs RPC vs GraphQL vs gRPC
| Style | What a cache or gateway sees | Wins when |
|---|---|---|
| REST | method + URL + status; reads cached by URL | many unknown clients, cacheable reads |
| RPC over HTTP | POST /api/chat.postMessage, 200 + ok: false |
one team owns both ends; action-shaped domain |
| GraphQL | POST /graphql, usually 200 + errors |
many screens want different shapes of a graph |
| gRPC | HTTP/2 POST /pkg.Svc/Method, status in trailers |
internal calls, streaming, strict schemas |
The middle column is the argument: REST’s constraints exist so that column says something an intermediary can act on, and the other three trade it for action-shaped calls, client-chosen shapes or binary speed. GraphQL may use GET for queries (never mutations, per the GraphQL-over-HTTP draft), so a CDN can cache them; gRPC needs gRPC-Web or a proxy to reach a browser. More in API Protocols Comparison — REST, GraphQL, gRPC, SOAP.
Interview angle 4
- “What are REST’s constraints, and which matters most?” - client-server,
stateless, cacheable, uniform interface, layered system, and optional code on
demand. The uniform interface makes REST REST: standard methods, URIs and
media types let components that know nothing about your app act on it.
- Follow-up: “Which constraint lets a CDN serve your API without knowing
anything about it, and what must your responses get right?” - cacheable
plus self-descriptive messages: a safe method, the URL as the key, an
explicit
Cache-Control. Per-user responses must sayprivate, because by default only anAuthorizationheader keeps a shared cache off them, andVarymust name every request header that changes the body.
- Follow-up: “Which constraint lets a CDN serve your API without knowing
anything about it, and what must your responses get right?” - cacheable
plus self-descriptive messages: a safe method, the URL as the key, an
explicit
- “Why must GET be safe?” - RFC 9110 lets crawlers and prefetchers GET any
link without fear, so a GET that changes state gets fired by things that are
not users.
- Follow-up: “Name a real way that happens without a click.” - Slack or Teams unfurling a pasted link, Safe Links scanning mail so one-time links arrive used, Chrome prefetching via Speculation Rules, and in 2005 Google Web Accelerator emptying Backpack pages by prefetching their “delete” links. The fix: the GET renders a confirmation and the POST acts.
- “Model ‘generate a 10-minute report’ RESTfully.” - the job is a resource.
The POST validates, enqueues and returns 202 with
Locationto a status resource andRetry-After; the status GET returns 200 with progress, then 303 See Other to the report; a failure is afailedstate with problem details.- Follow-up: “Why not hold the request open?” - ALB and nginx cut idle
upstreams at 60 s by default and Cloudflare at 125 s, so the client gets a
504, retries the POST, and the report is built twice. An
Idempotency-Keymakes the retried submit return the same job.
- Follow-up: “Why not hold the request open?” - ALB and nginx cut idle
upstreams at 60 s by default and Cloudflare at 125 s, so the client gets a
504, retries the POST, and the report is built twice. An
- “Your API is
POST /api/getOrder, answering 200 with{"success": false}. What did you lose?” - caching (a POST has no URL key), safe automatic retries, error visibility for monitoring and HTTP clients, and gateway authorization by method. It is RPC over HTTP - Richardson level 0 with the operation’s name moved into the URL.- Follow-up: “Is RPC over HTTP ever right?” - yes, when it is chosen and labelled: Slack’s Web API is openly RPC and its SDKs absorb the cost. For internal, typed or streaming calls, use gRPC rather than inventing one.