Skip to content

API design

REST, GraphQL, gRPC, webhooks and versioning, and which problem each one actually solves.

01

REST and resource modelling

beginner

REST models a system as resources addressed by URL, with HTTP methods describing the action and status codes describing the outcome. The name comes from Roy Fielding's 2000 dissertation, which describes an architectural style with six constraints. Almost nothing called RESTful today satisfies all six, and that is fine: the constraint that pays for itself in practice is the uniform interface, the agreement that GET means read, that 404 means the thing is not there, and that these mean the same on your API as on everyone else's.

The reason to care is that a large amount of infrastructure acts on those meanings without ever reading your code. RFC 9110, the current HTTP semantics specification, divides methods into safe ones that do not change state (GET, HEAD, OPTIONS, TRACE) and idempotent ones that can be repeated without additional effect (those four plus PUT and DELETE). POST is neither. A CDN will cache a GET response at the edge. A proxy, a load balancer or a client library will retry an idempotent request after a dropped connection. Neither will do anything of the sort for a POST, because you have told the network that repeating it might charge a card twice.

Status codes are the other half of that contract, and the common failure is returning 200 with an error inside the body. Every layer above you now believes the call succeeded: the load balancer keeps the backend in rotation, the retry policy never fires, the dashboard shows a healthy service, and the only place the failure exists is in a field that nothing except your own client parses. A 5xx is not an admission of guilt, it is how you tell the rest of the system what happened.

Resource modelling is where people get stuck, usually on actions that are not obviously nouns. Publishing an article, refunding a payment, resending an invite. The trick that works most of the time is to notice that the action produces a thing, and to make that thing the resource: POST /payments/123/refunds creates a refund, which has its own id, its own status and its own history, all of which you wanted anyway the first time someone asked why a refund failed. Where that genuinely does not fit, a verb endpoint is better than a contorted noun. Consistency is worth more than purity.

Getting the method wrong has a measurable price. A search implemented as POST /search cannot be cached by a CDN or the browser, cannot be bookmarked or shared, will not be retried by anything in the path, and in a browser it triggers a CORS preflight because a JSON content type is not on the short list of simple requests, which adds a full round trip before the real request leaves. The honest reason to use POST for search is a query too large for a URL, where roughly 2,000 characters is the safe practical ceiling across proxies and servers. That is a real constraint, and it deserves a comment in the code saying so.

Pagination deserves the same care, because it is the endpoint most likely to be hit by a script. Offset pagination, page=47, gets slower the deeper it goes, since the database still has to walk the rows it is about to discard, and it produces duplicates and gaps when rows are inserted mid-scan. Cursor pagination hands back an opaque marker for the last row seen and asks for what follows it, which is stable under writes and stays flat as the offset grows. Almost every large API has migrated in that direction, and the ones that have not are the ones with a slow page 500.

Check yourself

Why does cursor pagination beat page numbers on a large, actively written table?

GET /articles/9on miss onlyretry on 502readPOST bypasses cacheClientbrowser or serviceCDNcaches GET onlyLoad balancerretries idempotentAPIyour codeDatabasethe expensive part
ClientEdge / CDNServiceData storeDashed = asynchronous
What the network does for free when the method is correct
Evidence · 2 sources
  1. 01
    RFC 9110: HTTP semantics

    The division of methods into safe ones that do not change state, GET, HEAD, OPTIONS and TRACE, and idempotent ones, those four plus PUT and DELETE, with POST in neither category.

  2. 02
    Fielding, Architectural styles and the design of network-based software architectures (2000)

    The origin of REST as an architectural style with a set of constraints, of which the uniform interface is the one that pays for itself in practice.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

02

GraphQL and its costs

intermediate

GraphQL began at Facebook in 2012, driven by a specific problem: the mobile news feed needed a different shape of data from the web one, over a slower network, and every screen change meant either a new bespoke endpoint or several round trips. The specification was published in 2015 and moved to the GraphQL Foundation in 2018. The idea is that the client sends a query describing exactly the fields it wants, and gets back a response with that shape and nothing else. The wins are real and they are mostly about clients. Over-fetching disappears, because nobody is shipping thirty fields to a screen that renders four. Round trips collapse, because a screen that was five REST calls with dependencies between them becomes one request. The schema is typed and introspectable, so tooling, autocomplete and generated client types come for free, and a breaking change is visible before it ships rather than after a client crashes.

The first thing you give up is HTTP caching, and it is a bigger loss than it sounds. Every query is a POST to a single endpoint, so the CDN, the browser cache and every proxy in between have nothing to work with, and a system that was serving most reads from an edge cache is suddenly serving all of them from origin. The standard recovery is persisted queries: register the query text once, send a hash of it, and let the request be a GET again so the edge can cache on the hash. It works, and it is one more piece of machinery you now operate.

The second thing you give up is control over cost, because the client now writes the query. A nested selection can fan out into thousands of database reads without anything in the request looking unusual. The defences are depth limiting, complexity analysis and pricing. GitHub's GraphQL API does the last of these openly: a query is scored before it runs, a call cannot request more than 500,000 nodes, and each token has a budget of 5,000 points an hour rather than a count of requests. That is the right shape of answer, because the honest unit of work is not one request.

The third is N+1, which is structural rather than accidental. Resolvers run per field, so a list of fifty authors resolves the author field fifty times, each with its own database call. DataLoader style batching collects the calls made within a single tick and issues one query for the fifty keys. It is standard equipment, it works, and it has to be applied deliberately at every level that can fan out. Nobody notices the missing one in development, because the list has three items.

Finally, errors arrive with a 200 and an errors array beside partial data, which is deliberate: half a screen is better than none. It also means every dashboard, alert and load balancer health check that reads the status code sees a healthy service while a resolver is failing on a third of requests. Your monitoring has to parse the body, and this is the single most common reason a GraphQL outage is discovered by a customer rather than by a graph.

Check yourself

A GraphQL service is failing on a third of calls, and the dashboards are green. Why?

POST /graphqlunder budget50 field resolves1 batched call1 batched callClientwrites the queryCost analysisdepth and pointsExecutorresolves per fieldDataLoaderbatches per tickUsers serviceone query for 50 idsPosts serviceone query for 50 ids
ClientEdge / CDNServiceData storeDashed = asynchronous
One request, and everything you now own behind it
Evidence · 1 source
  1. 01
    GitHub, resource limitations on the GraphQL API

    That a call cannot request more than 500,000 nodes and that a token has a budget of 5,000 points an hour, scored per query, rather than a count of requests.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

03

gRPC and binary protocols

intermediate

gRPC is Protocol Buffers over HTTP/2: a compact binary encoding with a schema, generated client and server stubs in a dozen languages, and four call shapes rather than one. Unary is the familiar request and response; the other three stream, from the server, from the client, or in both directions at once over a single connection. It was open sourced by Google in 2015, and it is the public version of an internal system, Stubby, that had already been carrying the traffic between Google's services for over a decade.

The efficiency comes from several places at once. Protobuf writes a field as a small numeric tag plus a value rather than a repeated field name, and tags 1 to 15 fit in a single byte, which is why the convention is to spend those numbers on the fields that appear most often. HTTP/2 compresses headers with HPACK, so the repeated metadata that dominates small JSON calls largely disappears. Multiplexing lets many calls share one connection without queueing behind each other. And there is no JSON parsing, which on a service doing hundreds of thousands of small calls a second is not a rounding error.

The schema is the part that changes how teams work, because it makes compatibility a build-time question. The rules are simple and unforgiving: field numbers are the wire identity, so they are never reused, and a removed field's number is marked reserved so nobody can accidentally take it. Unknown fields are preserved through a decode and re-encode, so an old intermediary does not silently strip data written by a newer one. Break those rules and the failure is not a parse error, it is a value quietly landing in the wrong field.

Deadlines are the underrated feature. A gRPC call carries a deadline as part of the call itself, and it propagates: a service that received a call with 300ms remaining passes what is left to whatever it calls next, so the whole tree agrees on when the work stops being useful. Do that with REST and you are wiring an X-Request-Deadline header through every hop by hand, and getting it wrong somewhere, which is how a client that gave up eight seconds ago ends up paying for a database query that is still running.

The costs are mostly at the edges. Browsers cannot emit or read HTTP/2 trailers, where gRPC puts its status, so calling gRPC from a browser needs gRPC-Web and a proxy such as Envoy to translate. Debugging by hand is harder, because you cannot read the bytes without the schema, and the tooling (grpcurl and friends) is one more thing to install on a machine where curl was already there.

There is an operational trap worth knowing before it finds you. gRPC holds long-lived connections, so a layer 4 load balancer that balances connections rather than requests will pin every call from a client to one backend, and adding capacity does nothing at all: the new instances sit idle while one melts. The fixes are a proxy that understands HTTP/2 request-level balancing, or client-side load balancing with a resolver that knows the backend set. The usual arrangement is gRPC between your own services and REST or GraphQL at the public edge, which is Google's own split.

Check yourself

A team adds three replicas behind a layer 4 balancer and gRPC latency does not improve. Why?

gRPC-WebtranslatedgRPC unarydeadline propagatesqueryBrowserno HTTP/2 trailersEdge proxyEnvoy, gRPC-WebAPI gatewayREST and JSONService AgRPC, deadline 300msService BgRPC, deadline 180msStorecancelled with thecall
ClientEdge / CDNServiceData storeDashed = asynchronous
Internal binary, public JSON, and the proxy where they meet
Evidence · 2 sources
  1. 01
    Protocol Buffers: encoding

    That a field is written as a numeric tag plus a value, and that tags 1 to 15 occupy a single byte, which is why they are spent on the most frequent fields.

  2. 02
    Protocol Buffers: proto best practices

    That field numbers are the wire identity and must never be reused, and that a removed field's number is reserved so it cannot be taken by accident.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

04

Webhooks and delivery

advanced

A webhook inverts the direction. Instead of clients polling you, you call them when something happens, which removes the polling load and cuts the latency from an average of half the poll interval to roughly the time of one request.

The saving is larger than it looks. A thousand integrators polling a quiet endpoint every thirty seconds is 2,880,000 requests a day, and if events arrive for one integrator in a hundred per poll then 99% of that traffic exists to learn that nothing happened. The same information as webhooks is about 29,000 calls, made only when there is something to say.

What you have bought instead is a delivery problem, and it is yours rather than theirs. Receivers go down, respond in eight seconds, return 200 while failing internally, or vanish for a fortnight. So a webhook sender is really four things: a queue, a retry policy, a dead letter path, and somewhere an integrator can see what failed and why.

Retries need a budget and a shape. Immediate, then 1 minute, 5, 30, 2 hours, 6 hours, and stop at 24 gives eight attempts across a day and covers both a deploy and a night of downtime; Stripe runs the same idea out to three days. Jitter every interval, because a receiver that fell over under load will otherwise be hit by every sender's retry at the same second and fall over again.

Receivers have to verify the call came from you. An HMAC-SHA256 over the raw body with a shared secret, sent as a header, and a timestamp in the signed payload so a captured request cannot be replayed a week later. Sign the raw bytes, not the parsed object: a receiver that re-serialises JSON before checking will compute a different digest for the same message and reject you, which is one of the more annoying afternoons in this line of work. Order is the part people forget. Retries mean the second event can land before the first, so a payload carries a sequence number or an event timestamp, and the receiver is expected to ignore anything older than what it already holds. Without that, a customer.updated for a stale version arriving after the current one silently reverts a record, and nobody notices until a support ticket says the address changed back on its own.

Check yourself

Why sign webhook payloads with an HMAC rather than relying on a secret URL?

HMAC over bodyPOST, with retriesattempts exhaustedEvent happensorder paidDelivery queuesigned and retriedReceivercustomer endpointDead letterafter N attempts
ServiceQueue / streamExternalDashed = asynchronous
Delivery is the product: sign it, retry it, and give failures somewhere to go
Evidence · 2 sources
  1. 01
    Stripe: webhook signatures

    That the signature is an HMAC over the raw body with a timestamp in the signed payload, and that the raw bytes must be used rather than a re-serialised object.

  2. 02
    Stripe: webhook builder and delivery behaviour

    That failed deliveries are retried with exponential backoff for up to three days, and that each attempt and response is visible to the integrator.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

05

Versioning and breaking changes

advanced

Start from what actually breaks, because it is narrower than people assume. Adding an optional request field, adding a response field, adding an endpoint or a new enum value in a field the client only writes: all safe, provided clients are tolerant readers that ignore what they do not recognise. Removing or renaming a field, tightening validation, making an optional input required, changing a status code, or changing the meaning of an existing value while keeping its name: all breaking, and the last one is the worst, because nothing in any schema catches it.

Where the version number lives matters less than people argue. In the path (/v2/charges) it is explicit, trivially routable and visible in every log line. In a header or media type it keeps URLs stable so a resource has one identity across versions. Both work. What does not work is mixing them, or versioning at three different granularities in the same API, because then no one can answer what version they are on without reading your source.

Stripe's model is the most demanding and the most instructive. An account is pinned to the API version that was current when it first integrated, and requests and responses are passed through a chain of transformations between that version and the current internal one. The cost is that every backwards-incompatible change ever made must exist forever as a piece of running code. What it buys is that they have effectively never broken an existing integration, on an API where breaking one means someone stops taking payments.

Retirement is the part almost everyone skips, and it is the only part that determines whether versioning was worth doing. A version lives exactly as long as clients use it, so the prerequisite is per-version usage telemetry broken down by client: not how many requests hit v1, but which twelve integrators are responsible for them and when each last called. Without that, nobody can prove removal is safe, so nothing is ever removed and every version is maintained forever.

With the telemetry, deprecation becomes a process rather than a hope. Announce with a date. Advertise it in the responses themselves, using the Sunset header from RFC 8594 and the Deprecation header standardised in RFC 9745, so the machinery can see it and not only the mailing list nobody reads. Contact the identified clients directly, because they are a list rather than a crowd. Then run brownouts, short deliberate outages of the old version at announced times, which is the only reliable way to find the integrations whose owners left the company two years ago. Google's deprecation policy commits to a year of notice for stable APIs; a year with brownouts works, and a year of silence followed by a switch-off does not.

The last piece of advice is to version the smallest thing that changed. A global version bump forces every client to revalidate everything to receive one new field, so it gets deferred, so the old version never empties. A new field behind a flag, a new endpoint beside the old one, or a per-resource version keeps the blast radius proportionate to the change, and keeps the migration something a client can do in an afternoon.

Check yourself

Which is a backwards-compatible API change?

requestrequestold versions onlynormalisedwho is on whatOld clientpinned 2019-08New clientcurrent versionVersion routerreads the pinTransform chain2019-08 to currentCore APIone internal shapeUsage telemetryper version, perclient
ClientEdge / CDNServiceData storeDashed = asynchronous
A pinned version, and the transforms that keep an old client working
Evidence · 3 sources
  1. 01
    RFC 8594: the Sunset HTTP header field

    That a resource's forthcoming removal can be advertised in the response itself, so tooling can see a deprecation rather than only a mailing list.

  2. 02
    RFC 9745: the Deprecation HTTP header field

    The standardised header for signalling that a resource is deprecated, alongside Sunset for when it will stop working.

  3. 03
    Stripe: API upgrades and versioning

    That an account is pinned to the API version current when it integrated, and that requests and responses are transformed between that version and the current internal one.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.