API Design
API design covers the contract between clients and services: the protocol style (REST, GraphQL, gRPC), and the mechanics that make APIs safe and pleasant at scale, versioning, pagination, idempotency, and webhooks.
REST vs GraphQL vs gRPC
REST models resources as URLs manipulated with HTTP verbs: GET /users/123, POST /orders, DELETE /sessions/abc. Its strengths are ubiquity, human readability, and free riding on HTTP semantics, GET is cacheable by browsers and CDNs, status codes are standardized, every language and tool speaks it. Design conventions to voice: plural nouns, nesting for ownership (/users/123/orders), proper verb semantics (PUT idempotent full replace, PATCH partial update), and meaningful status codes (201 Created, 404, 409 Conflict, 429).
GraphQL exposes a typed schema and lets clients ask for exactly the fields they need in one request, solving REST's over-fetching (downloading 40 fields to use 3) and under-fetching (needing 4 round trips to assemble one screen). It shines for complex frontends over rich data graphs, GitHub's public API v4 is GraphQL. The costs: caching is harder because everything is a POST to one endpoint, unbounded queries can be pathologically expensive so you need depth limits and query cost analysis, and naive resolvers create N+1 database query patterns that require batching (DataLoader).
gRPC uses Protocol Buffers over HTTP/2: binary serialization several times smaller and faster than JSON, code-generated clients in every major language, strict contracts from .proto files, and native streaming (client, server, and bidirectional). It dominates internal service-to-service communication, latency-sensitive paths, and polyglot microservice fleets. It is a poor fit for public browser-facing APIs since browsers cannot speak native gRPC without a gRPC-Web translation layer. The standard senior answer: REST for public APIs, gRPC internally, GraphQL where a complex client owns aggregation, and these coexist in one system.
Versioning and Compatibility
APIs outlive their first design, and the cardinal rule is never break existing clients. Additive changes (new optional fields, new endpoints) are safe; removing or renaming fields, changing types, or tightening validation are breaking and require a versioning strategy.
The common approaches: URL path versioning (/v1/users, most visible and most popular, used by Stripe-style public APIs and most REST services), header or media-type versioning (cleaner URLs, harder to test in a browser), and date-based versioning, Stripe's signature move, where each account pins to the API version from its first request and Stripe maintains transform layers between dozens of dated versions so ancient integrations keep working for years.
gRPC and protobuf handle this at the field level: fields have numbered tags, old clients ignore unknown fields, and you never reuse or renumber tags. GraphQL prefers continuous evolution over versions: add fields freely, mark old ones deprecated, and monitor field usage before removal. Whatever the mechanism, the operational half matters: publish deprecation timelines, emit warnings (Sunset headers), track per-version usage, and keep the number of live versions small, every version is a permanent test and maintenance burden.
Pagination
Any endpoint that returns a list needs pagination, unbounded responses are a reliability bug waiting for a big customer. Offset pagination (?limit=20&offset=40, or page numbers) is simple and lets users jump to page N, but it degrades and misbehaves at scale: OFFSET 100000 forces the database to scan and discard 100,000 rows, and if rows are inserted or deleted between page fetches, items shift so users see duplicates or gaps.
Cursor (keyset) pagination returns an opaque cursor encoding the position of the last item, typically its sort key: ?limit=20&cursor=xyz translates to WHERE (created_at, id) < (cursor values) ORDER BY created_at DESC, id DESC LIMIT 20. This is a pure index seek, constant cost at any depth, and stable under concurrent inserts, which is why Stripe, Slack, and Twitter/X APIs are cursor-based. Include the id as a tiebreaker so the sort is total, and keep the cursor opaque (base64) so clients cannot construct or misparse it.
Say the limits too: cursors cannot jump to an arbitrary page and only support the predefined sort orders you indexed for. A pragmatic hybrid is offset for small admin datasets, cursors for anything user-facing or large. Also cap the limit parameter (max 100) or a client will ask for a million rows.
Idempotency Keys
Networks fail in the worst way: the client times out without knowing whether the server processed the request. If the request was POST /payments for 50 dollars, blindly retrying risks a double charge, but not retrying risks a failed payment. Idempotency keys resolve this: the client generates a unique key (a UUID) per logical operation and sends it as a header, Idempotency-Key: abc-123. The server atomically records the key before processing and stores the response; a retry with the same key returns the stored response instead of re-executing. Stripe's API is the canonical implementation, retaining keys for 24 hours.
Implementation details that interviewers probe: the key check and the operation should commit atomically (same database transaction, or an atomic insert of the key acting as a lock) or a race between two concurrent retries can still double-execute; concurrent duplicates should get a 409 or wait; keys need a TTL; and the stored response must be returned byte-identical so clients cannot distinguish a replay from the original.
Connect this to HTTP semantics: GET, PUT, and DELETE are defined as idempotent, POST is not, which is exactly why POST endpoints with side effects (payments, orders, sends) are where idempotency keys matter. The same concept generalizes to queue consumers: at-least-once delivery means every consumer of a payment event needs idempotent handling too.
Webhooks
Webhooks invert the API: instead of clients polling GET /orders/123 every few seconds, the server POSTs an event to a URL the client registered when something happens, order.completed, payment.failed. This eliminates polling waste (thousands of empty polls per real event) and cuts notification latency to near-real-time. Stripe, GitHub, Slack, and Twilio are all webhook-driven platforms.
Delivering webhooks reliably is a real system: the receiver may be down, so you need retries with exponential backoff (Stripe retries for up to 3 days), which means at-least-once delivery, which means receivers must deduplicate by event ID. Senders sign payloads with HMAC (Stripe-Signature header) including a timestamp to block forgery and replay; receivers must verify the signature and respond 200 quickly, enqueueing heavy work rather than processing inline, or they will time out and trigger spurious retries. Order is not guaranteed, so events carry IDs and timestamps and receivers reconcile against the API as the source of truth.
Provide an events log endpoint (GET /events) so consumers can backfill anything missed during an outage, and a dashboard showing delivery attempts, Stripe and GitHub both do this because debugging webhook failures is otherwise miserable. In an interview, webhooks pair naturally with a message queue on the sender side: the app emits events to a queue, and a delivery worker pool handles fan-out, retries, and dead-lettering.
Key points
- ▸REST for public ubiquity and HTTP caching, GraphQL for flexible client-driven queries over complex data, gRPC for fast typed internal RPC with streaming; large systems use all three in different places.
- ▸Never break existing clients: version via URL path or Stripe-style pinned dates, evolve protobufs by field-number discipline, deprecate GraphQL fields with usage monitoring.
- ▸Cursor pagination is constant-cost at any depth and stable under writes; offset pagination degrades and skips or duplicates items. Cap page sizes.
- ▸Idempotency keys make unsafe retries safe: client sends a UUID, server atomically records it and replays the stored response for duplicates. Essential for payments.
- ▸Webhooks replace polling with pushed events but require HMAC signing, retries with backoff, receiver-side dedup by event ID, and a backfill endpoint.
- ▸Design errors deliberately: correct status codes, machine-readable error bodies, and 429 with Retry-After for rate limits.
Tradeoffs
REST
Pros
- + Universal tooling and developer familiarity
- + Native HTTP caching, CDN-friendly GETs, standard status codes
Cons
- − Over- and under-fetching for complex client views
- − No formal contract unless you add OpenAPI discipline
GraphQL
Pros
- + Clients fetch exactly what they need in one round trip
- + Strongly typed schema with introspection; smooth field-level evolution
Cons
- − HTTP/CDN caching largely lost; needs query cost limits to prevent abuse
- − N+1 resolver patterns and server complexity (batching, persisted queries)
gRPC
Pros
- + Compact binary protobufs and HTTP/2 multiplexing; low latency at high QPS
- + Generated clients, strict contracts, and native bidirectional streaming
Cons
- − Not browser-native; needs gRPC-Web or a REST gateway for public use
- − Binary payloads are harder to debug with generic HTTP tools
In the interview
- ★When asked to design an API, sketch 4-6 concrete endpoints with verbs, status codes, and pagination parameters rather than speaking abstractly.
- ★For anything involving money or side effects, volunteer idempotency keys and explain the retry-timeout ambiguity they solve, it is a strong senior signal.
- ★Default to cursor pagination for user-facing lists and say why offset breaks at depth and under concurrent writes.
- ★If your design notifies third parties, propose webhooks and immediately cover signing, retries, and receiver dedup so it does not sound hand-wavy.