Engineering Atlas › System Design Interviews

Sketching APIs under interview pressure

13 min read · last reviewed 2026-10-17

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

In one minute

How it works

Choosing the style

The checklist interviewers listen for

A repeatable approach

  1. List the three to five core use cases from the requirements.
  2. Write one endpoint per use case with request and response shapes.
  3. Mark which are read-heavy (cacheable, paginated) and which mutate (idempotent, authorised).
  4. Decide where the client needs push rather than poll.
  5. Mention errors, rate limits and versioning in one sentence each.

Common mistakes

Down the stack

Layer 1The contract

RFC 9110 defines GET, HEAD, OPTIONS and TRACE as safe, and those plus PUT and DELETE as idempotent: sending the same request twice has the same intended effect as once (RFC 9110, section 9.2). POST and PATCH are neither by default, which is why they need idempotency keys to be safely retried. A well-designed idempotent create promises: for the key's lifetime (often 24 hours), the same key and the same request body yield the same response; the same key with a different body is rejected; and concurrent duplicates do not both execute. Pagination contracts should say whether items inserted during a scan may appear, and that a cursor is opaque and may expire.

Layer 2The mechanism

An idempotency layer stores a record keyed by (client_id, idempotency_key) with a fingerprint of the request (a hash of method, path and body), a state (started or completed), and the stored response. On a new request it inserts the record with a unique constraint; if the insert conflicts, it either replays the completed response, returns 409 while the first attempt is still running, or rejects a mismatched fingerprint with 422. Stripe's public write-up describes this pattern with recovery points so a request that crashed midway can resume. Implementations differ on failures. Stripe's v1 API saves the status and body of the first request once the endpoint starts executing, including 500 errors, saves nothing when validation fails or a concurrent request holds the key, and lets keys be pruned after 24 hours; its v2 API instead retries failed work to completion where it can (Stripe docs). Many in-house layers store only completed results, so a retry after a 5xx runs again; that is safe only if the failed attempt left no side effect behind. Cursors are commonly an encoded and signed tuple of the last sort key, such as base64 of {"created_at": ..., "id": ...}, mapped to a keyset query on a composite index. Optimistic concurrency uses a row version: the ETag is the version, and the update runs UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ?, returning 412 when zero rows change. gRPC maps each call to an HTTP/2 stream, frames messages with a 5-byte prefix (a 1-byte compression flag and a 4-byte big-endian length), and propagates deadlines through the grpc-timeout header, such as 200m for 200 milliseconds (gRPC protocol).

Layer 3The machine

A new HTTPS connection costs a TCP handshake (one round trip) plus a TLS 1.3 handshake (one more round trip, RFC 8446), so 50 to 300 ms on a mobile network before the first byte of the request. That is why APIs reuse connections, HTTP/2 multiplexes many streams over one TCP connection, and HTTP/3 over QUIC removes TCP head-of-line blocking when packets are lost. Payload size matters on mobile: JSON is usually noticeably larger than the equivalent protobuf before compression, and parsing it costs CPU on both sides. A page of 50 feed items at 2 KB each is 100 KB, about 70 TCP segments at a typical 1,460-byte MSS, which takes several round trips while the congestion window grows from its initial 10 segments (RFC 6928). Small, paginated responses with compression keep this within one or two round trips.

Why it's built this way

Networks drop and duplicate messages, so clients must retry, and retries of non-idempotent operations duplicate side effects; hence idempotency keys. Databases skip rows slowly and lists change while being read, so pagination uses keyset cursors. Many clients update the same resource without coordination, so writes carry a version for optimistic locking. Connections are expensive to set up, so protocols multiplex. And APIs outlive the code behind them, so they are designed to evolve by addition.

Diagram

sequenceDiagram
    participant C as Client
    participant A as API
    participant S as Idempotency store
    participant P as Payments
    C->>A: POST /charges, Idempotency-Key k1
    A->>S: insert k1 (started, fingerprint)
    A->>P: charge
    P-->>A: ok
    A->>S: k1 completed, store 201 body
    A--xC: 201 lost in transit
    C->>A: retry POST /charges, k1
    A->>S: lookup k1
    S-->>A: completed, same fingerprint
    A-->>C: replay 201 body

Worked example

Design the API for a photo-sharing app's core flows: upload a photo, view a user's photos, view a home feed, like a photo.

POST /v1/uploads                       -> 201 {upload_id, upload_url, expires_at}
PUT  {upload_url}  (direct to object storage, up to 20 MB)
POST /v1/photos  Idempotency-Key: k    {upload_id, caption}  -> 201 {photo_id, created_at}
GET  /v1/users/{id}/photos?limit=30&cursor=c   -> {items: [...], next_cursor}
GET  /v1/feed?limit=20&cursor=c                -> {items: [...], next_cursor}
PUT  /v1/photos/{id}/likes/me                  -> 204   (idempotent like)
DELETE /v1/photos/{id}/likes/me                -> 204

Notes to say aloud: uploads go straight to object storage with a pre-signed URL, so 20 MB bodies never pass through the API tier. Photo creation is idempotent because mobile clients retry on flaky networks. A like is modelled as a PUT on a sub-resource, so double taps and retries are naturally idempotent, and the count is eventually consistent. Feeds use opaque cursors. At 30 items of about 1 KB of metadata each, a page is about 30 KB before compression, with image bytes served by the CDN.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
REST/JSONUniversal, cacheable, easy to debugOver- or under-fetching, verbosePublic and mobile APIs
gRPCTyped contracts, fast, streaming, deadlinesBrowser support needs a proxy, binaryInternal service calls
GraphQLClient-shaped queries, one round tripCost control, harder cachingMany clients over a rich graph
Cursor paginationStable, constant cost per pageNo jump to page NFeeds, large or changing lists
Offset paginationSimple, random page accessSlow deep pages, duplicates on insertSmall admin lists
WebSocketBidirectional, low latencyStateful connections, harder to scaleChat, live collaboration

Pitfalls in production

Leadership lens

Check yourself

  1. Why is PUT /photos/{id}/likes/me a better "like" endpoint than POST /photos/{id}/like?

    Show answer

    PUT on a per-user sub-resource is idempotent by definition, so retries and double taps set the same state instead of adding two likes.

  2. Why does offset pagination get slower for deep pages?

    Show answer

    The database must find and skip all the earlier rows before returning the page, so cost grows with the offset, while a keyset cursor seeks directly in the index.

  3. A client retries a payment with the same idempotency key but a different amount. What should the server do?

    Show answer

    Reject it, typically with 422, because the key is bound to the original request fingerprint and replaying the old response would hide a client bug.

  4. Why do many idempotency layers avoid storing 5xx responses against a key, and when is storing them the safer choice?

    Show answer

    A 5xx is often transient, and storing it makes every retry return the same failure instead of letting the operation succeed. If the failed attempt may already have caused a side effect, as with a payment, replaying the stored result (Stripe's v1 behaviour) prevents a retry from repeating it.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

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