06 · APIs & Web Services

REST

8 min read · 5 questions · 1 exercise

REST models an API as addressable resources acted on by a fixed set of HTTP verbs, with each request carrying everything the server needs to answer it.

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?

  1. Six counts the first POST as a request to /orders/42. It was sent to /orders, the collection; 42 is the address the reply pointed to, not the one the request went to.

  2. The GET, both PUTs, the DELETE and the final GET: five requests addressed to the item. The other three, a GET and two POSTs, went to the collection, and the second POST created /orders/43.

  3. Four leaves out the final GET because it found nothing. The request still arrived and was answered correctly, with a 404. The badge counts requests, and a 404 is an answer.

  4. Three drops the repeated PUT and the 404, as if only requests that changed or returned something count. Idempotent means the second PUT leaves the state as it was, not that it never reaches the server.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

One resource, from creation to 404

one HTTP request, then its reply · a badge counts the calls to that URL

client
/orders
/orders/42

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?

  1. Idempotency is defined by the state the server is left in, not by identical responses. A 404 on the retry is an honest answer about a resource that is already gone.

  2. GET, PUT and DELETE are all defined as idempotent. A client that retries a DELETE after a timeout is relying on exactly that.

  3. After one DELETE or two, order 42 is gone, and that is all idempotency promises. The responses are allowed to differ: some APIs answer the retry with 404 and others with 204, and both are idempotent.

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?

  1. If the first attempt landed, the server recognizes the key and returns the original result; if it did not, the retry does the work. Either way the customer is charged once, which is otherwise impossible to guarantee after a lost response.

  2. Nothing on the server compares two POSTs unless you build it. Each one is a new submission, so the retry is a second payment unless a key ties the two together.

  3. The first attempt may well have succeeded, since a lost response looks exactly like a lost request. A user told it failed will press Pay again, which is the double charge, done by hand.

  4. PUT is idempotent because it names the resource it replaces, and /payments is the collection, not a payment. It works only if the client picks the id itself, PUT /payments/<id>, which is an idempotency key by another name.

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?

  1. The resource does not exist, and sending the same request again will not make it exist. A retry just repeats the question.

  2. The server understood the request and rejected what it said. The same body will be rejected the same way; the client has to change the request, not resend it.

  3. The request clashes with the resource's current state, such as editing an order that was already cancelled. Resending it meets the same state; the client has to re-read the resource and decide again.

  4. Anything in the 500s puts the failure on the server, and 503 says it is temporary: overloaded or restarting. The request itself was fine, so the same request a moment later may succeed. Retry after a delay, and only requests that are safe to repeat.

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?

  1. Round-robin hands each request to the next server in turn, with no memory of who sent it. Keeping a user on one server is a separate feature, sticky sessions, and even then the cart vanishes when that server restarts.

  2. With two servers taking turns, roughly half of a user's requests land on the server that never saw their cart. The fix is statelessness: keep the cart in a resource, such as /carts/7, that any server can read.

  3. Each server keeps a separate cart per user in its own memory. The problem is not that carts are shared but that only one server holds each one.

  4. Nothing syncs two processes' memory unless you build it. Both servers answer happily, one of them with an empty cart, which is worse than an error because nobody is told.

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.