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

RESTGraphQLgRPC
FormatJSON over HTTP/1.1JSON over HTTP/1.1 (POST)Protobuf over HTTP/2
FetchingFixed shape per endpointClient-specified shapeFixed shape per RPC
CachingNative (URL-based, HTTP semantics)Hard (single endpoint, POST)Application-level only
TypingLoose (OpenAPI optional)Strong (schema-first)Strong (schema-first, codegen)
StreamingNo (polling/webhooks/SSE)Subscriptions (via WS)Native, bidirectional
Best forPublic APIs, CRUD resourcesAggregating flexible client queriesInternal service-to-service
Browser-nativeYesYesNo (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

VerbSafe (no side effects)Idempotent (repeat = same result)Typical use
GETYesYesFetch a resource
HEADYesYesFetch headers only (existence/metadata check)
OPTIONSYesYesDiscover allowed methods (CORS preflight)
PUTNoYesFull replace of a resource at a known URL
DELETENoYesRemove a resource
PATCHNoUsually not (depends on semantics)Partial update
POSTNoNo (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

RangeMeaningCommon codes
2xxSuccess200 OK, 201 Created, 202 Accepted (async, processing not done), 204 No Content (success, empty body — typical for DELETE)
3xxRedirection301 Moved Permanently, 304 Not Modified (conditional GET cache hit)
4xxClient error400 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
5xxServer error500 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

StrategyExampleProsCons
URL path/v1/users, /v2/usersExplicit, cacheable per version, easy to route/deploy independentlyURL is technically “the resource” changing identity across versions; encourages whole-API version bumps even for one endpoint
HeaderAccept: application/vnd.example.v2+jsonKeeps 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=2Simple to addEasy to forget/omit, caching gets messy (same URL, different content by param)
None (additive-only evolution)No version proliferation, one API to maintainRequires 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

ApproachHow it worksGood for
API keyStatic secret string sent per request (header or query param), checked against a stored listServer-to-server, simple usage metering/attribution, no per-user identity needed
OAuth 2.0Delegated 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
JWTSelf-contained signed token (header.payload.signature) carrying claims (user id, roles, expiry); server verifies the signature, no DB lookup neededStateless 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/code distinct from the human-readable title/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 responseRemove or rename an existing field
Add a new endpointChange a field’s type (string → int)
Add a new optional request parameterMake a previously-optional parameter required
Add a new enum value, if clients are expected to ignore unknown valuesChange 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 resourceChange 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.

My Notes