API Design: REST, gRPC & Pagination
An API is a contract that outlives its first client: status codes and error bodies, conditional requests, cursors instead of offsets, REST against gRPC on the wire, and changing a schema without breaking the apps already installed on people's phones.
An interactive System Design lesson: 22 steps, about 32 minutes, on a live simulation in your browser.
The shop's orders API has a million orders and two clients: the web app and an older phone app, mobile-v1, that thousands of people still have installed and will not update this month. Both were built against the same contract, v1.
The web app asks for one order: GET /v1/orders/42. The call goes through the gateway to the api, which reads one row from the db. The answer is 200 OK with 106 bytes of JSON, 47.1 ms after the request left.
What you will learn
One call, read closely
- GET one order: A REST URL names a thing and the method names the action; the headers are part of the answer, not decoration.
- Ask again, with the ETag: A conditional GET saves bytes and server work downstream, not the round trip: 304 means your copy is still good.
- Create with POST: GET is safe, PUT and DELETE are idempotent, POST is neither: that is why a retried POST can charge a customer twice.
Errors are part of the contract
- Errors with a body: An error is part of the API: a status that says whose fault it is, and a machine-readable body that says what to fix.
- The same 404 over gRPC: In gRPC, :status 200 only means the transport worked; the call's result is grpc-status in the trailers.
- Drill: pick the status code
Pages: offset against cursor
- Page 25,001: OFFSET n costs n rows: the deeper the page, the slower it gets, because the database must count its way there.
- A cursor seeks instead of counting: A cursor says where you stopped, an offset says how many to skip: seeking to a key costs the same at any depth.
- New orders arrive mid-walk: An offset counts positions, and positions move when rows are inserted above them: the next page repeats rows.
- Orders deleted mid-walk: Deletes above an offset make the next page skip rows silently, which is worse than a repeat: nobody notices the gap.
- The same churn, with a cursor: A cursor walk is stable under writes: inserts land above it and deletes just vanish, so every row is seen exactly once.
- Drill: the keyset query
REST and gRPC on the wire
- One order as protobuf: Protobuf sends field numbers and compact values instead of names and text, so the same order is a third the size, but only a reader with the schema can make sense of it.
- Thirty calls, two protocols: Most of gRPC's speed here comes from HTTP/2: one connection, many streams in parallel, and headers sent once.
- Connections cost more than bytes: Latency is dominated by round trips and connection setup; reuse connections before you change encodings.
Changing a contract
- Add a field: Additive changes are safe: old clients ignore what they do not know, so new optional fields can ship in place.
- Someone renames v1 in place: JSON's contract is the field names; protobuf's contract is the field numbers and types. A rename breaks the first and not the second.
- Remove a field from protobuf: In protobuf a removed field is not an error but a default value: the client keeps running on data that is quietly wrong.
- A rename belongs in v2: Breaking changes go into a new version that runs next to the old one; the old one retires when its traffic does, not when you would like it to.
- Retries and deadlines in the contract: A contract says how to retry safely and how long to wait: an idempotency key for writes, a deadline on every call.
Recap & playground
- Cheat sheet
- Playground