Engineering Atlas › System Design Fundamentals

Designing clean REST APIs

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Resources and methods

A resource is a noun with an identity: /orders/8812, /orders/8812/items. Collections take GET (list) and POST (create). Members take GET, PUT (replace), PATCH (partial update) and DELETE. GET, HEAD, OPTIONS and TRACE are safe: the client asks for no state change, so crawlers and prefetchers can call them. PUT, DELETE and the safe methods are idempotent: repeating them has the same intended effect as doing them once, so clients may retry after a connection drops (RFC 9110). POST is neither, which is why creating things needs extra care. PATCH is neither by default either (RFC 5789), and its body format is a choice you must document: JSON Merge Patch (RFC 7396) sends just the changed fields and uses null to delete, which breaks if null is a real value; JSON Patch (RFC 6902) sends an ordered list of operations (Azure guide).

Actions that do not fit CRUD ("cancel an order") are modelled either as a state change (PATCH /orders/8812 {"status":"cancelled"}) or as a sub-resource (POST /orders/8812/cancellation). Pick one convention and use it everywhere.

Searches with large filter objects used to be forced into POST, which gave up caching and safe retries. RFC 10008 (June 2026) adds QUERY: the filter goes in the body, the request is safe and idempotent, and caches may store the response under a key that includes the body (RFC 10008).

Status codes and errors

Use the code classes properly: 2xx success, 4xx the client must change something, 5xx the server failed and a retry may help. The distinctions that matter operationally: 400 vs 422 (malformed vs semantically invalid; 422 began in WebDAV and is now part of core HTTP as "Unprocessable Content" in RFC 9110), 401 vs 403 (not authenticated vs not allowed), 409 (conflict with current state), 429 (rate limited, with Retry-After), 503 (temporarily unavailable). Return a single error body shape everywhere, such as RFC 9457 problem details with a machine-readable type, so clients can branch on it.

Idempotency for POST

A client creating a payment times out. Did it succeed? Without help, retrying may charge twice. The standard fix is an Idempotency-Key header: the server stores the key with a fingerprint of the request and the response, and replays the stored response for a repeat. The IETF draft that standardises the header says a missing key gets 400, a reused key with a different body gets 422, and a retry that arrives while the first attempt is still running gets 409; the draft (revision 07, October 2025) expired without becoming an RFC, so treat it as convention (IETF draft). Stripe prunes keys after 24 hours and saves a result only once the endpoint starts executing, so a request that failed validation can simply be retried (Stripe docs). AWS adds a subtle rule: the replay should be semantically the same as the first success, not an "already exists" error that sends the caller down a different code path (AWS Builders' Library).

Pagination

Offset pagination (?offset=10000&limit=50) is simple but the database still walks the skipped rows, and inserts shift pages so clients see duplicates or gaps. Cursor pagination returns an opaque token encoding the last seen sort key (created_at, id), and the next query uses WHERE (created_at, id) > (...), which uses an index and is stable under inserts. Google's API rules add two details worth copying: tokens must be opaque (base64 of readable JSON does not count), and an oversized page size is quietly capped to the maximum instead of rejected (AIP-158).

Concurrency and caching

ETag plus If-None-Match lets clients revalidate cheaply (304 Not Modified). If-Match on writes gives optimistic concurrency: the update succeeds only if the resource has not changed, otherwise 412 Precondition Failed. This prevents lost updates without locks.

Long-running operations

Some writes take minutes, such as an export or a bulk refund, and holding the request open hits proxy timeouts. The usual shape is to validate, then answer 202 Accepted with a Location header pointing at a status resource and a Retry-After hint. The status resource returns 200 with a state such as running or failed while work continues, and 303 See Other to the result when it is done, which forces the client's next request to be a GET. A DELETE on the status resource cancels (Azure pattern). Combine it with an idempotency key so a retried submit returns the existing operation rather than starting a second one.

Versioning

Additive changes (new optional fields, new endpoints) are non-breaking if clients ignore unknown fields. Removing or renaming fields, changing types, or tightening validation are breaking. Version in the URL (/v2/), a query string, a header or a media type; URL and query-string versions are the cache-friendly ones because the same URL always means the same representation (Azure guide). Support old versions with a published deprecation window.

Down the stack

Layer 1The contract

HTTP semantics (RFC 9110) are the contract every client, proxy and cache relies on. GET and HEAD are safe; PUT and DELETE are idempotent; a 2xx means the method's effect happened; a 5xx or a timeout means the outcome is unknown. Conditional requests are precise: a write with If-Match: "v7" either applies to version 7 or fails with 412 and changes nothing. An idempotency key extends the contract to POST: for 24 hours, the same key and the same body produce the same result exactly once, and a different body under that key is rejected.

Layer 2The mechanism

A request is a method, a target, header fields and an optional body. The server derives an ETag from a version column or a content hash (strong, or weak with a W/ prefix when only semantic equality is promised). Optimistic concurrency becomes one SQL statement, UPDATE orders SET ... , version = version + 1 WHERE id = $1 AND version = $2; zero rows updated means 412. The idempotency store is a table with a unique key column, a request fingerprint, a state (in_progress or complete), the stored status and body, and a lock timestamp. A second request arriving while the first is in progress gets 409, so the client backs off rather than racing. Cursors are a base64url encoding of the last row's sort key with an HMAC, and the next page runs WHERE (created_at, id) > ($1, $2) ORDER BY created_at, id LIMIT 100 against a composite index.

Layer 3The machine

A new HTTPS connection costs a TCP handshake plus a TLS 1.3 handshake, two round trips before the first byte of the request, so keep-alive and connection pools matter more than payload tuning on high-latency links. HTTP/1.1 headers often run 500 to 800 bytes per request; HTTP/2's HPACK replaces repeated headers with table indexes of a byte or two. On the database side, a Postgres B-tree leaf page is 8 KB and holds a few hundred index entries, so OFFSET 1500000 walks around 5,000 leaf pages, each a possible 100-microsecond SSD read if not cached. Keyset pagination descends three or four levels of the tree and reads one or two leaf pages, whatever the depth.

Why it's built this way

  1. Networks drop responses, so clients cannot tell failure from success, and retries must be made safe by method semantics or idempotency keys.
  2. Caches and proxies sit between clients and servers and know nothing about your app, so a uniform interface with standard meanings lets them help.
  3. HTTP requests are stateless and long locks across them are impossible, so concurrency control uses validators like ETags instead.
  4. Databases read fixed-size pages and cannot skip to row N, so deep offsets cost linear I/O and cursors use the index directly.
  5. Clients upgrade slowly (mobile apps live for years), so APIs evolve additively and version only when they must break.

Diagram

sequenceDiagram
    participant C as Client
    participant A as API
    participant S as "Idempotency store"
    participant D as Database
    C->>A: POST /payments, Idempotency-Key k1
    A->>S: lookup k1
    S-->>A: not found, reserve k1
    A->>D: insert payment
    A->>S: save response for k1
    A--xC: 201 lost in network
    C->>A: retry POST, Idempotency-Key k1
    A->>S: lookup k1
    S-->>A: stored 201
    A-->>C: 201 replayed, no second charge

Worked example

A marketplace exposes GET /orders?seller_id=... to 3,000 sellers, some with 2 million orders.

With offset pagination, a seller exporting all orders at 100 per page makes 20,000 requests; page 15,000 makes the database scan and discard 1.5 million index entries, so late pages take 800 ms instead of 5 ms and the export takes hours while hammering the primary. Switching to a cursor on (created_at, id) makes each page an index range scan of 100 rows, around 3 ms regardless of depth. The cursor is base64 of the last key, signed so clients cannot forge it.

For writes, the team adds idempotency keys to POST /payments. At 400 payments per second and a 24-hour key retention, the store holds about 35 million keys; at roughly 300 bytes each (key, fingerprint, response) that is about 10 GB, comfortably in a Redis cluster or a database table with a TTL index.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Offset paginationSimple, jump to page NSlow deep pages, unstable under writesSmall admin lists
Cursor paginationFast at any depth, stableNo random page accessFeeds, exports, large tables
URL versioningVisible, easy to route and cacheDuplicate surface per versionPublic APIs with many clients
Header versioningClean URLs, per-request choiceHarder to test and cacheInternal APIs with capable clients
PATCH partial updateSmall payloads, fewer conflictsMerge semantics need definingLarge resources edited piecemeal

Pitfalls in production

Leadership lens

Check yourself

  1. Why is PUT /users/7 safe to retry but POST /users not?

    Show answer

    PUT sets the resource to a given state, so repeating it yields the same state. POST creates a new resource each time unless the server deduplicates with an idempotency key.

  2. Why does cursor pagination avoid duplicates when new rows are inserted?

    Show answer

    The next page starts strictly after the last seen key, so inserts elsewhere do not shift which rows come next.

  3. What does If-Match prevent?

    Show answer

    Lost updates: a write based on a stale version fails with 412 instead of overwriting someone else's change.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →