Skip to content
Software Atlas

API Design

How to design an HTTP API that other people can build on and that you can keep changing: resources and status codes, errors, pagination that doesn't skip rows, evolving without breaking clients, REST vs GraphQL vs gRPC, and the reliability features every serious API ends up needing.

⏱ ~55 min read14 sections9 interactive widgetsGo · JavaScript · PostgreSQL
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceAn API is a promise to strangers. Once someone builds on it, you can add to it whenever you like, but you can't quietly change what's already there.

Two true-to-life stories:

  • A shop's backend team renamed one JSON field, total → total_cents, the same day the new mobile app shipped. The new app worked. But 40% of phones hadn't updated yet, and on every one of them the checkout screen showed "Total: $NaN". Nothing on those phones had changed.
  • An internal GET /orders endpoint returned all orders. Fine with 200 orders at launch. Three years later it returned 2 GB of JSON, took 40 seconds, and knocked over the server every time the finance team opened their dashboard.

Neither was a coding bug. Both were design bugs: the first broke the contract with existing clients, and the second had no Pagination built in from the start. This topic is about avoiding both kinds.

Try it. Twenty phones run different versions of the shop app. Pick what the server team ships:

Ship a server change to 20 phones
Each tile is a phone running some version of the shop app. The app shows an order's total. Pick what the server team ships today.
GET /orders/ord_2 returns
{
  "id": "ord_2",
  "status": "paid",
  "total_cents": 4850
}
v3.5 read total_cents
v3.4, v3.3, v3.1 read total
The phones40% broken
v3.5
$48.50
v3.5
$48.50
v3.5
$48.50
v3.5
$48.50
v3.5
$48.50
v3.5
$48.50
v3.5
$48.50
v3.5
$48.50
v3.5
$48.50
v3.5
$48.50
v3.5
$48.50
v3.5
$48.50
v3.4
$NaN
v3.4
$NaN
v3.4
$NaN
v3.4
$NaN
v3.3
$NaN
v3.3
$NaN
v3.3
$NaN
v3.1
$NaN
The server renamed the field on the same day the new app shipped. Phones that haven't updated yet still read total, get undefined, and show $NaN. Nobody changed those phones, yet they broke.

Why this is harder than it looks

When you write a function, you can change it and fix every caller in the same commit. With an API, the callers aren't yours. They're apps on phones you can't update, partner companies on their own schedules, scripts someone wrote two years ago. A change that would be a five-minute refactor inside one codebase becomes a months-long migration across a hundred.

So API design is mostly about choices that are expensive to undo: names, shapes, how errors look, how lists are paged, how you'll change things later. This topic covers them, using one running example, an online shop with orders, items and payments:

§You'll learn
02–03How to name things (resources and URLs), and which method and status code to use
04–05A real orders API in Go and Node, and an error format clients can act on
06–07Pagination that doesn't skip or repeat rows, and why OFFSET gets slow (measured)
08Changing an API without breaking anyone, and versioning when you must
09REST vs GraphQL vs gRPC: the same screen in each, and when each one wins
10–11Retries, rate limits, slow jobs, webhooks; and the staff-level toolkit (OpenAPI, guidelines, compatibility tests)
Foundations§02

Resources: naming the things in your API

In one sentenceIn a REST API, URLs name things and methods say what to do with them. Design the nouns first; the endpoints mostly follow.

From functions to resources

If you'd never seen an HTTP API, you'd probably design the shop like a list of functions: /getOrder?id=42, /createOrder, /cancelOrder. That works, but every API invents its own names, and nothing tells a client which calls are safe to retry or to cache.

REST turns it around. You pick the things your shop has, called resources, and give each one a URL. Then you use the standard HTTP methods on them. Every client already knows what GET and DELETE mean, so there's less to learn and less to get wrong.

The shop's nouns

Start by listing the nouns in your product and how they relate. For the shop:

The shop's resources
customers
/customers/{id}
orders
/orders/{id}
items
/orders/{id}/items
payments
/payments/{id}
created via /orders/{id}/payments
Boxes are resources; the URL under each is its address. Items live inside an order; payments are created under an order but also get their own address.

Each noun gives you two kinds of URL:

  • A collection, a plural noun: /orders. GET lists orders, POST creates one.
  • A member of the collection: /orders/ord_42. GET reads it, PATCH changes some of its fields, DELETE removes it.
  • Things that only exist inside a member become a sub-resource under its URL: /orders/ord_42/items.

The ord_42 part is a path parameter: routers write the pattern as /orders/{id}. Optional inputs like filters go in query parameters after the ?: /orders?status=paid.

Those four operations (create, read, update, delete) are called CRUD, and they cover most of what an API does.

Explore it

The shop's API, explored
Click an endpoint to see the request, the response, and why it's shaped that way.
Request
GET /orders?status=paid&limit=2
Response
200 OK

{
  "data": [
    { "id": "ord_42", "status": "paid", "total_cents": 4850 },
    { "id": "ord_40", "status": "paid", "total_cents": 1200 }
  ],
  "next_cursor": "eyJpZCI6NDB9"
}
A collection: a plural noun. GET lists its members, one page at a time. Filters go in the query string (?status=paid), not in the path.

Rules of thumb

DoDon'tWhy
/orders, /orders/ord_42/getOrders, /order/42The method already says "get"; plural collections read naturally both as a list and with an id.
/orders?status=paid&customer=c7/orders/paid/customer/c7Filters are optional and can be combined; the path is for identity.
/orders/ord_42/items/customers/c7/orders/ord_42/items/3One level of nesting. Deeper paths force clients to know every parent id.
ids like "ord_42" (strings)ids like 42 (numbers)You can change how ids are made later (to UUIDs, say) without a type change. The prefix tells a human what kind of id it is.
snake_case or camelCase, everywherea mixPick one per API and never deviate. Clients write code against the names.

Actions that don't fit CRUD

Some operations aren't "change a field". Cancelling an order refunds the payment, releases the stock and emails the customer. You could model it as PATCH /orders/ord_42 {"status": "cancelled"}, but that hides real work behind a field write. It also invites nonsense like PATCH {"status": "shipped"} from a client.

Two honest options:

  1. An action sub-path: POST /orders/ord_42/cancel. Clear, easy to secure, easy to document. Google's API guidelines call these custom methods and write them POST /orders/ord_42:cancel; the idea is the same.
  2. Turn the action into a noun: POST /orders/ord_42/cancellations creates a cancellation record, which you can GET later to see who cancelled and why. Worth it when the action has its own data and history (refunds are the classic example).

Either is fine. Pick one style and use it everywhere.

Foundations§03

Choosing methods and status codes

In one sentenceA status code is the first thing a client's code checks. Pick it so that a program, not a human, can decide what to do next: show the result, fix the input, wait and retry, or give up.

HTTP §02 lists the methods and the five classes of status code. Here we're choosing between them for the shop.

Methods: what each promises

MethodUse it forSafe?Idempotent?
GETRead a resource or list a collectionyesyes
POSTCreate a member of a collection; run an actionnono
PUTReplace a resource completelynoyes
PATCHChange some fieldsnonot guaranteed
DELETERemove a resourcenoyes

Safe means "only reads". Browsers, CDNs and crawlers assume GET is safe and will happily prefetch, cache and repeat it. So never make a GET /orders/42/cancel link: a link-preview bot will cancel the order.

Idempotent means doing it twice has the same effect as once. PATCH depends on the body: PATCH {"quantity": 3} sets a value, so repeating it changes nothing more; PATCH {"add_quantity": 1} adds one every time it's sent. A client whose PUT timed out can simply send it again. A POST is different: sending it again might create a second order. §10 shows how idempotency keys make POST safe to retry.

The status codes that matter

You need about a dozen. Group them by what the client should do next:

Client should…CodeWhen
use the result200 OKRead worked, update worked, action worked
201 CreatedA POST created something. Add a Location header with its URL
202 AcceptedWork has started but isn't finished; here's where to check (§10)
204 No ContentIt worked and there's nothing to send back (no body at all)
fix the request400 Bad RequestCan't parse it: broken JSON, wrong content type
422 Unprocessable ContentParsed fine, but a value is invalid: quantity: 0, unknown product
409 ConflictValid, but clashes with the current state: cancelling a shipped order
log in / ask for access401 Unauthorized"Who are you?" Missing or expired credentials (it really means unauthenticated)
403 Forbidden"I know who you are, and you may not do this"
stop asking404 Not FoundNo such resource (or one the caller mustn't know exists)
wait, then retry429 Too Many RequestsThis client is over its rate limit. Send Retry-After
503 Service UnavailableThe server is temporarily overloaded or in maintenance. Send Retry-After
report a bug500 Internal Server ErrorYour code failed. The client can't fix it

The first digit carries most of the meaning, so clients that only check 4xx vs 5xx still behave sensibly. That gives you the most important retry rule: 4xx means "don't retry the same request, it will fail the same way" (429 is the exception: it says "retry later"). 5xx and network errors may be worth retrying, carefully (Idempotency §09).

Practise

Pick the code you'd send. Some situations have an acceptable second-best answer, shown in amber:

Which status code?
Read the situation, then pick the code you'd send. Some have a second-best answer.
score 0/0
SITUATION 1 / 12

POST /orders with a valid body. The order is saved.

Pick a code. Green codes are 2xx (it worked), amber are 4xx (the client's fault), red are 5xx (the server's fault).

Three mistakes to avoid

1

200 with an error inside

200 OK with {"success": false, "error": "not found"}. Every client, proxy, monitoring tool and retry library reads the status code first. They'll all think it worked.
2

404 for an empty list

GET /orders?status=refunded with no matches is 200 with []. The collection exists; it's just empty.
3

500 for bad input

If the client sent nonsense, it's a 4xx. A 500 tells your on-call engineer "we have a bug" and tells clients to retry. Both are wrong.
Practitioner§04

A real orders API in Go and Node

In one sentenceThree endpoints, two languages, no frameworks: POST /orders, GET /orders/{id}, POST /orders/{id}/cancel. Everything in §02–03, running.

The shop keeps its orders in memory (a real one would use a database) and knows two products. Order ord_1 already exists and has shipped, so we can try to cancel it. Read the Go version first; the Node version does the same thing. (If Go syntax is new, the Go primer covers what you need.)

main.go
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"sync"
	"time"
)

type Item struct {
	SKU      string `json:"sku"`
	Quantity int    `json:"quantity"`
}

type Order struct {
	ID         string    `json:"id"`
	Status     string    `json:"status"` // pending | paid | shipped | cancelled
	Items      []Item    `json:"items"`
	TotalCents int64     `json:"total_cents"` // money as whole cents, never a float
	CreatedAt  time.Time `json:"created_at"`
}

var prices = map[string]int64{"MUG-BLUE": 1200, "TEA-1KG": 2450}

type Store struct {
	mu     sync.Mutex // handlers run concurrently, so guard the map
	orders map[string]*Order
	nextID int
}

func main() {
	s := &Store{orders: map[string]*Order{
		"ord_1": {ID: "ord_1", Status: "shipped", Items: []Item{{"MUG-BLUE", 1}}, TotalCents: 1200,
			CreatedAt: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)},
	}, nextID: 2}

	mux := http.NewServeMux()
	mux.HandleFunc("POST /orders", s.createOrder)             // ← method + path pattern (Go 1.22+)
	mux.HandleFunc("GET /orders/{id}", s.getOrder)            // ← {id} is a wildcard
	mux.HandleFunc("POST /orders/{id}/cancel", s.cancelOrder)
	log.Fatal(http.ListenAndServe("localhost:8080", mux))
}

func (s *Store) getOrder(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id") // "ord_1" for GET /orders/ord_1
	s.mu.Lock()
	o, ok := s.orders[id]
	s.mu.Unlock()
	if !ok {
		writeError(w, http.StatusNotFound, "no order with id "+id)
		return
	}
	writeJSON(w, http.StatusOK, o)
}

func (s *Store) createOrder(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Items []Item `json:"items"`
	}
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // refuse bodies over 1 MiB
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeError(w, http.StatusBadRequest, "body is not valid JSON")
		return
	}
	if len(in.Items) == 0 {
		writeError(w, http.StatusUnprocessableEntity, "an order needs at least one item")
		return
	}
	var total int64
	for _, it := range in.Items {
		price, ok := prices[it.SKU]
		if !ok || it.Quantity < 1 {
			writeError(w, http.StatusUnprocessableEntity, "bad item "+it.SKU)
			return
		}
		total += price * int64(it.Quantity)
	}

	s.mu.Lock()
	o := &Order{ID: fmt.Sprintf("ord_%d", s.nextID), Status: "pending", Items: in.Items,
		TotalCents: total, CreatedAt: time.Now().UTC().Truncate(time.Second)}
	s.orders[o.ID] = o
	s.nextID++
	s.mu.Unlock()

	w.Header().Set("Location", "/orders/"+o.ID) // where the new resource lives
	writeJSON(w, http.StatusCreated, o)
}

func (s *Store) cancelOrder(w http.ResponseWriter, r *http.Request) {
	s.mu.Lock()
	defer s.mu.Unlock()
	o, ok := s.orders[r.PathValue("id")]
	switch {
	case !ok:
		writeError(w, http.StatusNotFound, "no such order")
	case o.Status == "shipped":
		writeError(w, http.StatusConflict, "order has already shipped")
	default:
		o.Status = "cancelled" // cancelling twice is fine: same end state
		writeJSON(w, http.StatusOK, o)
	}
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json") // headers first…
	w.WriteHeader(status)                              // …then the status…
	json.NewEncoder(w).Encode(v)                       // …then the body
}

// A first, simple error body. §05 turns it into a proper standard.
func writeError(w http.ResponseWriter, status int, detail string) {
	writeJSON(w, status, map[string]any{"status": status, "title": http.StatusText(status), "detail": detail})
}

Running it

Start either server, then talk to it with curl (-i shows the response headers, -X picks the method, -d sends a body):

$ curl -i -X POST localhost:8080/orders -d '{"items":[{"sku":"MUG-BLUE","quantity":2},{"sku":"TEA-1KG","quantity":1}]}'
HTTP/1.1 201 Created
Content-Type: application/json
Location: /orders/ord_2

{"id":"ord_2","status":"pending","items":[{"sku":"MUG-BLUE","quantity":2},{"sku":"TEA-1KG","quantity":1}],"total_cents":4850,"created_at":"2026-10-30T15:34:33Z"}

$ curl -i -X POST localhost:8080/orders/ord_1/cancel
HTTP/1.1 409 Conflict
Content-Type: application/json

{"detail":"order has already shipped","status":409,"title":"Conflict"}

$ curl -i -X POST localhost:8080/orders/ord_2/cancel
HTTP/1.1 200 OK
Content-Type: application/json

{"id":"ord_2","status":"cancelled", …}

$ curl -i -X DELETE localhost:8080/orders/ord_2
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff

Method Not Allowed

That's real output from the Go server (Date and Content-Length headers trimmed, and the long cancelled-order body shortened to …). Three things to notice:

  1. The 201 comes with a Location header, so the client knows the new order's URL without building it.
  2. Cancelling an already-cancelled order would also return 200: same end state, so the action is idempotent and safe to retry.
  3. For DELETE, Go's ServeMux answered 405 by itself, with an Allow header listing the methods that do exist for that path (it adds HEAD automatically wherever you registered GET). But that 405 body is plain text, not our JSON shape. A polished API wraps the mux so even router errors use the same error format (§05). Our Node version builds its own 405 and gets this right.

Bad input must get a 4xx, never a 500

Clients send strange things. Here's the Node server receiving five of them. Real output:

$ curl -s -X POST localhost:8080/orders -d '{"items":[{"sku":"toString","quantity":1}]}'
{"status":422,"title":"Unprocessable Entity","detail":"bad item toString"}
$ curl -s -X POST localhost:8080/orders -d '{"items":[{"sku":"MUG-BLUE","quantity":1.5}]}'
{"status":422,"title":"Unprocessable Entity","detail":"bad item MUG-BLUE"}
$ curl -s -X POST localhost:8080/orders -d '{"items":[{"sku":"MUG-BLUE","quantity":"2"}]}'
{"status":422,"title":"Unprocessable Entity","detail":"bad item MUG-BLUE"}
$ curl -s -X POST localhost:8080/orders -d '{"items":[null]}'
{"status":422,"title":"Unprocessable Entity","detail":"each item must be an object"}
$ curl -s -X POST localhost:8080/orders -d 'null'
{"status":422,"title":"Unprocessable Entity","detail":"an order needs at least one item"}

Our first draft checked it.sku in prices and it.quantity >= 1. It accepted "toString" as a product (storing an order with "total_cents": null), charged 1.5 mugs, and answered {"items":[null]} with a 500, because reading null.sku threw. Go's typed decoding rejects most of these for free; in JavaScript, every check is yours to write.

A client

Here's the shop's web app creating an order with fetch (Node 18+ and every browser):

client.mjs
async function createOrder(items) {
  const res = await fetch("http://localhost:8080/orders", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ items }),
    signal: AbortSignal.timeout(3000), // ← give up after 3 s
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${body.title}: ${body.detail}`);
  return { order: body, location: res.headers.get("Location") };
}

console.log(await createOrder([{ sku: "TEA-1KG", quantity: 2 }]));
await createOrder([{ sku: "TEA-1KG", quantity: 0 }]).catch((err) => console.log("failed:", err.message));
$ node client.mjs        # against a freshly started server
{
  order: {
    id: 'ord_2',
    status: 'pending',
    items: [ [Object] ],
    total_cents: 4900,
    created_at: '2026-10-30T15:46:31Z'
  },
  location: '/orders/ord_2'
}
failed: 422 Unprocessable Entity: bad item TEA-1KG
Practitioner§05

Errors clients can act on

In one sentenceAn error response has two readers: a program that must decide what to do, and a human debugging it later. Give each its own field, and use the same shape for every error in the API.

What goes wrong without a standard

Teams that don't decide on an error shape end up with all of these in one API, because each endpoint was written by someone different:

{"error": "not found"}
{"message": "Invalid quantity", "code": 1042}
{"errors": ["items[1].quantity must be > 0"]}
"Internal Server Error"

Every client now needs four parsers. Worse, the only way to tell errors apart is to compare message text, so fixing a typo in a message breaks a client somewhere.

Problem details (RFC 9457)

There's an Internet standard for this: problem details, RFC 9457 (2023; it replaced RFC 7807, which you'll still see cited). An error body is a JSON object, sent with the media type application/problem+json, with these fields:

FieldForExample
typeprograms: a URL that names this kind of error. Never changes."https://api.shop.example/problems/out-of-stock"
titlehumans: a short summary, the same every time for this type"Item is out of stock"
statusa copy of the HTTP status code409
detailhumans: what happened this time"Only 1 of MUG-BLUE left; you asked for 2"
instanceoptional: an id for this occurrence, to quote in a support ticket"/orders/ord_42" or a request id

You may add any extension fields you like. The most useful is a list of field-level errors for validation failures.

Validation errors that point at the field

If the client sent three bad fields, tell it about all three at once, and say exactly where each one is. A JSON Pointer does that: #/items/1/quantity means "the quantity of the item at index 1 (the second item)". A form can then put a red message under the right input box.

problem.go
// Problem is an RFC 9457 "problem details" body.
type Problem struct {
	Type   string       `json:"type"`             // a URL naming the kind of error
	Title  string       `json:"title"`            // short, same for every error of this type
	Status int          `json:"status"`           // copy of the HTTP status
	Detail string       `json:"detail,omitempty"` // what went wrong this time
	Errors []FieldError `json:"errors,omitempty"` // our extension: one entry per bad field
}

type FieldError struct {
	Pointer string `json:"pointer"` // JSON Pointer to the bad field, e.g. "#/items/1/quantity"
	Detail  string `json:"detail"`
}

func writeProblem(w http.ResponseWriter, p Problem) {
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(p.Status)
	json.NewEncoder(w).Encode(p)
}

func validateItems(items []Item) []FieldError {
	var errs []FieldError
	if len(items) == 0 {
		errs = append(errs, FieldError{"#/items", "must contain at least one item"})
	}
	for i, it := range items {
		if _, ok := prices[it.SKU]; !ok {
			errs = append(errs, FieldError{fmt.Sprintf("#/items/%d/sku", i), "unknown SKU " + it.SKU})
		}
		if it.Quantity < 1 {
			errs = append(errs, FieldError{fmt.Sprintf("#/items/%d/quantity", i), "must be 1 or more"})
		}
	}
	return errs // nil when everything is fine
}

// in createOrder, after decoding:
if errs := validateItems(in.Items); errs != nil {
	writeProblem(w, Problem{
		Type:   "https://api.shop.example/problems/validation",
		Title:  "Your request has invalid fields",
		Status: http.StatusUnprocessableEntity,
		Errors: errs, // ← report ALL problems at once, not just the first
	})
	return
}

Real output from the Go server, for an order with an unknown product and a zero quantity:

$ curl -i -X POST localhost:8080/orders -d '{"items":[{"sku":"MUG-BLUE","quantity":2},{"sku":"MUG-RED","quantity":0}]}'
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{"type":"https://api.shop.example/problems/validation","title":"Your request has invalid fields","status":422,
 "errors":[{"pointer":"#/items/1/sku","detail":"unknown SKU MUG-RED"},
           {"pointer":"#/items/1/quantity","detail":"must be 1 or more"}]}

(Line breaks added for reading.) The app can now highlight both problems on the second line of the cart, in one round trip.

Don't leak your internals

Here's a tempting shortcut, and what it produced when a client sent "quantity": "two":

✕ Don't
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
	writeProblem(w, Problem{Status: 400, Title: "Malformed JSON",
		Detail: err.Error()}) // ← Go's own error text
	return
}
// detail: "json: cannot unmarshal string into Go
//   struct field Item.items.quantity of type int"
✓ Do
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
	slog.Info("bad order body", "err", err) // log/slog, Go's built-in logger: detail stays in OUR logs
	writeProblem(w, Problem{Status: 400, Title: "Malformed JSON",
		Type:   "https://api.shop.example/problems/malformed-json",
		Detail: "The body must be a JSON object matching the Order schema."})
	return
}

The left one tells strangers your language, your struct names and your field types, and its wording changes whenever Go's does. Clients will start parsing it anyway. Errors from the database are worse: they can leak table names, queries or other customers' data.

For 5xx errors, always send a generic body ("something went wrong on our side") plus a request id the user can quote to support. Log the real error, with the same request id, on the server.

Practitioner§06

Pagination: offset vs cursor

In one sentenceEvery list endpoint returns one page at a time, from day one. And the client should ask for the next page with "continue after this row", not "skip N rows".

Why from day one?

Remember the 2 GB GET /orders from §01. Adding pagination later is a breaking change: every existing client expects the full list in one response, and would silently show only the first page. So every collection endpoint starts paginated, with a default and a maximum page size (say 20 and 100), even if today it returns five items.

Two ways to ask for "the next page"

Offset pagination counts positions: "skip 40 rows, give me 20".

SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 40;  -- page 3

It's what most people build first: easy to write, and it supports "jump to page 7". But positions move when the data changes. If a new order arrives while you're reading page 1, every row shifts down one place, and page 2 starts with a row you've already seen. If a row is deleted, everything shifts up, and one row is never shown to you.

Cursor pagination (also called keyset pagination) remembers where you were instead: "give me 20 rows that come after order #40 in this ordering".

SELECT * FROM orders WHERE id < 40 ORDER BY id DESC LIMIT 20;  -- "after #40", newest first

New rows at the top or deleted rows above you don't matter: "after #40" still means the same place. See it happen:

Paging through a list that keeps changing
Orders, newest first, 3 per page. Fetch a page, let the shop change, fetch the next. Try both modes with the same clicks.
The orders table right now (newest first)
0·#121·#112·#103·#94·#85·#76·#67·#58·#49·#310·#211·#1
shown onceshown twiceskippedplaced after you startedsmall number = position (what OFFSET counts)
Pages the app received0 dup · 0 skipped
nothing yet
client remembers: offset = 0
Try: fetch page 1 → a new order arrives → fetch page 2. Then reset, switch to cursor, and do the same.

Try this sequence in offset mode: fetch page 1 → a new order arrives → fetch page 2. Page 2 repeats #10. Now reset and try fetch page 1 → the top order is deleted → fetch page 2: #9 is skipped and never shown. Switch to cursor mode and repeat both: no duplicates, no skips.

What the response looks like

The client shouldn't build the cursor itself. The server hands it out, ready-made, with each page:

{
  "data": [
    { "id": 107, "status": "pending", "created_at": "2026-10-30T09:30:00Z" },
    { "id": 106, "status": "paid",    "created_at": "2026-10-30T09:25:00Z" },
    { "id": 105, "status": "pending", "created_at": "2026-10-30T09:20:00Z" }
  ],
  "next_cursor": "eyJ0IjoiMjAyNi0xMC0zMFQwOToyMDowMFoiLCJpZCI6MTA1fQ"
}

The client asks for GET /orders?cursor=eyJ0Ijoi… to get the next page. When next_cursor is null, there are no more pages.

That string is an opaque cursor: Base64 of a little JSON object holding the last row's sort values, here {"t":"2026-10-30T09:20:00Z","id":105} for order 105 (§07 shows the code and where these numeric ids come from). The {data, next_cursor} wrapper around the list is called an envelope. "Opaque" is a promise to yourself: clients must treat it as a meaningless token and only send it back. Say so in the docs. Then you can change what's inside (add a field, sign it, switch databases) without breaking anyone.

What you give up with cursors

Cursor pagination isn't free. Be honest about the trade-offs when you choose:

OffsetCursor
Rows repeated or skipped while data changesyesno
Jump to page 7yesno: only next (and previous, if you also build a "before" cursor)
Cost of a deep pagegrows with the offset (§07)the same for every page
"Page 3 of 812"needs a COUNT(*), slow on big tablessame: needs a COUNT(*)
Sort by any columnyesonly by columns you built the cursor (and an index) for

"Jump to page 7" is rarely needed. Infinite scroll, "load more", syncing every order to a Data warehouse, and an SDK helper that walks through all pages all go forward one page at a time. Admin tables with page numbers over a few thousand rows are the main place offset paging is still fine.

Practitioner§07

Keyset pagination: the SQL, the code, the numbers

In one sentenceA cursor is just the sort key of the last row you sent. The next page is WHERE (sort key) < (that key), and with the right index the database jumps straight there instead of counting its way down.

This section uses SQL and database indexes. If indexes are new to you, Indexes §01 is a five-minute intro; the short version is "a sorted copy of some columns that lets the database find a row without reading the whole table".

Step 1: a sort order with no ties

Our shop lists orders newest first, by created_at. But two orders can be placed in the same second. If the cursor only says "after 09:10:00", the database can't tell which of the two 09:10:00 orders you already saw. One of them gets repeated or skipped: the very bug cursors were meant to fix.

The fix is a tiebreaker: add a unique column to the sort, so every row has exactly one position.

ORDER BY created_at DESC, id DESC   -- id breaks ties between equal created_at values

The cursor then stores both values of the last row: {"t": "2026-10-30T09:20:00Z", "id": 105}.

Step 2: "after this row", in one comparison

"Rows after (09:20, #105)" in a newest-first list means: earlier created_at, or the same created_at with a smaller id. Postgres (and MySQL, SQLite) can write that as a single row comparison:

SELECT id, status, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-10-30 09:20:00+00', 105)  -- compares created_at first, then id
ORDER BY created_at DESC, id DESC
LIMIT 21;                                                 -- 20 to show + 1 to learn "is there more?"

(a, b) < (x, y) works like comparing words in a dictionary: compare the first values; only if they're equal, compare the second ones.

Step 3: an index in the same order

CREATE INDEX orders_created_id ON orders (created_at DESC, id DESC);

With a Composite index in exactly the sort order, the database walks the index from the cursor position and stops after 21 entries. OFFSET 100000 uses the same index, but it has to walk past 100,000 entries and throw them away first. (Indexes §04 shows why a B-tree can jump to a key but can't jump to a position.)

The numbers

We loaded 1,000,000 orders into PostgreSQL 16 and asked for 20 rows at various depths. Here's EXPLAIN ANALYZE at row 100,000:

-- OFFSET: walks 100,020 index entries, keeps the last 20
 Limit (actual time=15.705..15.709 rows=20 loops=1)
   Buffers: shared hit=736 read=386
   ->  Index Scan using orders_created_id on orders (actual time=0.032..12.761 rows=100020 loops=1)
 Execution Time: 15.732 ms

-- Keyset: jumps to the cursor, reads 20 entries
 Limit (actual time=0.042..0.045 rows=20 loops=1)
   Buffers: shared hit=3 read=1
   ->  Index Scan using orders_created_id on orders (actual time=0.041..0.043 rows=20 loops=1)
         Index Cond: (ROW(created_at, id) < ROW('2025-11-09 12:00:30+00'::timestamp with time zone, 900001))
 Execution Time: 0.077 ms

Read rows=100020 vs rows=20, and Buffers (8 KB pages touched; hit = found in Postgres's memory cache, read = fetched from the operating system or disk): about 1,100 vs 4. Across depths:

Fetching 20 rows at different depths
Median of 5 warm runs of EXPLAIN ANALYZE on a 1,000,000-row orders table, PostgreSQL 16 on this site's build machine. Your numbers will differ; the shape won't.
LIMIT 20 OFFSET nkeyset: WHERE (created_at, id) < (…) LIMIT 20
page 1
0.030 ms
0.034 ms
row 1,000
0.150 ms
0.034 ms
row 10,000
1.32 ms
0.041 ms
row 100,000
12.4 ms
0.047 ms
row 500,000
62.0 ms
0.063 ms
row 999,980
127 ms
0.045 ms
Hover a row for the comparison. On the linear scale the keyset bars are too short to see; switch to log scale to see that they stay flat.

The OFFSET cost grows in a straight line with depth, and this is the friendly case: everything fits in memory, and nothing else is running. On a busy production database, deep pages from a crawler or a "sync everything" script can each hold a connection for hundreds of milliseconds.

The endpoint, in Go and Node

The page size is 3 here so the output stays short. Both files are complete programs; the outputs below come from exactly this code. Notice the two kinds of failure: a cursor the server can't decode is the client's mistake (400, don't retry), while a database error is ours (500).

list.go
package main

import (
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"log"
	"net/http"
	"os"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib" // the Postgres driver; registers "pgx" with database/sql
)

// orderRow is one row of the orders table. Here ids are database numbers (bigint),
// not the "ord_2" strings of §04's in-memory shop.
type orderRow struct {
	ID         int64     `json:"id"`
	Status     string    `json:"status"`
	TotalCents int64     `json:"total_cents"`
	CreatedAt  time.Time `json:"created_at"`
}

// cursor = "the last row the client saw". Clients treat it as an opaque string.
type cursor struct {
	CreatedAt time.Time `json:"t"`
	ID        int64     `json:"id"`
}

func encodeCursor(c cursor) string {
	b, _ := json.Marshal(c) // can't fail for this struct
	return base64.RawURLEncoding.EncodeToString(b)
}

func decodeCursor(s string) (c cursor, err error) {
	b, err := base64.RawURLEncoding.DecodeString(s)
	if err == nil {
		err = json.Unmarshal(b, &c)
	}
	return c, err
}

const pageSize = 3

func listOrders(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		after := cursor{CreatedAt: time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)} // "from the very top"
		if s := r.URL.Query().Get("cursor"); s != "" {
			var err error
			if after, err = decodeCursor(s); err != nil {
				http.Error(w, "bad cursor", http.StatusBadRequest) // the client's fault: 400
				return
			}
		}
		rows, err := db.QueryContext(r.Context(), `
			SELECT id, status, total_cents, created_at FROM orders
			WHERE (created_at, id) < ($1, $2)          -- ← start right after the last row seen
			ORDER BY created_at DESC, id DESC
			LIMIT $3`, after.CreatedAt, after.ID, pageSize+1) // one extra row = "is there more?"
		if err != nil {
			log.Println("list orders:", err)
			http.Error(w, "database error", http.StatusInternalServerError) // our fault: 500
			return
		}
		defer rows.Close()

		page := []orderRow{}
		for rows.Next() {
			var o orderRow
			if err := rows.Scan(&o.ID, &o.Status, &o.TotalCents, &o.CreatedAt); err != nil {
				http.Error(w, "database error", http.StatusInternalServerError)
				return
			}
			page = append(page, o)
		}
		if err := rows.Err(); err != nil {
			http.Error(w, "database error", http.StatusInternalServerError)
			return
		}

		var next *string // stays nil → JSON null on the last page
		if len(page) > pageSize {
			page = page[:pageSize] // drop the extra row
			last := page[pageSize-1]
			c := encodeCursor(cursor{last.CreatedAt, last.ID})
			next = &c
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]any{"data": page, "next_cursor": next})
	}
}

func main() {
	db, err := sql.Open("pgx", os.Getenv("DATABASE_URL")) // e.g. postgres://localhost/shop
	if err != nil {
		log.Fatal(err)
	}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders", listOrders(db))
	log.Fatal(http.ListenAndServe("localhost:8080", mux))
}

Real output from the Go server (some fields trimmed to …), against a 7-row table with two orders placed at the same second (#103 and #104):

$ curl -s localhost:8080/orders
{"data":[{"id":107,…,"created_at":"2026-10-30T09:30:00Z"},{"id":106,…},{"id":105,…,"created_at":"2026-10-30T09:20:00Z"}],
 "next_cursor":"eyJ0IjoiMjAyNi0xMC0zMFQwOToyMDowMFoiLCJpZCI6MTA1fQ"}

$ echo 'eyJ0IjoiMjAyNi0xMC0zMFQwOToyMDowMFoiLCJpZCI6MTA1fQ==' | base64 -d   # add back the == padding RawURLEncoding drops
{"t":"2026-10-30T09:20:00Z","id":105}

$ curl -s 'localhost:8080/orders?cursor=eyJ0IjoiMjAyNi0xMC0zMFQwOToyMDowMFoiLCJpZCI6MTA1fQ'
{"data":[{"id":104,…,"created_at":"2026-10-30T09:10:00Z"},{"id":103,…,"created_at":"2026-10-30T09:10:00Z"},{"id":102,…}],
 "next_cursor":"eyJ0IjoiMjAyNi0xMC0zMFQwOTowNTowMFoiLCJpZCI6MTAyfQ"}

$ curl -s 'localhost:8080/orders?cursor=eyJ0IjoiMjAyNi0xMC0zMFQwOTowNTowMFoiLCJpZCI6MTAyfQ'
{"data":[{"id":101,…}],"next_cursor":null}

Both 09:10:00 orders appear exactly once, in a fixed order, thanks to the id tiebreaker.

And the two failure paths, from the Node version. The first cursor is valid base64 but decodes to {"t":"nope","id":"x"}; for the second request, the server's database was stopped:

$ curl -s -w ' → %{http_code}\n' 'localhost:8080/orders?cursor=eyJ0Ijoibm9wZSIsImlkIjoieCJ9'
bad cursor → 400
$ curl -s -w ' → %{http_code}\n' localhost:8080/orders
database error → 500

# and in the server's own log:
list orders: connect ECONNREFUSED 127.0.0.1:15400

(-w ' → %{http_code}\n' makes curl print the status code after the body.)

Walking every page from a client

Clients that need everything (a sync job, an export) loop until next_cursor is null. In JavaScript, an async generator hides the paging so the caller sees one stream of orders:

walk.mjs
async function* allOrders(base) {
  let cursor = null;
  do {
    const url = new URL("/orders", base);
    if (cursor) url.searchParams.set("cursor", cursor);
    const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const page = await res.json();
    yield* page.data;          // hand out every order on this page
    cursor = page.next_cursor; // null on the last page → loop ends
  } while (cursor);
}

for await (const o of allOrders("http://localhost:8080")) {
  console.log(o.id, o.status, o.created_at);
}
Practitioner§08

Changing an API without breaking anyone

In one sentenceAdd, don't change. New fields, new endpoints and new optional inputs are free. Renames, removals, type changes and new rules are breaking changes, and need a migration, not a deploy.

What old clients actually see

Back to the rename from §01. The server now sends {"id":"ord_2","total_cents":4850,"discount":0}, and here's what code written against the old total field does with it. Real output:

old_app.go
type Order struct {
	ID    string `json:"id"`
	Total int64  `json:"total"` // the app's model, written when the API sent "total"
}

var o Order
err := json.Unmarshal([]byte(body), &o)
fmt.Printf("err=%v  order=%+v\n", err, o)
// err=<nil>  order={ID:ord_2 Total:0}      ← no error! Total is silently 0

dec := json.NewDecoder(strings.NewReader(body))
dec.DisallowUnknownFields() // a strict reader
fmt.Println("strict:", dec.Decode(&o))
// strict: json: unknown field "total_cents"

Two lessons in one example:

  1. Removing or renaming a field doesn't fail loudly. Go fills in the Zero value, JavaScript gives undefined, and the user sees $0.00 or $NaN. Your error dashboards stay green while customers complain.
  2. Unknown fields are ignored by default, in both languages. Only discount and total_cents were new, and the default decoder didn't mind. A client that rejects unknown fields (DisallowUnknownFields) breaks every time the server adds anything.

That second behaviour has a name: a tolerant reader. Clients should read the fields they need and ignore the rest. It's what makes additive changes safe, and it's why you should never turn on strict decoding for API responses in client code. (On the server, rejecting unknown request fields is a reasonable choice: it catches typos like quantiy early. But then you can't remove a request field without breaking clients that still send it.)

Safe or breaking? Drill it

Some are obvious; a few catch experienced teams:

Safe or breaking?
For each change to the shop API, decide whether an existing, correctly written client could break.
0/0 right
Add a field discount_cents to the order response
Rename total to total_cents
Add a new endpoint GET /orders/{id}/invoice
Make the request field shipping_address required
Add an optional request field gift_note
Change id from a number 42 to a string "ord_42"
Add a new status value "refunded"
Lower the max quantity per item from 100 to 10
Change the default page size from 50 to 20
Reword an error's detail text

The pattern behind the answers: a change is safe if every response an old client could get is still one it understands, and every request it sends still succeeds with the same meaning. Behaviour counts too: default page sizes, sort orders and which errors happen are all part of the contract.

Renaming safely: expand, migrate, contract

You can rename total to total_cents. It just takes three releases instead of one, the same expand / contract recipe used for database columns (SQL vs NoSQL §12):

Renaming a field without breaking anyone
server sends
total
total + total_cents (expand)
total_cents
new app
ships, reads total_cents
old apps
read total
still read total, slowly upgrading…
the last few % break
you
announce deprecation
measure who reads total
remove (contract)
Time runs left to right. The old field is removed only once (almost) no client reads it, and you find that out by measuring, not guessing.
  1. Expand: send both fields with the same value. Accept both in requests.
  2. Migrate: ship clients that use the new name. Mark the old one deprecated in the docs and in the machine-readable API description (an OpenAPI file, §11).
  3. Measure: log which clients (API key, app version from User-Agent) still use the old field or endpoint.
  4. Contract: remove the old field once usage is low enough that you accept the remaining breakage, or never. For a public API, "never" is a common and perfectly respectable answer.

Announcing a removal: Deprecation and Sunset

Deprecation means "this still works, but it's going away: move off it". Docs and emails get missed, so announce it in the responses themselves, where client developers' tools and logs can see it. Two standard headers do this:

deprecated.go
// deprecated wraps a handler to announce that it's going away.
func deprecated(next http.Handler, since, sunset time.Time, docs string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h := w.Header()
		h.Set("Deprecation", fmt.Sprintf("@%d", since.Unix()))      // RFC 9745: deprecated since…
		h.Set("Sunset", sunset.UTC().Format(http.TimeFormat))         // RFC 8594: may stop working after…
		h.Add("Link", fmt.Sprintf(`<%s>; rel="deprecation"`, docs)) // where to read more
		slog.Info("deprecated endpoint used", "path", r.URL.Path, "client", r.Header.Get("User-Agent"))
		next.ServeHTTP(w, r)
	})
}

mux.Handle("GET /v1/orders/{id}", deprecated(http.HandlerFunc(getOrderV1), // HandlerFunc turns a func into an http.Handler
	time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC),
	time.Date(2027, 5, 1, 0, 0, 0, 0, time.UTC),
	"https://docs.shop.example/migrate-v2"))
Deprecation: @1793491200
Sunset: Sat, 01 May 2027 00:00:00 GMT
Link: <https://docs.shop.example/migrate-v2>; rel="deprecation"
INFO deprecated endpoint used path=/v1/orders/ord_2 client="ShopApp/3.4 (iOS)"

When you must break: versioning

Sometimes a change can't be made additive: the old design was wrong, or a security fix changes behaviour. Then you need API versioning: serve old and new behaviour side by side, and let each client choose. Three common ways to carry the choice:

StyleLooks likeGoodBad
URL/v1/orders, /v2/ordersObvious; easy to route, cache, log and try in a browserClients must change every URL; tempting to make v2 a total rewrite
Header / media typeAccept: application/vnd.shop.v2+json (a custom "vendor" media type: vnd. marks it as yours, +json says it's still JSON)URLs stay stableInvisible in logs and browser address bars; easy to forget
Dated versionsStripe-Version: 2024-06-20Each client is pinned to the date it was built against; many small changes instead of one big v2You need a layer that translates between versions, forever

Most teams should use URL major versions, and as few of them as possible. Each live version is a copy of the API you must test, document, secure and keep running. Stripe-style dated versions work beautifully, but only because Stripe built serious machinery for them. As Stripe has described publicly, their code is written for the newest version, and a chain of small "version change" modules transforms each response back to whatever date the client is pinned to.

Practitioner§09

REST vs GraphQL vs gRPC

In one sentenceREST, GraphQL and gRPC are three answers to "who decides the shape of the response?": the server per URL, the client per query, or a shared typed contract. Each answer moves cost somewhere else.

A quick introduction to the two newcomers:

  • GraphQL: one endpoint (POST /graphql). The client sends a query that lists exactly the fields it wants, nested as deep as it likes, and the server returns JSON in exactly that shape.
  • gRPC: you describe functions and messages in a .proto file, a tool generates client and server code, and the client calls GetOrder(...) like a local function. Messages travel as compact binary Protocol Buffers over HTTP/2. The gRPC topic covers it in depth.

One screen, four ways

The shop app's order screen shows the status, the total, each item's name and quantity, and the customer's name. Here's that screen's data fetched four ways:

One screen, four ways
The order screen needs: status, total, item names and quantities, and the customer's name. Same data, different API styles.
request 1
GET /orders/ord_42
response477 B
{"id":"ord_42","status":"paid","customer_id":"c7","currency":"USD","total_cents":4850,"discount_cents":0,"tax_cents":0,"created_at":"2026-10-30T09:30:00Z","updated_at":"2026-10-30T09:31:12Z","items":[{"sku":"MUG-BLUE","name":"Blue mug","quantity":2,"unit_price_cents":1200},{"sku":"TEA-1KG","name":"Green tea 1 kg","quantity":1,"unit_price_cents":2450}],"shipping_address":{"name":"Ada Lovelace","line1":"12 St James's Sq","city":"London","postcode":"SW1Y 4JH","country":"GB"}}
request 2
GET /customers/c7

← can only start after request 1
  returns customer_id
response177 B
{"id":"c7","name":"Ada Lovelace","email":"ada@example.com","phone":"+44 20 7946 0000","created_at":"2024-02-01T10:00:00Z","marketing_opt_in":false,"default_address_id":"addr_3"}
response bytes654 B
round trips2, one after the other (under-fetching)
from a browseryes, plain fetch
HTTP cachingyes: GET URLs cache in browsers and CDNs
contractOpenAPI (optional)
Each endpoint returns a fixed, general-purpose shape. The screen needs 6 values and receives every field of the order and the customer (over-fetching), and it needs a second request it can only start after the first one returns (under-fetching).
Byte counts are the JSON bodies as shown (no headers, no compression). The protobuf size was measured by encoding the same order with Go's protowire package (the same fields as JSON would be 167 B); gRPC adds a 5-byte frame header on top.

Two words to take from that comparison:

  • Over-fetching: the plain REST response carries every field of the order and the customer, though the screen needs six values. That's wasted bytes on a phone plan and wasted work on the server.
  • Under-fetching: the customer's name needs a second request, which can only start after the first returns (it needs customer_id). Each extra round trip on a mobile network can cost 100–300 ms.

GraphQL's bill arrives on the server

GraphQL makes the client's life easy by making the server's harder. Each field is filled in by a resolver function, and resolvers run once per object. Here's a real run with graphql-js, where customer is resolved separately for each order in a list of 3:

gql.mjs
const rootValue = {
  orders: ({ first }) =>
    db.orders(first).then((rows) =>
      rows.map((o) => ({ ...o, customer: () => db.customer(o.customerId) })), // ← one query per order
    ),
};
await graphql({ schema, rootValue, source: `{ orders(first: 3) { id customer { name } } }` });
SQL: SELECT … FROM orders LIMIT 3
SQL: SELECT … FROM customers WHERE id = c7
SQL: SELECT … FROM customers WHERE id = c3
SQL: SELECT … FROM customers WHERE id = c7
queries: 4

That's the N+1 query problem: 1 query for the list, plus 1 per order (and customer c7 was fetched twice). With first: 100 it's 101 queries. The standard fix is DataLoader, which batches all the customer lookups made during the same turn of the Event loop into a single WHERE id IN (…) query; N+1 §08 builds it.

Two more costs to plan for:

  • Expensive queries. A client can write { customers { orders { items { product { reviews { author { orders … } } } } } } }. Public GraphQL APIs compute a query cost before running anything and reject queries over a budget. GitHub's GraphQL API does this, for example.
  • Caching. Every query is a POST to the same URL, so browsers and CDNs can't cache responses the way they cache GET /products/7. The usual workaround is persisted queries: the app registers its queries ahead of time and sends GET /graphql?id=orderScreen&variables=….

gRPC: fast, typed, and awkward for browsers

The same operation as a gRPC contract:

shop/v1/orders.proto
syntax = "proto3";
package shop.v1;

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);
  rpc WatchOrder(GetOrderRequest) returns (stream OrderEvent); // server streaming
}

message GetOrderRequest { string id = 1; }

message Order {
  string id = 1;
  Status status = 2;
  repeated Item items = 3;
  Customer customer = 4;
  int64 total_cents = 5;
}
message Item { string name = 1; int32 quantity = 2; }
message Customer { string name = 1; }
message OrderEvent { Status status = 1; }
enum Status { STATUS_UNSPECIFIED = 0; STATUS_PENDING = 1; STATUS_PAID = 2; STATUS_SHIPPED = 3; }

The numbers (= 1, = 2) identify fields on the wire instead of names, so you can rename a field in the .proto without breaking anyone, but must never reuse a number (gRPC §05). Enum values carry the type's name as a prefix (STATUS_PAID) because in protobuf they all share one namespace; a later enum PaymentStatus { PAID = 1; } would otherwise clash. Encoded, the Order above is 63 bytes; the same fields as JSON are 167 bytes (we measured both). Browsers, however, can't speak gRPC directly: they need gRPC-Web or Connect (a gRPC-compatible protocol that also works over plain HTTP from a browser), plus a proxy or a server that supports it.

When each one wins

REST + JSONGraphQLgRPC
Best atPublic APIs, simple resources, anything cacheableMany different screens over richly connected dataService-to-service calls you control on both ends
Response shape decided bythe server, per endpointthe client, per querythe shared .proto
HTTP caching / CDNsbuilt in for GETneeds persisted queriesnone
Try it with curlyesyes (verbose)needs grpcurl (a curl-like tool for gRPC)
Streamingvia SSE or WebSocketssubscriptions: a query that keeps sending updates (usually over WebSockets)built in, both directions
Main server-side costmany endpoints to design and versionresolvers, N+1, query cost limitscodegen, HTTP/2, proxies for browsers

A sensible default for a product like the shop: REST + JSON for the public API (partners and scripts expect it), gRPC between internal services if you have many of them, and GraphQL only when many different client screens keep asking for new endpoint shapes. Often a backend for frontend (one small server per app that calls the internal APIs and returns exactly what each screen needs) solves the same problem with less machinery.

Practitioner§10

Reliability, designed into the API

In one sentenceNetworks drop requests, clients retry, some work is slow, and some events happen when nobody is asking. A good API has a designed answer for each, so every client doesn't invent its own.

1. Retries: idempotency keys

A phone sends POST /orders/ord_42/payments, then loses signal before the response arrives. Did the payment happen? The app can't know. If it retries a plain POST, the customer may be charged twice.

The fix is part of the API's design: the client generates a unique id for this intent (one checkout, not one HTTP attempt) and sends it as an Idempotency key header. The server remembers each key with the response it produced. A retry with the same key gets the saved response back, and the work is never done twice.

POST /orders/ord_42/payments
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json

{"method": "card", "amount_cents": 4850}

As the API designer, your decisions are: which endpoints accept a key (every POST that creates something or moves money), how long keys are kept (Stripe keeps them 24 hours), and what errors mean (409 while the first attempt is still running, 422 if the same key arrives with a different body). Idempotency §05 builds the full server side, including the database table and the race conditions.

2. Rate limits: tell clients where they stand

Every public API limits how fast each client may call it. The API design question is how you say so. Reject with 429 and a Retry-After header, and send rate-limit headers on every response so well-behaved clients can slow down before they hit the wall:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 2
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=0;t=2

{"type":"https://api.shop.example/problems/rate-limited","title":"Too many requests","status":429,
 "detail":"This API key may make 100 requests per minute."}

The RateLimit-* header syntax is still an IETF draft and has changed between versions; many APIs send X-RateLimit-Remaining and friends instead. Pick one, document it, and keep it stable. Rate limiting §09 covers the headers and a client that backs off politely.

3. Timeouts: on both sides

  • Clients must set a Timeout on every call (AbortSignal.timeout, http.Client{Timeout: …}). Your SDK and docs should do it by default.
  • Servers should bound how long any request may run, and document it: "requests that take longer than 30 s are cancelled". Load balancers and proxies often cut requests after 30–60 seconds anyway, whatever your code does (HTTP §11).

Which raises the next question: what if the work genuinely takes three minutes?

4. Slow work: 202 Accepted and a status resource

Don't hold the request open. Accept the job, answer at once, and give the client a URL to check on it. This is the long-running operation pattern:

A 3-minute job behind a 50-ms API
202 Accepted, a status resource, and polling with Retry-After.
Code
1const sleep = (ms) =>
2 new Promise((r) => setTimeout(r, ms));
3
4async function runExport(params) {
5 const start = await fetch("/exports", {
6 method: "POST",
7 headers: { "Content-Type": "application/json" },
8 body: JSON.stringify(params),
9 });
10 if (!start.ok) throw new Error(`HTTP ${start.status}`);
11 const url = start.headers.get("Location");
12
13 while (true) {
14 const res = await fetch(url);
15 if (!res.ok) throw new Error(`HTTP ${res.status}`);
16 const job = await res.json();
17 if (job.status === "succeeded")
18 return job.result_url;
19 if (job.status === "failed")
20 throw new Error(job.error.detail);
21 const secs = res.headers.get("Retry-After") ?? 2;
22 await sleep(secs * 1000); // wait, then poll again
23 }
24}
client
POST /exports {from: "2026-01-01"}
API server
empty
background worker
idle
The client asks for an export of every order this year. Building that file takes minutes, far longer than any HTTP request should stay open (proxies and load balancers often cut idle requests after 30–60 s).
1 / 7

The status resource is a normal resource, so all the earlier rules apply: it has a stable shape (status: queued | running | succeeded | failed, an optional progress, a result_url or a problem-details error); it's GET-able, so retries are free; and it should stay around for a while after the job finishes, so a client that was offline can still find the result.

5. Webhooks: when the API calls the client

Polling is wasteful when events are rare. A webhook reverses the direction: the customer registers a URL (https://partner.example/hooks/shop), and your server POSTs to it when something happens (order.paid, export.succeeded).

That URL is public, so anyone on the internet can POST fake "order.paid" events to it. The receiver needs proof that a request really came from you and wasn't changed. The standard tool is an HMAC signature: you and the customer share a secret, you compute HMAC-SHA256(secret, id + "." + timestamp + "." + body) and send the result as a header, and the receiver recomputes it and compares.

webhook.go
// sign is what the SENDER (our shop) does before POSTing the webhook.
func sign(secret []byte, id string, ts int64, body []byte) string {
	mac := hmac.New(sha256.New, secret)
	fmt.Fprintf(mac, "%s.%d.", id, ts) // id and timestamp are signed too
	mac.Write(body)
	return "v1=" + hex.EncodeToString(mac.Sum(nil))
}

// verify is what the RECEIVER does before trusting the body.
func verify(secret []byte, id, tsHeader, sigHeader string, body []byte, now time.Time) error {
	ts, err := strconv.ParseInt(tsHeader, 10, 64)
	if err != nil {
		return errors.New("bad timestamp")
	}
	if age := now.Sub(time.Unix(ts, 0)); age > 5*time.Minute || age < -5*time.Minute {
		return errors.New("timestamp too old or in the future") // blocks replays
	}
	want := sign(secret, id, ts, body)
	if !hmac.Equal([]byte(want), []byte(sigHeader)) { // constant-time compare
		return errors.New("signature mismatch")
	}
	return nil
}

We signed a webhook with the Go sign and checked it with both verifiers. Real output:

Webhook-Signature: v1=cfff3f8ec75dabbbce635e22cd09bebe8d9ef16ede5342e7bce5412ab787e174
genuine:   <nil>
tampered:  signature mismatch
replayed:  timestamp too old or in the future
JS verified the Go signature ✓
re-ordered keys: signature mismatch

Now try to get a forged webhook past the receiver. The signatures below are computed live in your browser with real HMAC-SHA256:

Verify a webhook
A webhook arrives claiming to be from our shop, signed with HMAC-SHA256 over id.timestamp.body. You are the receiver: you hold the real shared secret. Try to get a fake one past the checks.
What arrivedsigned by: the shop (real key)
Webhook-Id: evt_1Webhook-Timestamp: 1793352000Webhook-Signature: v1=…
Receiver's checks
receiver's secret: whsec_test_123 (never changes)
✓ timestamp within 5 minutes✗ recomputed signature matchesrecomputed: v1=…
Reject with 401. The body changed after the shop signed it. The signature covers the exact bytes, so any change, even reordering JSON keys, gives a completely different HMAC. That's why receivers must verify the raw body before parsing it.
Staff-level§11

Staff-level: contracts, guidelines and compatibility checks

In one sentenceAt scale, API quality stops being about one good engineer and becomes about machinery: one written contract, a style guide a robot can check, and a CI gate that won't merge a breaking change by accident.

API-first: write the contract down

In §07 our Go and Node servers returned the same order with different types (107 vs "107"). Nobody decided that; it just happened, because the contract only existed in people's heads. OpenAPI fixes that: a YAML file that describes every path, parameter, response and schema. In an API-first team, that file is written and reviewed before the code:

openapi.yaml
openapi: 3.1.0
info: { title: Shop API, version: 1.4.0 }
servers: [{ url: https://api.shop.example }]
security: [{ bearerAuth: [] }]   # every endpoint needs a bearer token (e.g. a JWT)
paths:
  /orders/{id}:
    get:
      operationId: getOrder
      summary: Get one order
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, example: ord_42 } }
      responses:
        "200":
          description: The order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "404":
          description: No such order (or not yours)
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer }
  schemas:
    Order:
      type: object
      required: [id, status, total_cents, created_at]
      properties:
        id: { type: string, example: ord_42 }        # a string, decided once, for every server
        status:
          type: string
          description: "Open enum: more values may be added. Show unknown ones as 'Processing'."
        total_cents: { type: integer, format: int64, minimum: 0 }
        total: { type: integer, deprecated: true, description: Use total_cents. Removed after 2027-05-01. }
        created_at: { type: string, format: date-time }
    Problem:
      type: object
      required: [type, title, status]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }

That file passes redocly lint with its recommended rules (one warning: no license field). Everything else is then derived from the file:

API-first: the spec comes first, everything else is derived
openapi.yaml
the contract, reviewed in a pull request like code
lint
Spectral checks your style guide: naming, error shape, pagination
generate
server interfaces (oapi-codegen) and typed clients (openapi-typescript)
mock
a fake server from the spec, so the app team starts on day one
implement
handlers fill in the generated interfaces
CI gate
oasdiff compares with main and fails the build on breaking changes
One file is the source of truth. Docs, clients, server stubs, mocks and the breaking-change check all come from it, so they can't drift apart.
  • Code generation: oapi-codegen turns the spec into Go types and a server interface your handlers implement, so a handler that returns the wrong shape doesn't compile. openapi-typescript produces TypeScript types for the web app.
  • Linting: Spectral (or Redocly) checks the spec against your style guide: every list is paginated, every error is application/problem+json, every property is snake_case.
  • Mock servers let the app team build against the API on day one.
  • Docs come from the same file, so they can't go stale.

A CI gate for breaking changes

Since the contract is a file, a tool can compare the version in a pull request with the one on main. We removed the deprecated total field and added a required currency query parameter, then ran oasdiff (v1.11.7):

$ oasdiff breaking openapi.yaml openapi-next.yaml --fail-on ERR
2 changes: 1 error, 1 warning, 0 info
error	[new-required-request-parameter] at openapi-next.yaml
	in API GET /orders/{id}
		added the new required 'query' request parameter 'currency'

warning	[response-optional-property-removed] at openapi-next.yaml
	in API GET /orders/{id}
		removed the optional property 'total' from the response with the '200' status

$ echo $?
1

The exit code 1 fails the build, but look at why: only because of currency. Removing total is just a warning, and with --fail-on ERR a pull request that does nothing but remove it passes:

$ oasdiff breaking openapi.yaml removed-total.yaml --fail-on ERR
1 changes: 0 error, 1 warning, 0 info
warning	[response-optional-property-removed] at removed-total.yaml
	in API GET /orders/{id}
		removed the optional property 'total' from the response with the '200' status

$ echo $?
0

The tool's logic: total was never in the required list, so the spec promised clients only that it might be there. That's exactly the §01 outage, waved through. Two fixes, both checked with oasdiff:

  • Fail on warnings too: --fail-on WARN exits 1 for the same diff. Expect to mark some warnings as accepted on purpose.
  • Make the spec tell the truth: if a field is always sent, list it in required. Then removing it is reported as error [response-required-property-removed], and --fail-on ERR catches it.

Note also what the first run did not flag: we also added a new currency property to the response and marked it required. For a response, "required" means "always present", which can only help readers, so that's an additive change. The tool knows the asymmetry from §08: requests may only get looser, responses may only get richer.

A spec diff only checks the spec. To check that servers really behave as the spec says, add contract tests: run each server in CI and validate real responses against the schemas (tools like Schemathesis generate the requests from the spec). Large teams also replay a sample of recorded production requests against the new version and diff the responses.

A style guide, so every endpoint feels the same

Clients learn your API once, then guess. Make the guesses right. A real guide is a few pages; these decisions matter most:

Decide onceOur shop's choice
Namingsnake_case JSON, plural collection nouns, POST /…/{id}/verb for actions
IDsprefixed strings (ord_…, pay_…), never numbers
Moneyan integer count of minor units (cents for USD) plus a currency: {"total_cents": 4850, "currency": "USD"}. Never floats: 0.1 + 0.2 is 0.30000000000000004
TimeRFC 3339 strings in UTC: "2026-10-30T09:30:00Z"
Listsalways paginated with ?cursor= / next_cursor, default 20, max 100
Errorsproblem details with a stable type URL (§05)
Enumsopen, with documented fallback behaviour (§08)
Absent vs nullabsent = "not included in this response"; null = "known to be empty". Document per field

Filtering, sorting and sparse fieldsets

List endpoints grow query parameters. Keep them predictable:

GET /orders?status=paid&created_after=2026-10-01T00:00:00Z     # filters: field names, ANDed together
GET /orders?sort=-created_at                                    # sort: field name, "-" for descending
GET /orders/ord_42?fields=id,status,total_cents                 # sparse fieldset
GET /orders/ord_42?expand=customer                              # inline a related resource

Every filter and sort you offer is a promise to keep it fast, which means an index for each combination clients use (Indexes §05), and a cursor format that includes the sort key (§07). So offer only the ones real screens need, and reject unknown sort fields with a 400 instead of silently ignoring them. Sparse fieldsets and expand give you most of GraphQL's flexibility (§09) while keeping cacheable GETs.

Where authentication and authorization go

Two different checks, in two different places (AuthN vs AuthZ covers the mechanisms):

  • Authentication ("who is calling?") belongs at the edge, in one shared piece of middleware or an API gateway, so no endpoint can forget it. It turns a token from the Authorization header into a caller identity. Credentials go in headers, never in URLs: URLs end up in logs, browser history and Referer headers.
  • Authorization ("may this caller touch this order?") needs the order, so it belongs in the handler or the service layer (the code between handlers and the database that holds the business rules), next to the data. Forgetting it is the top API security bug, called BOLA (broken object-level authorization): the login check passes, and /orders/ord_43 happily shows someone else's order.
o, err := store.GetOrder(ctx, r.PathValue("id"))
if err != nil && !errors.Is(err, ErrNotFound) {
	writeProblem(w, internalError) // the database failed: 500, and log err
	return
}
if err != nil || o.CustomerID != caller.CustomerID {
	writeProblem(w, notFound) // ← same answer for "missing" and "not yours": don't reveal it exists
	return
}

Prefixed, random-looking ids (ord_8Kx2… rather than 42) also make guessing harder, but they're not a substitute for the check.

Two clients editing the same order

A support agent and the customer both open order ord_42 and both send a PATCH. The second write silently overwrites the first. Optimistic concurrency lets the API catch this without locking:

GET /orders/ord_42                    →  200 OK, ETag: "v7"
PATCH /orders/ord_42  If-Match: "v7"  →  200 OK, ETag: "v8"         (first writer wins)
PATCH /orders/ord_42  If-Match: "v7"  →  412 Precondition Failed    (second writer: re-read and retry)

The ETag is a version label for the resource (a row-version counter works). The server applies the write only if the client's If-Match still matches, ideally inside the same database update: UPDATE orders SET … , version = version + 1 WHERE id = $1 AND version = $2.

Practitioner§12

Pitfalls gallery

#1

A list endpoint with no limit

It's fine at launch and a 2 GB response three years later (§01). Adding pagination afterwards breaks every client that expects the full list. Every collection endpoint is paginated from its first release, with a default and a maximum page size (§06).

#2

Your database table, served as JSON

✕ Don't
{"order_id": 42, "cust_fk": 7, "is_del": 0,
 "status_cd": 3, "upd_ts": 1793352000,
 "internal_notes": "VIP, refund freely"}
✓ Do
{"id": "ord_42", "customer_id": "c7",
 "status": "shipped",
 "updated_at": "2026-10-30T09:20:00Z"}

Exposing columns as-is freezes your schema (now you can't rename cust_fk without a breaking change), exposes internal codes clients must decode, and sooner or later leaks a field that was never meant to leave the building. Map table rows to API types that you design on purpose (§02).

#3

Money as a float

{"total": 48.5} invites 0.1 + 0.2 === 0.30000000000000004 bugs in every client, and says nothing about the currency. Send integer minor units and a currency: {"total_cents": 4850, "currency": "USD"}. (Some APIs send decimal strings like "48.50"; also fine. Floats are not.)

#4

GET that changes things

GET /orders/ord_42/cancel looks convenient in an email link. Then a link-preview bot, an antivirus scanner or the browser's prefetching visits it, and the order is cancelled. GET must be safe (§03): use POST for anything that changes state, and put a confirmation page behind email links.

#5

A chatty API

If showing one screen takes 1 + N requests (the order, then each item's product, then each product's image), every phone pays N extra round trips. Design responses around what screens need: embed small related data, offer expand= (§09), or add a purpose-built endpoint or a backend for frontend.

#6

Clients that reject what they don't know

A client with DisallowUnknownFields, a strict schema validator on responses, or a switch on status with no default case turns every additive change into an outage (§08). Be a tolerant reader, and ship your SDKs that way.

#7

Every error is 500, or every response is 200

500 for a validation failure pages your on-call engineer and makes clients retry a request that will never succeed. 200 {"success": false} makes every monitoring tool and retry library think things are fine. Status codes are for machines; use them (§03).

#8

Ambiguous timestamps

"created": "10/11/26 09:30": is that October or November? Which time zone? Use RFC 3339 in UTC ("2026-10-30T09:30:00Z") everywhere, and name fields so their meaning is obvious (created_at, expires_at).

#9

Starting at /v2 for every small change

Each version is a full copy of the API to run, test, document and secure. Teams that bump the major version for every rename end up maintaining four APIs. Exhaust additive options first (new field, new endpoint, new optional parameter), and treat v2 as a rare, planned migration (§08).

#10

Checking that the user is logged in, but not that the order is theirs

The single most common API vulnerability (BOLA). Every handler that loads an object by id must check that the caller may see that object, and answer 404 when they may not (§11).

Practitioner§13

Check yourself

Predict the answer before you click. Q1 is covered in §01 and §08, Q2 in §03, Q3 in §06, Q4 in §08, Q5 in §10 and Q6 in §09.

QUESTION 1 / 6
What does the old app show?

App v3.4 has this code. The server just renamed total to total_cents.

const order = await (await fetch("/orders/ord_2")).json();
label.textContent = `$${(order.total / 100).toFixed(2)}`;
QUESTION 2 / 6
Pick the status

POST /orders/ord_1/cancel. The request is well-formed, the user owns the order, but it has already shipped.

QUESTION 3 / 6
Offset paging, one insert

Newest first, 3 per page. You fetched page 1 (orders #12, #11, #10). Then order #13 is placed. You fetch OFFSET 3 LIMIT 3. What do you get?

QUESTION 4 / 6
Safe or breaking?

You add "refunded" as a new possible value of order.status. The docs never said the list could grow.

QUESTION 5 / 6
Why did verification fail?

The receiver uses verify from §10 (it throws on a bad signature) like this, and every genuine webhook is rejected with 401.

app.post("/hooks", express.json(), (req, res) => {
  try {
    verify(secret, req.headers, JSON.stringify(req.body)); // §10's verify: throws if bad
  } catch {
    return res.sendStatus(401);
  }
  // … handle the event
});
QUESTION 6 / 6
Which wins here?

Twenty internal Go services call each other thousands of times per second. You control every client and server, and want typed clients and streaming.

Answered 0 / 6
Foundations§14

Cheat sheet

📜
It's a contractClients you don't control depend on every name, type and behaviour. Add freely; change nothing quietly.
🏷️
Nouns in URLs/orders, /orders/{id}, /orders/{id}/items. Methods say what to do. Real actions: POST /orders/{id}/cancel.
🚦
Status codes for machines201 + Location, 202 for slow work, 204 empty. 400/422 bad input, 409 wrong state, 404 not found or not yours, 429/503 + Retry-After.
🧯
One error shapeapplication/problem+json: stable type to branch on, detail for humans, field errors with JSON Pointers. No stack traces.
📑
Paginate from day oneDefault and max page size. Opaque cursor with a tiebreaker (created_at, id), keyset SQL, matching index.
🧩
Evolve by addingNew optional fields and endpoints are safe. Renames: expand → migrate → measure → contract. Open enums.
🔁
Retries and slow workIdempotency-Key on POSTs that create or charge. 202 + status resource + Retry-After. Signed webhooks.
📐
Write it downOpenAPI spec reviewed first; lint, codegen, mocks; a breaking-change check in CI.
I want to…Do this
Create somethingPOST /things → 201 Created, Location: /things/{id}, body = the new thing
Report invalid input422 (or 400) problem details with errors: [{pointer: "#/items/1/quantity", detail}]
Page through a list?cursor=… → {data: [...], next_cursor}; SQL WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT n+1
Rename a fieldSend both → ship clients using the new one → measure → remove the old one (or never)
Retire an endpointDeprecation: @<unix time>, Sunset: <HTTP date>, Link: <docs>; rel="deprecation", and log who still calls it
Make a POST safe to retryClient sends Idempotency-Key: <uuid>; server stores key + response (Idempotency)
Run a 3-minute job202 Accepted + Location: /jobs/{id}; client polls with Retry-After, or gets a webhook
Prove a webhook is genuineHMAC-SHA256 over id.timestamp.rawBody; constant-time compare; reject > 5 min old; dedup by id
Choose a stylePublic: REST + JSON. Internal service-to-service: gRPC. Many varied screens on connected data: GraphQL or a BFF
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Software ArchitectureCaching Strategies
Read →