What it is
The contract between a service and its callers. Good API design decides six things: protocol (REST vs gRPC vs others), resource modeling, versioning, pagination, idempotency, and how identity and context flow through requests.
When you care
Every system design interview has an API section. Candidates who name three endpoints and move on lose signal to candidates who call out idempotency keys, pagination style, and versioning strategy in the same breath. The cheap wins live here.
The protocol landscape: RPC, REST, and gRPC
RPC (Remote Procedure Call) is a concept, not a protocol: call a function on a remote machine as if it were local. REST is also a concept: model resources over HTTP. Both are architectural styles.
Concrete implementations:
| Style | Implementations |
|---|---|
| RPC | gRPC, Thrift (Meta), Avro (Kafka), Twirp, JSON-RPC, XML-RPC |
| REST | HTTP/1.1, HTTP/2 (same semantics, better transport) |
| Query | GraphQL, OData |
gRPC is the dominant RPC implementation today — open-source from Google, runs on HTTP/2, uses Protobuf. When people say “use RPC for internal services,” they almost always mean gRPC specifically. It’s worth calling out by name in interviews because its properties (type safety, streaming, codegen) are concrete, not generic.
REST vs gRPC
| Dimension | REST | gRPC |
|---|---|---|
| Wire format | JSON over HTTP/1.1 or HTTP/2 | Protobuf over HTTP/2 |
| Schema | OpenAPI (optional) | .proto file (required) |
| Codegen | Optional | Native, multi-language |
| Browser support | Native | Requires gRPC-Web proxy |
| Streaming | SSE or WebSocket as sidecar | Built-in (client, server, bidi) |
| Human-readable | Yes | No (binary) |
| Versioning | URL or header-based | Proto field numbers |
| Good for | Public APIs, browser clients, third-party integrations | Internal service-to-service, polyglot backends, streaming |
REST is the default for anything a browser or third party will call. gRPC is the default for internal microservice traffic where both sides are under your control. Most production systems use both — REST at the edge, gRPC inside.
Why gRPC fits service-to-service
These are the specific properties that make it the default for internal traffic — worth naming in an interview rather than just saying “it’s faster”:
- Strong typing enforced at compile time. The
.protofile is the contract. Client and server are generated from the same file; a field rename or type change breaks the build, not production. REST/JSON has no equivalent guarantee without optional OpenAPI validation. - Binary efficiency. Protobuf is 5–10× smaller than equivalent JSON and faster to serialize/deserialize. At high internal call rates (millions/sec), this adds up in CPU and bandwidth.
- HTTP/2 multiplexing. Multiple RPC calls share one TCP connection concurrently, eliminating the per-request connection overhead and head-of-line blocking of HTTP/1.1. One connection to an upstream can carry thousands of in-flight RPCs.
- Built-in streaming. Four call types (below) handle any data-flow pattern natively — no bolted-on SSE or WebSocket sidecar.
- Interceptors as first-class middleware. Auth, tracing, logging, and retry logic sit in a chain of interceptors, not scattered across handlers.
REST API design
REST API design
- Nouns, not verbs.
POST /orders, notPOST /createOrder. - Plural resource names.
/users/123, not/user/123. - Hierarchy reflects ownership.
/users/123/ordersfor user-scoped resources; top-level/ordersfor globally addressable ones. - Status codes carry meaning.
201 Createdon successful POST,204 No Contenton DELETE,409 Conflicton idempotency mismatch,422 Unprocessable Entityon validation failure.
Identity from context, not parameters
Never accept user_id as a request parameter on endpoints that act on
the caller. Derive it from the authenticated session.
BAD: GET /api/orders?user_id=123
GOOD: GET /api/orders (user_id derived from auth token)
user_id as a parameter invites authorization bugs — someone changes
123 to 124 and sees another user’s orders. Sensitive identifiers
come from the auth context, not the request body. The parameter-based
form is only acceptable when the caller legitimately acts on behalf of
another user (admin endpoints, support tools) and that authorization is
explicitly checked.
Versioning
| Strategy | Form | Tradeoff |
|---|---|---|
| URL versioning | /v1/orders, /v2/orders | Explicit, visible, easy to route. Breaks link permanence across versions. |
| Header versioning | Accept: application/vnd.api+json; version=2 | URLs stay stable. Harder to debug; invisible in logs. |
| Parameter versioning | /orders?version=2 | Rarely used; mixes versioning with query semantics. |
| Never | Always breaking | Don’t. |
URL versioning is the default for public APIs. Bump major versions for breaking changes; add optional fields for non-breaking additions. gRPC uses proto field numbers for the same purpose — adding new fields is backward-compatible by design.
Pagination
| Style | Request | Response | Good for |
|---|---|---|---|
| Offset / limit | ?offset=100&limit=20 | Items + total count | Small datasets, random-access UIs (page 47). |
| Cursor-based | ?cursor=abc123&limit=20 | Items + next_cursor | Large datasets, feeds, infinite scroll. |
| Keyset (seek) | ?after_id=999&limit=20 | Items (client uses last ID) | Monotonically ordered data; highest performance. |
Offset pagination is simple but breaks at scale. OFFSET 1,000,000
on a DB is an O(N) scan. It also returns inconsistent results when the
underlying data changes mid-pagination.
Cursor pagination encodes the position as an opaque token (often a signed, base64-encoded row ID + sort key). Handles insertions gracefully, scales cleanly, and hides implementation details from the client. The default choice for any feed, list, or log.
Keyset pagination is cursor pagination with a known structure (usually the primary key). Fastest option when the ordering matches the index. Used heavily in internal systems.
Idempotency
An idempotent API produces the same observable result when called once or multiple times with the same input. Idempotency is a correctness property for network retries — if the client retries a payment because a response timed out, you do not want two payments.
| Method | Idempotent? | Notes |
|---|---|---|
| GET, HEAD | Yes | Natural. |
| PUT | Yes | Replaces the resource; same PUT = same state. |
| DELETE | Yes | Second DELETE returns 404 or 204, not an error. |
| POST | Not by default | Make it idempotent via idempotency keys. |
| PATCH | Depends | Only if the patch is a replace, not an increment. |
Idempotency keys. For non-idempotent operations (mostly POST), the
client generates a unique key (UUID) per logical operation and sends it
as a header: Idempotency-Key: abc-123. The server stores a mapping of
key → result for some window (commonly 24 hours). On retry, it returns
the stored result without re-executing.
Every non-idempotent endpoint that matters (payments, order creation, transfers, message sends) should accept an idempotency key. This is a small design decision with a large correctness payoff, and it’s one of the cheapest signals to display in an interview.
REST common practices worth naming
- Consistent error shape. One envelope across all errors:
{ error: { code, message, details } }. Clients parse once. - Field naming. Pick
snake_caseorcamelCaseand never mix.snake_caseis the REST/JSON convention in most style guides. - Timestamps are ISO 8601 strings, UTC.
2026-05-08T14:30:00Z. Numeric Unix timestamps invite timezone bugs. - Rate limiting at the edge.
429 Too Many RequestswithRetry-Afterheader. Belongs in a gateway, not per-endpoint logic. - Null vs missing. Decide whether absent fields mean “no value” or “don’t change” (PATCH). Document it.
gRPC API design
The four call types
This is the table to have in your head when someone asks “how would you design this internal API?”:
| Call type | Pattern | When to use |
|---|---|---|
| Unary | One request → one response | Standard RPC; same as a REST POST. Default choice. |
| Server streaming | One request → stream of responses | Server pushes an ongoing result set: log tailing, live feed, large result pagination |
| Client streaming | Stream of requests → one response | Client uploads chunks: file upload, batched event ingestion, sensor telemetry |
| Bidirectional streaming | Stream ↔ stream | Both sides send and receive concurrently: real-time collaboration, chat, live game state |
In interviews: unary for most things; name server-streaming when the response is large or ongoing (e.g. a notification feed internal API); bidi when you’d otherwise reach for WebSocket between services.
Auth
gRPC auth runs through metadata (the HTTP/2 equivalent of headers) and interceptors (middleware). Two common patterns:
- Token-based (JWT / OAuth): client attaches a token in metadata (
Authorization: Bearer <token>). A server-side interceptor validates it before the handler runs. This is the most common pattern for inter-service auth in a microservices mesh. - mTLS (mutual TLS): both client and server present certificates; the TLS handshake itself establishes identity. No application-level token needed. Common in zero-trust networks (Istio, Linkerd) where the service mesh handles cert rotation automatically. Stronger than token-based because there’s no token to steal — the private key never leaves the process.
In practice: mTLS via a service mesh for east-west traffic; token-based for north-south (edge → internal). Name this split in an interview.
Load balancing
This is where gRPC behaves differently from HTTP/1.1 REST in a way that surprises people:
The problem: standard L4 load balancers (AWS NLB, HAProxy in TCP mode) operate at the connection level. gRPC uses HTTP/2, which multiplexes many RPCs over one long-lived TCP connection. An L4 balancer that routes on connection establishment will send all RPCs from a client to whichever server it first connected to — load is never spread.
Solutions:
| Approach | How | Best for |
|---|---|---|
| L7 load balancing | Proxy understands HTTP/2 frames; routes each RPC individually. Envoy, NGINX, gRPC-aware ALBs. | Most production deployments; pairs with service mesh |
| Client-side load balancing | Client holds the full list of backend IPs (from DNS or service registry) and picks one per RPC with a round-robin or least-loaded policy. gRPC’s built-in pick_first / round_robin policies. | Services that own their own discovery; avoids a proxy hop |
| Lookaside load balancing | Client queries a separate load balancer service (a “balancer”) before each RPC to get the current best backend. | Fine-grained placement control; used in Google’s internal gRPC deployment |
In interviews: name the L4 trap first — it’s a common gotcha — then say you’d use an L7 proxy (Envoy/service mesh) or client-side LB depending on the deployment model.
Concurrency and thread pools
gRPC stubs come in two flavors with very different failure modes:
- Blocking stub: each RPC call occupies a thread until it completes. Simple to reason about, but if a downstream is slow, threads fill up and the caller’s thread pool saturates — upstream sees timeouts, not slow responses. Easy to create a cascading failure. Default in many generated clients.
- Async / non-blocking stub: RPCs are issued against a callback or Future; a small event-loop thread handles I/O for many in-flight RPCs. Much higher throughput per thread, but logic is callback-structured (or uses Kotlin coroutines / reactive libraries).
The rule of thumb: use async stubs for services with high concurrency or calls to slow upstreams. Blocking stubs are fine for low-concurrency, fast-path calls. Size your thread pool to expected_concurrency × (1 + p99_latency_sec / timeout_sec) as a starting estimate — the excess headroom absorbs latency spikes before threads saturate.
gRPC common practices worth naming
- Error codes use gRPC status codes, not HTTP status codes:
NOT_FOUND(14),UNAVAILABLE(14),RESOURCE_EXHAUSTED(8). Map these explicitly in interceptors; don’t let framework defaults leak the wrong code. - Deadlines, not timeouts. gRPC propagates deadlines through the call chain — each hop subtracts its budget. A deadline set at the edge expires at the right moment for all downstream RPCs, avoiding the “timed out on the last hop but the upstream already did the work” problem. Always set deadlines; never rely on infinite timeouts.
- Interceptors for cross-cutting concerns. Auth, tracing (OpenTelemetry), logging, retry, and circuit breaking all live in interceptors — the chain is composable and testable independently of handlers.
- Proto field evolution. Adding a field is safe (defaults to zero/empty on old clients). Removing a field is not — reserve the field number and name. Never reuse a field number; it’s the wire identity.
When to pick what
- Public API, browser clients, third-party integrations: REST with URL versioning, cursor pagination, idempotency keys on all non-GET endpoints.
- Internal microservice traffic: gRPC — Protobuf efficiency, codegen, strong typing, streaming. REST only at the edge.
- Internal streaming (log tail, live feed, sensor ingestion): gRPC server-streaming or client-streaming. No WebSocket between services.
- Real-time bidirectional between services: gRPC bidi streaming.
- Browser-facing real-time: SSE (one-way) or WebSocket (two-way). gRPC-Web is an option but adds proxy complexity.
- Operations that modify state: PUT if idempotent by design, POST with idempotency key otherwise.
- When you hear “service mesh” / “Envoy”: gRPC is almost certainly already in use; lean into mTLS + L7 LB assumptions.
Related
- Walkthrough: Designing an Ad Click Aggregation System — the
event_idfield is a canonical idempotency-key example. - Walkthrough: Designing a URL Shortener — a minimal REST API with interesting 301 vs 302 tradeoffs.
- Walkthrough: Designing a RAG System — illustrates
202 Acceptedfor async ingestion and tenant context from auth.