REST: The Default
resources, verbs, and statelessnessREST models everything as resources addressed by URL and manipulated with HTTP verbs. GET /users/42 reads, POST /users creates, PUT/PATCH updates, DELETE removes. Status codes carry the outcome: 2xx success, 4xx the client's fault, 5xx the server's.
It's the default for a reason. It rides plain HTTP, so every browser, proxy, and CDN understands it; GETs are cacheable for free; and it's stateless โ each request carries everything it needs, so any server can handle any request and you scale by adding boxes (Scaling Patterns).
| Code | Means | Typical use |
|---|---|---|
| 200 / 201 | OK / Created | Read succeeded / resource created |
| 400 | Bad request | Malformed input, failed validation |
| 401 / 403 | Unauthenticated / forbidden | No valid identity / not allowed |
| 404 | Not found | Resource doesn't exist |
| 409 | Conflict | Version clash, duplicate create |
| 429 | Too many requests | Rate limited (Rate Limiter) |
| 5xx | Server error | Retryable if the operation is idempotent |
gRPC: Fast, Typed, Internal
the service-to-service workhorsegRPC defines the contract in a Protobuf schema, generates client and server stubs in every language, and sends compact binary frames over HTTP/2. Smaller payloads than JSON, a real typed contract checked at compile time, and multiplexed connections. It also supports streaming โ client, server, or bidirectional โ which REST can't do cleanly.
That combination wins inside a system: high-volume, low-latency calls between services that you control on both ends (Microservices).
Internal service mesh, tight latency budgets, strict contracts across many languages, and streaming RPCs. The binary encoding and HTTP/2 multiplexing pay off most where call volume is highest.
Browsers can't speak raw gRPC โ you need a gRPC-Web proxy. Payloads aren't human-readable, so debugging needs tooling, and plain HTTP caches can't see inside. For public, browser-facing APIs, REST is still the safer default.
GraphQL: Client-Shaped Queries
one endpoint, you ask for exactly what you needGraphQL exposes a single endpoint and a typed schema. The client sends a query describing precisely the fields it wants, across whatever entities, and gets back that exact shape in one round trip. No over-fetching, no under-fetching, no five calls to stitch a screen together.
The cost lands on the server and the edges. Caching is hard โ every query is a unique POST body, so HTTP caching by URL no longer works. And the flexible query surface makes the N+1 problem easy to trigger: one query for a list, then one more per item, unless you batch with a loader.
REST GraphQL
GET /users/42 โโ POST /graphql
GET /users/42/posts โโผโ 3 trips { user(id:42) { โโ
GET /users/42/photos โโ name โ 1 trip,
posts { title } โ exact shape
over/under-fetch, photos { url } } } โโ
fixed shapes server resolves each field
Request-Response vs Event-Driven
coupling now, or decoupling laterAll three styles above are synchronous: the caller sends a request and blocks for the answer. Simple, immediate, and the right default โ but it couples caller to callee. If the callee is slow or down, the caller waits or fails with it.
The alternative is event-driven: the producer emits an event and moves on; consumers react whenever they can. That decouples failure domains and absorbs spikes, at the price of eventual consistency and harder debugging โ you trade a clear call stack for a chain of messages (Messaging).
| Request-response | Event-driven | |
|---|---|---|
| Coupling | Caller waits on callee | Producer and consumer independent |
| Result | Immediate, in-band | Later, out-of-band |
| Failure | Propagates to caller | Contained; retried from the queue |
| Spikes | Hit the callee directly | Absorbed by the queue |
| Best for | Reads, anything needing an answer now | Fire-and-forget work, fan-out, decoupling |
Details That Come Up
pagination, versioning, idempotencyA handful of API details show up in almost every design round. Knowing the right answer to each is cheap credibility.
Offset (LIMIT 20 OFFSET 10000) is easy but the DB scans and discards every skipped row, so deep pages get slow, and inserts shift the window and cause skips or dupes.
Cursor passes an opaque pointer to the last item seen (WHERE id > last). Constant-time regardless of depth and stable under inserts. The default for feeds and large lists.
A retried POST /payments must not charge twice. The client sends a unique Idempotency-Key; the server records it on first use and, on a replay, returns the stored result instead of re-executing.
This is what makes mutations safe to retry over a flaky network (Concurrency Control).
Versioning keeps you from breaking existing clients when the shape changes: a URL prefix (/v2/) is the blunt, visible option; a header or media-type version is cleaner but easier to miss. Whichever you pick, additive changes shouldn't need a new version โ only breaking ones.
Rate-limit headers (X-RateLimit-Remaining, Retry-After) tell a well-behaved client how much budget is left and when to come back, turning a 429 into a signal instead of a surprise (Rate Limiter).
Quick Reference
the three styles on one card| REST | gRPC | GraphQL | |
|---|---|---|---|
| Transport | HTTP/1.1+ | HTTP/2 | HTTP (usually POST) |
| Payload | JSON (text) | Protobuf (binary) | JSON, client-shaped |
| Contract | Convention / OpenAPI | Strict, generated stubs | Typed schema |
| Streaming | No (awkward) | Yes, bidirectional | Via subscriptions |
| HTTP caching | Free on GET | No | Hard |
| Browser | Native | Needs proxy | Native |
| Best fit | Public, browser-facing APIs | Internal service-to-service | Many clients, aggregation layer |
| Pagination | How | Cost |
|---|---|---|
| Offset | LIMIT/OFFSET | Slow deep pages, drifts on insert |
| Cursor | Pointer to last item seen | Constant-time, stable โ use at scale |