must-know mid · part of Skills & Topics · Senior SWE Roadmap · related: Rate Limiting · Microservices vs Monolith · Load Balancing
REST vs GraphQL vs gRPC
Three different answers to “how does a client talk to a server,” each optimizing for a different thing: REST optimizes for simplicity and cacheability, GraphQL for flexible data-fetching, gRPC for raw performance between services you control.
REST
Resource-oriented: nouns are URLs, verbs are HTTP methods. The server decides the exact shape of every response.
GET /users/42/posts?limit=20 HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
// 200 OK
{
"data": [
{ "id": 501, "title": "Hello world", "author_id": 42, "created_at": "2026-01-04T10:00:00Z" }
],
"next_cursor": "eyJpZCI6NTAxfQ=="
}Strengths: cacheable by URL out of the box (CDNs, browsers, reverse proxies all understand GET + Cache-Control), simple mental model, huge tooling/ecosystem. Weakness: the response shape is fixed by the server — a mobile client that only needs title still gets author_id and created_at (over-fetching), and a client that needs both a post and its author’s name has to make two round trips (under-fetching), or the backend grows ad-hoc ?include=author params to compensate.
GraphQL
Single endpoint, client specifies the exact shape of the response in the query itself.
query {
user(id: 42) {
name
posts(limit: 20) {
title
createdAt
}
}
}{
"data": {
"user": {
"name": "Alice",
"posts": [{ "title": "Hello world", "createdAt": "2026-01-04T10:00:00Z" }]
}
}
}One request gets exactly the fields needed, nested arbitrarily deep, no over/under-fetching. The cost: every query is a POST to the same URL, so URL-based HTTP caching is gone (needs its own caching layer, e.g. persisted queries or a client-side normalized cache like Apollo/Relay); a naive deeply-nested query can trigger the N+1 problem on the backend (resolvers calling the DB once per nested field) unless batched via something like DataLoader; and an unbounded query can be used to DoS the server, so query cost/depth limiting becomes a real concern.
gRPC
Binary protocol over HTTP/2, contract defined in a .proto file, code-generated client/server stubs, strongly typed.
service UserService {
rpc GetUser (GetUserRequest) returns (User);
}
message GetUserRequest { int64 id = 1; }
message User { int64 id = 1; string name = 2; repeated Post posts = 3; }Call looks like a local function call in generated code: user = userServiceClient.GetUser(GetUserRequest{Id: 42}). Wire format is compact binary Protobuf, not human-readable JSON. Supports streaming (client, server, or bidirectional) natively over HTTP/2 multiplexed connections. Fast — smaller payloads, no JSON parsing, connection reuse. Downsides: not natively browser-friendly (needs gRPC-Web + a proxy), harder to debug on the wire (can’t just curl and read it), and the strict schema means both sides need the .proto and regenerated stubs to change — this is a feature for internal services (forces contract discipline) and friction for public APIs (external consumers can’t be forced to regenerate on your schedule).
Comparison
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Format | JSON over HTTP/1.1 | JSON over HTTP/1.1 (POST) | Protobuf over HTTP/2 |
| Fetching | Fixed shape per endpoint | Client-specified shape | Fixed shape per RPC |
| Caching | Native (URL-based, HTTP semantics) | Hard (single endpoint, POST) | Application-level only |
| Typing | Loose (OpenAPI optional) | Strong (schema-first) | Strong (schema-first, codegen) |
| Streaming | No (polling/webhooks/SSE) | Subscriptions (via WS) | Native, bidirectional |
| Best for | Public APIs, CRUD resources | Aggregating flexible client queries | Internal service-to-service |
| Browser-native | Yes | Yes | No (needs gRPC-Web proxy) |
Interview default: public-facing API → REST unless the client-flexibility problem is explicitly in scope → GraphQL. Internal microservice-to-microservice calls where you control both ends and latency matters → gRPC. See Microservices vs Monolith for how this interacts with service boundaries.
HTTP verbs, status codes, and idempotency
Verbs
| Verb | Safe (no side effects) | Idempotent (repeat = same result) | Typical use |
|---|---|---|---|
GET | Yes | Yes | Fetch a resource |
HEAD | Yes | Yes | Fetch headers only (existence/metadata check) |
OPTIONS | Yes | Yes | Discover allowed methods (CORS preflight) |
PUT | No | Yes | Full replace of a resource at a known URL |
DELETE | No | Yes | Remove a resource |
PATCH | No | Usually not (depends on semantics) | Partial update |
POST | No | No (by default) | Create a resource / trigger an action |
“Safe” means it doesn’t change server state — a GET should never have side effects (don’t build a “delete via GET link” — crawlers and prefetchers will click it). “Idempotent” means calling it N times has the same effect as calling it once: PUT /users/42 {name: "Alice"} sets the name to Alice whether sent once or five times — no side effect accumulates. DELETE /users/42 is idempotent too: the resource is gone after the first call, and the next three calls are no-ops (though the status code might differ — 204 then 404 — the state doesn’t change). PATCH is idempotent only if the patch is a full-field-set assignment; PATCH {balance: 100} is idempotent, PATCH {balance: +$10} is not (repeat it three times, balance moves three times).
Why idempotency matters for retries
A client that sends a request and gets a timeout genuinely doesn’t know whether the server processed it before the connection dropped. For an idempotent operation, the safe move is trivial: just retry. For a non-idempotent one like POST /payments, blindly retrying can double-charge a customer.
The standard fix is an idempotency key: the client generates a unique key (e.g. a UUID) per logical operation and sends it as a header; the server stores “key → result” the first time it processes that key, and on any retry with the same key it just returns the stored result without re-executing the side effect.
sequenceDiagram participant C as Client participant A as API Server participant Store as Idempotency Store participant D as Database C->>A: POST /payments (Idempotency-Key: k1) A->>Store: has k1? Store-->>A: no A->>D: charge card, create payment row D-->>A: payment_id=789 A->>Store: save k1 -> payment_id=789 A-->>C: 201 Created (payment_id=789) Note over C,A: network blip — client never saw the response, retries C->>A: POST /payments (Idempotency-Key: k1) A->>Store: has k1? Store-->>A: yes -> payment_id=789 A-->>C: 201 Created (payment_id=789, no new charge)
This is the standard pattern for payment APIs (Stripe, for example, requires exactly this). It’s covered in more depth in Design a Payments System and Design a Digital Wallet.
Status codes
| Range | Meaning | Common codes |
|---|---|---|
| 2xx | Success | 200 OK, 201 Created, 202 Accepted (async, processing not done), 204 No Content (success, empty body — typical for DELETE) |
| 3xx | Redirection | 301 Moved Permanently, 304 Not Modified (conditional GET cache hit) |
| 4xx | Client error | 400 Bad Request, 401 Unauthorized (missing/invalid auth), 403 Forbidden (authenticated but not allowed), 404 Not Found, 409 Conflict (e.g. version mismatch on update), 422 Unprocessable Entity (semantically invalid), 429 Too Many Requests |
| 5xx | Server error | 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable (often with Retry-After), 504 Gateway Timeout |
A common interview trip-up: 401 vs 403. 401 = “I don’t know who you are” (missing/invalid credentials — retryable with better credentials). 403 = “I know who you are, you’re not allowed” (retrying with different credentials as the same resource won’t help). Getting this distinction right signals you actually understand auth, not just HTTP trivia.
Versioning strategies
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/users, /v2/users | Explicit, cacheable per version, easy to route/deploy independently | URL is technically “the resource” changing identity across versions; encourages whole-API version bumps even for one endpoint |
| Header | Accept: application/vnd.example.v2+json | Keeps URLs stable (same resource identity); more RESTfully “correct” | Less discoverable (can’t just paste a URL in a browser); harder to test/debug casually |
| Query param | /users?version=2 | Simple to add | Easy to forget/omit, caching gets messy (same URL, different content by param) |
| None (additive-only evolution) | — | No version proliferation, one API to maintain | Requires strict discipline — see below |
URL versioning is the pragmatic default for public APIs: obvious to consumers, trivial to route at the gateway/LB level to different backend deployments, easy to sunset (/v1 returns 410 Gone on a fixed date). Header versioning is more “correct” in REST theory (the resource /users/42 is the same entity regardless of representation version) but adds friction for API consumers, so it shows up more in enterprise/B2B APIs with sophisticated clients than in typical public APIs.
The “none” approach — never incrementing a version, only ever adding — is what large-scale APIs (Stripe, AWS in places) actually prefer once they mature, because juggling N live versions forever is its own maintenance tax. It shifts the cost from “maintain multiple versions” to “never remove or change anything, ever” — see backward compatibility below.
Pagination
Offset-based
GET /items?offset=40&limit=20 — simple, supports jumping to an arbitrary page, but breaks under concurrent writes: if an item is inserted before offset 40 while a client is paging through, every subsequent page shifts by one, causing skipped or duplicated rows. Also gets slower with large offsets on most databases (OFFSET 100000 still has to scan and discard 100,000 rows).
Cursor-based
The client passes an opaque cursor (typically an encoded last-seen ID or (sort_key, id) tuple) instead of a numeric offset; the server returns the next page plus the cursor for the page after that.
sequenceDiagram participant C as Client participant A as API Server participant D as Database C->>A: GET /posts?limit=20 A->>D: SELECT * FROM posts WHERE id < MAX_ID ORDER BY id DESC LIMIT 20 D-->>A: rows 1..20 (last id = 501) A-->>C: {data: [...], next_cursor: "id=501"} Note over C: user scrolls further C->>A: GET /posts?limit=20&cursor=id=501 A->>D: SELECT * FROM posts WHERE id < 501 ORDER BY id DESC LIMIT 20 D-->>A: rows 21..40 A-->>C: {data: [...], next_cursor: "id=481"}
Stable under concurrent inserts (new rows above the cursor don’t shift already-fetched pages), and each query is an indexed range scan (WHERE id < cursor) instead of a large OFFSET, so it stays fast at depth. Tradeoff: no random access to “page 47” — only forward/backward traversal from a cursor. This is why every infinite-scroll feed (Twitter/X, Instagram, Slack message history) uses cursor pagination, and it’s the expected answer whenever a feed is “constantly being written to.”
API gateway pattern
Once an API is backed by more than one service, a single entry point that fronts all of them earns its keep: it terminates TLS, authenticates the caller once, applies Rate Limiting, routes to the right backend service, and can aggregate multiple backend calls into one response for the client — all logic that would otherwise be duplicated in every service.
flowchart TD Client --> GW["API Gateway<br/>(auth, rate limiting, routing, TLS termination, logging)"] GW --> LB["Load Balancer tier"] LB --> Users["Users Service"] LB --> Orders["Orders Service"] LB --> Inventory["Inventory Service"] GW -. "aggregates/composes<br/>multiple calls for BFF-style endpoints" .-> Users GW -. "" .-> Orders
- Centralizes cross-cutting concerns (auth, rate limiting, request logging/Observability, CORS) instead of every service reimplementing them.
- Can do request routing (path-based:
/users/* → Users Service), and sometimes protocol translation (public REST/JSON in, internal gRPC out). - BFF (Backend-for-Frontend) is a common variant: a gateway tailored per client type (mobile BFF, web BFF) that composes/shapes responses differently for each, instead of one generic gateway serving all clients identically.
- Risk: becomes a single point of failure and a scaling bottleneck if not itself deployed redundantly behind a load balancer — the gateway needs the same horizontal-scaling treatment as any other tier (see Scalability Fundamentals).
Authentication
| Approach | How it works | Good for |
|---|---|---|
| API key | Static secret string sent per request (header or query param), checked against a stored list | Server-to-server, simple usage metering/attribution, no per-user identity needed |
| OAuth 2.0 | Delegated authorization — a user grants a third-party app scoped access without sharing their password, via an authorization server issuing tokens | ”Log in with Google,” third-party app access to user data |
| JWT | Self-contained signed token (header.payload.signature) carrying claims (user id, roles, expiry); server verifies the signature, no DB lookup needed | Stateless session verification across many services, especially post-OAuth-login |
API keys are the simplest: check a static value against a database/cache. Good for machine-to-machine and usage tracking, weak for anything requiring real identity or fine-grained permission (a leaked key is fully compromised until rotated).
OAuth 2.0 solves delegated authorization: a user grants app X limited access to their data on service Y without giving X their Y password. The most common flow (authorization code grant):
sequenceDiagram participant U as User participant App as Third-Party App participant Auth as Authorization Server participant API as Resource API U->>App: click "Log in with Google" App->>Auth: redirect user to /authorize (client_id, scope, redirect_uri) U->>Auth: logs in, approves scope Auth-->>App: redirect back with auth code App->>Auth: exchange code + client_secret for access_token Auth-->>App: access_token (+ refresh_token) App->>API: request with Authorization: Bearer access_token API-->>App: user data
Note OAuth is about authorization (what you’re allowed to access), not authentication (who you are) — OpenID Connect layers identity on top of it for login use cases.
JWTs are commonly the token format used after login (OAuth or otherwise): a signed blob any service can verify independently (with the issuer’s public key) without a round trip to a central auth service or session store — which is what makes them attractive in a stateless, horizontally-scaled architecture (see Scalability Fundamentals). The tradeoff: a JWT is valid until it expires, so revoking access before expiry needs either short-lived tokens + refresh tokens, or a (stateful) revocation list — which partially reintroduces the centralized-lookup cost JWTs were meant to avoid.
Error handling conventions
Returning {"error": "something broke"} with a 200 OK is a common anti-pattern — always match the HTTP status code to the error class, and give the body a consistent, parseable shape. A widely used convention is RFC 7807 (application/problem+json):
// 422 Unprocessable Entity
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 422,
"detail": "Account balance $12.50 is less than requested transfer of $50.00",
"instance": "/transfers/abc123",
"balance": 12.50,
"requested": 50.00
}Key properties of a good error contract:
- Consistent shape across every endpoint — clients can write one error-parsing path.
- Machine-readable
type/codedistinct from the human-readabletitle/detail— clients branch on the code, not by string-matching the message (which might get localized or reworded). - HTTP status reflects the error class; the body adds the specifics.
- Validation errors should ideally enumerate every field that failed, not just the first one — saves the client N round trips fixing one field at a time.
Backward compatibility
The core discipline for evolving an API without a version bump (or even across a long-lived version) is: only add, never remove or repurpose.
| Safe (backward compatible) | Breaking |
|---|---|
| Add a new optional field to a response | Remove or rename an existing field |
| Add a new endpoint | Change a field’s type (string → int) |
| Add a new optional request parameter | Make a previously-optional parameter required |
| Add a new enum value, if clients are expected to ignore unknown values | Change enum value meaning, or fail on unrecognized values |
| Relax a validation rule (accept more) | Tighten a validation rule (reject what used to work) |
| Add a new HTTP method on an existing resource | Change status codes returned for an existing case |
Practical mechanisms: consumers should be built to ignore unknown fields (defensive parsing) so additive changes are silently forward-compatible; deprecate before removing (mark old fields/endpoints deprecated in docs, emit a Deprecation / Sunset header, give a real timeline before actually removing); consider consumer-driven contract testing (e.g. Pact) so a change that breaks a real consumer fails CI before it ships, not after; and treat any genuinely breaking change as grounds for a new version rather than mutating an old one out from under existing clients.
Interview angles
- “Design the API for X” — pick REST by default unless the prompt specifically has a client-flexibility problem (many client types needing different shapes → GraphQL) or is internal service-to-service with a latency budget (→ gRPC). State the choice and why, don’t just default silently.
- “How would you paginate a feed that’s constantly being written to?” → cursor-based pagination is the expected answer; be ready to explain why offset breaks (page drift under concurrent inserts) not just that it does.
- “The client’s request timed out — did the write happen?” → idempotency keys; walk through the retry-with-same-key sequence.
- “How do you evolve this API without breaking the mobile app that can’t be force-updated?” → additive-only changes, deprecation windows, and why URL versioning gives a clean escape hatch when a truly breaking change is unavoidable.
- “Where does auth happen?” → API gateway centralizing it, JWT verification being stateless (no per-request DB hit) is usually the detail that signals depth.
- Interviewers listen for the 401-vs-403 distinction and idempotent-vs-safe distinction as cheap signals of real HTTP fluency — get them right without being asked to elaborate.