REST
Two teams ship an ordering API. The first exposes getOrder, getOrdersForUser,
updateOrderStatus, cancelOrder, reopenOrder, and thirty more like them.
Each is a small invention: its own name, its own parameters, its own idea of
what it returns when nothing matches. Learning it means reading all forty
entries, and every new capability adds a forty-first.
The second team exposes /orders and /orders/42, and acts on them with the
handful of methods HTTP already defines. There is far less to learn, because
most of it is not theirs — it is the same grammar the web has used since the
beginning. URLs are the nouns, methods are a small closed set of verbs, and
status codes are how the sentence comes back. REST is what you get when you
apply that grammar to an API instead of inventing a new one: resources, uniform
verbs, and requests that stand on their own.
Resources, not procedures
A resource is anything worth naming — an order, a customer, a customer’s orders.
Naming means giving it a URL, and that URL is its identity: /orders/42 refers
to that order in a log line, a bug report, a bookmark, and a cache key.
The rule that follows is the one people break first: the URL identifies, the
method acts. /orders/42 is a noun, so POST /orders/cancel and
DELETE /deleteOrder?id=42 are both the old design wearing new punctuation.
Two shapes cover most of an API. A collection — /orders — is the set, which
you read to list and post to in order to add. An item — /orders/42 — is one
member, which you read, replace, or delete. Nesting expresses belonging:
/customers/7/orders is a different resource from /orders, even when it
returns some of the same rows.
The verbs, and which ones repeat safely
There are five you will use. GET reads and changes nothing. POST submits
something to a collection, usually creating a new member. PUT replaces the
thing at a known URL with the body you send. PATCH changes part of it.
DELETE removes it.
Replies come from an equally small vocabulary, and the value is that a client
can react correctly without reading your documentation. 200 means here it is;
201 means created, with a Location header pointing at the new resource; 204
means done, nothing to send back. 400 means malformed and 422 well formed but
wrong; 401 means you are not authenticated and 403 that you are but not allowed;
404 means no such resource and 409 a conflict with its current state. Anything
in the 500s puts the failure on the server, which is also the signal that
retrying might work.
The scene below follows one order from creation to deletion using nothing but those verbs. The badge on each URL counts the requests it received.
Predict first
The client sends 8 requests: GET /orders, then POST /orders, which creates order 42, then GET /orders/42, PUT /orders/42 twice with the same body, DELETE /orders/42, GET /orders/42, and one more POST /orders. A badge on each URL counts the requests sent to it. What does the badge on /orders/42 read at the end?
Show the answer
The answer is B. The reasoning for each option is now shown under it.
one HTTP request, then its reply · a badge counts the calls to that URL
One client, two URLs, four verbs — use Next step to go one at a time.
Watch the two PUT requests in the middle. They are byte-identical, and the
second one changes nothing that the first one did not already change. That is
idempotency: sending a request more than once leaves the server in the same
state as sending it once. GET, PUT, and DELETE are all defined as
idempotent, and GET is additionally safe — it should have no side effects at
all. POST is neither: every POST to /orders asks the server to mint
something new, which is why the last step creates /orders/43.
This stops being trivia the moment a network is involved. A client that times
out cannot tell a lost request from a lost response, so it retries — and a
retried PUT is harmless while a retried POST charges the customer twice.
That is why idempotency keys exist for operations that need POST. And each
verb’s definition is a contract you are expected to honor, not one the protocol
enforces: a GET handler that increments a counter ships happily and quietly
breaks every cache in front of it.
The last two steps on /orders/42 are worth a second look. The delete answers
204 with nothing to send back; the GET after it answers 404, and that 404
still increments the badge, because the request arrived and was answered
correctly. There was simply nothing there.
A route table makes the uniform interface literal. The method and the path together select the handler, and nothing else does:
type Order = { id: string; total: number };
type Reply = { status: number; body?: Order | Order[] };
const orders = new Map<string, Order>();
let nextId = 42;
const routes = {
'GET /orders': (): Reply => ({ status: 200, body: [...orders.values()] }),
'GET /orders/:id': (id: string): Reply => {
const order = orders.get(id);
return order ? { status: 200, body: order } : { status: 404 };
},
'POST /orders': (payload: { total: number }): Reply => {
const order = { ...payload, id: String(nextId++) };
orders.set(order.id, order);
return { status: 201, body: order };
},
'PUT /orders/:id': (id: string, order: Order): Reply => {
const created = !orders.has(id);
orders.set(id, { ...order, id });
return { status: created ? 201 : 200, body: orders.get(id) };
},
'DELETE /orders/:id': (id: string): Reply => {
orders.delete(id);
return { status: 204 };
},
};
The idempotency claims are visible there rather than asserted: PUT twice with
the same body leaves the same map, DELETE twice leaves the entry absent, and
POST twice adds two entries, because the server mints a new id for each one.
Notice that a PUT to a new id answers 201 and the identical retry answers
200: idempotency is a promise about the server’s state, not about getting the
same reply twice.
That is the asymmetry: PUT and DELETE name the resource they act on, so
acting twice is acting once.
Try it
Make POST safe to retry
A client that times out on POST /orders cannot tell whether the order was
created, so it retries, and the route table above creates a second order. Give
POST /orders an idempotency key: a string the client picks once per order and
sends again with every retry. A request whose key the server has already seen
should get the original reply back and create nothing.
Hint
Store the reply under the key, in a Map<string, Reply>, and check it before
minting an id. The retry should receive the same 201 and the same order as
the first attempt, because from the client’s side it is the same request.
Solution
const replies = new Map<string, Reply>();
function postOrder(payload: { total: number }, idempotencyKey: string): Reply {
const earlier = replies.get(idempotencyKey);
if (earlier) return earlier;
const order = { ...payload, id: String(nextId++) };
orders.set(order.id, order);
const reply = { status: 201, body: order };
replies.set(idempotencyKey, reply);
return reply;
}In the route table, the entry becomes 'POST /orders': postOrder. The server
now remembers what it answered for each key, so a retry gets the same answer
and the work happens once. The key has to come from the client, because only
the client knows that two requests are the same attempt; on the wire it usually
travels in an Idempotency-Key header. Production versions also expire old
keys, and refuse a known key that arrives with a different body.
To see it work, retry one order and send a genuinely new one:
routes['POST /orders']({ total: 2500 }, 'k-1');
routes['POST /orders']({ total: 2500 }, 'k-1'); // the retry
routes['POST /orders']({ total: 900 }, 'k-2');
// [ '42', '43' ] (without keys: [ '42', '43', '44' ])
console.log([...orders.keys()]);Statelessness
The other constraint is that every request carries everything needed to understand it: who is asking, which resource, and what to do. The server keeps no memory of the previous request from that client — no “they are on step 3 of checkout” living in one process’s memory.
What that buys is substitution. Any server in the pool can answer any request,
so a load balancer can send the next one anywhere, a machine can be restarted
mid-session without logging anyone out, and capacity is added by adding
machines. Caches benefit for the same reason: a GET whose meaning depends only
on its URL and headers is a response a proxy can store and reuse.
Statelessness is about the conversation, not about data. The database is full of
state, and that is fine — the constraint is that state lives in resources you
can name, not in a server’s memory between two requests. Multi-step flows follow
the same rule: a half-finished checkout becomes a real resource with a real URL
and a status of draft, which has the side effect of surviving a browser crash.
The price is repetition: every request re-sends its credentials and its context, which is more bytes on the wire and a token to validate on every call. Most systems take that trade gladly.
The honest edges
The word is used loosely. In the dissertation that named it — Roy Fielding’s, in 2000 — REST is a set of architectural constraints, and one of them is that responses link to what you can do next, so a client can navigate the API by following links rather than by hard-coding its URLs. Almost nobody ships that. What the industry calls REST is the resource-and-verb subset, which is genuinely useful and worth doing well — just know that the term is a family resemblance rather than a specification you can be measured against.
The model’s real cost is that resources have fixed shapes. An order page needs
the order, the customer, and the line items, so you either make three round
trips or return a payload most callers do not need. The round trips cost more
than they look: the line items can be fetched alongside the order, but the
customer’s id is inside the order, so at 100 ms a round trip the page waits at
least 200 ms for data that one request could have brought in 100. Every team
then invents the
same workarounds — a ?include= parameter, an /orders/42/full endpoint — and
that pressure is where GraphQL came from. Pagination, filtering, and versioning
get invented locally too, since none of them are part of the model.
And some things really are verbs. Transcoding a video is an action, and
contorting it into a noun for the sake of purity helps nobody. The pragmatic
answer is to make the work itself a resource — POST /transcodes returning 202
and a URL you can poll — which is honest when the job has a lifecycle, and
overwrought when it does not.
Check yourself
Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.
Your DELETE /orders/42 answers 204 the first time and 404 when a client retries it. A reviewer says the second answer breaks idempotency. Are they right?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
A mobile app sends POST /payments, and the connection drops before any response arrives. The payment may or may not have gone through. What should the app do?
Show the answer
The answer is A. The reasoning for each option is now shown under it.
A client gets one of these replies to a request. Which one is worth sending again, unchanged, a moment later?
Show the answer
The answer is D. The reasoning for each option is now shown under it.
Checkout keeps each user's cart in the memory of whichever server handled their first request. You add a second server behind a round-robin load balancer. What do users notice?
Show the answer
The answer is B. The reasoning for each option is now shown under it.
Key takeaways
REST puts the nouns in the URL and the verbs in the HTTP method, so most of the API is vocabulary the client already knows.
GET, PUT, and DELETE are idempotent and POST is not, which decides what a client is allowed to retry after a timeout.
Statelessness means the server keeps no conversation between requests, which is what lets any machine in the pool answer any request.
Fixed resource shapes cause over-fetching and extra round trips — the known cost of the model, and the reason alternatives exist.