API Design: REST vs gRPC vs GraphQL
The API is the contract every other decision has to live with. How to choose a protocol, design endpoints that survive five years of change, and get pagination and idempotency right.
Covers: Resource modelling, idempotency, pagination, versioning, error contracts, protocol selection, backwards compatibility
In an interview you will be asked to sketch an API within the first ten minutes. It is a small artefact that reveals a lot: whether you think in resources or in RPCs, whether you have felt the pain of a breaking change, and whether you know what happens when a client retries.
30-second answer
REST for public APIs and anything cached by HTTP infrastructure - it is universally understood and works with every proxy, CDN and browser. gRPC for internal service-to-service calls where you want a typed contract, binary efficiency, streaming and code generation. GraphQL when many diverse clients need different shapes of the same data and over-fetching is the real problem - typically a mobile app plus a web app against a rich domain. Most large companies use all three: REST at the public edge, gRPC internally, GraphQL as a client-facing aggregation layer.
| REST | gRPC | GraphQL | |
|---|---|---|---|
| Transport / format | HTTP/1.1+, JSON | HTTP/2, Protobuf binary | HTTP, JSON, single endpoint |
| Contract | OpenAPI (optional) | .proto, enforced, codegen | Schema, enforced, introspectable |
| Payload size | Baseline | ~30–60% smaller | Baseline, but only what you asked for |
| Browser support | Native | Needs grpc-web proxy | Native |
| HTTP caching | Excellent (GET + ETag) | None by default | Poor - POST to one URL |
| Streaming | SSE or WebSocket bolt-on | Native, bidirectional | Subscriptions |
| Over/under-fetching | Common | Common | Solved by design |
| Best fit | Public APIs, CRUD, cacheable reads | Internal microservices, low latency | Many client shapes, one graph |
The decision, as a sentence you can say
- Is it public or partner-facing? → REST. Ubiquity and cacheability beat elegance when you cannot control the client.
- Is it internal, chatty and latency-sensitive? → gRPC. Typed contracts and binary framing pay off, and you control both ends.
- Do several very different clients need different slices of a connected dataset? → GraphQL, with a strict complexity budget.
- Is it a streaming or long-lived channel? → gRPC streaming internally, WebSocket or SSE at the browser edge.
What a typical large architecture actually does
Three protocols, one system
The common end state: GraphQL or REST at the client edge, gRPC everywhere behind it.
Flow
- Mobile appEdge / gateway(GraphQL)
- Web appEdge / gateway(GraphQL)
- Partner APIEdge / gateway(REST)
- Edge / gatewayUser service(gRPC)
- Edge / gatewayOrder service(gRPC)
- Edge / gatewaySearch service(gRPC)
Say these points
- REST for public and cacheable; gRPC for internal and latency-sensitive; GraphQL for many client shapes.
- gRPC's real wins are the enforced contract and code generation, not just binary size.
- GraphQL costs you HTTP caching and invites N+1 and expensive-query attacks.
- Large systems combine all three rather than picking one.
Avoid these mistakes
- Choosing GraphQL for a single client with a stable, simple data shape.
- Exposing gRPC directly to browsers without grpc-web.
- Ignoring that GraphQL breaks CDN caching.
- Treating protocol choice as a fashion decision instead of naming the constraint it solves.
Expect these follow-ups
- How do you make GraphQL responses cacheable at the CDN?
- What happens to your gRPC clients when you remove a field from a proto?
- When would you deliberately choose REST for an internal service?
30-second answer
Make the operation idempotent with a client-generated idempotency key. The client sends a unique key with the request; the server stores the key together with the response in a table with a uniqueness constraint before doing the work. A retry with the same key returns the stored response instead of executing again. The critical details are that the key must be recorded in the same transaction as the effect, that concurrent duplicates must be serialised by the unique constraint rather than a read-then-write check, and that in-flight duplicates should get a 409 rather than a second execution.
This is the highest-signal API question there is, because the naive answer - “check if it already exists, and if not, do it” - contains a race condition that will absolutely happen at scale.
Which methods are naturally idempotent?
| Method | Idempotent? | Note |
|---|---|---|
| GET | Yes | Must have no side effects at all |
| PUT | Yes | Full replacement - applying twice is the same as once |
| DELETE | Yes | Second call returns 404 or 204; state is identical |
| POST | No | This is the one that needs an idempotency key |
| PATCH | Depends | set balance = 100 yes; increment balance by 10 no |
The implementation, with the race condition closed
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY, -- client-supplied, e.g. a UUID
request_hash TEXT NOT NULL, -- guards against key reuse with a different body
state TEXT NOT NULL, -- 'in_progress' | 'completed'
response_status INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL -- 24h is a common window
);
CREATE INDEX ON idempotency_keys (expires_at); -- for the cleanup jobdef charge(idempotency_key, body):
request_hash = sha256(canonical_json(body))
# 1. Claim the key. The PK constraint - not a SELECT - resolves the race.
try:
with db.transaction():
db.execute(
"INSERT INTO idempotency_keys (key, request_hash, state, expires_at) "
"VALUES (?, ?, 'in_progress', now() + interval '24 hours')",
idempotency_key, request_hash,
)
except UniqueViolation:
row = db.query_one(
"SELECT * FROM idempotency_keys WHERE key = ?", idempotency_key
)
if row.request_hash != request_hash:
return 422, {"error": "idempotency key reused with a different payload"}
if row.state == "in_progress":
# A concurrent attempt is mid-flight. Do NOT execute a second time.
return 409, {"error": "request in progress, retry shortly"}
return row.response_status, row.response_body # replay the stored result
# 2. Do the work and record the outcome atomically.
with db.transaction():
charge_id = payments.execute(body) # the side effect
response = {"charge_id": charge_id, "status": "succeeded"}
db.execute(
"UPDATE idempotency_keys SET state='completed', response_status=201, "
"response_body=? WHERE key=?",
response, idempotency_key,
)
return 201, responseOperational details worth mentioning
- Who generates the key? The client, before the first attempt, so all retries of the same logical operation share it. A server-generated key defeats the purpose.
- How long do you keep keys? Usually 24 hours - long enough to cover any sane retry window, short enough that the table does not grow forever. Expire with a TTL or a scheduled delete.
- Hash the request body. If the same key arrives with different content, that is a client bug; return 422 rather than silently replaying an unrelated response.
- Return the original status code. A replayed create should still look like the original 201, so client logic behaves identically.
Say these points
- Client-generated idempotency key, stored with a uniqueness constraint.
- Insert first and catch the unique violation - never read-then-write.
- Commit the key and the side effect atomically, or push the key to the external provider.
- Return 409 for an in-flight duplicate, and replay the stored response for a completed one.
- Hash the body to detect key reuse with different content.
Avoid these mistakes
- A check-then-act pattern that races under concurrency.
- Storing the key after performing the side effect.
- Server-generated keys, which cannot deduplicate a retry.
- Letting the idempotency table grow without expiry.
- Assuming at-most-once network delivery is achievable.
Expect these follow-ups
- How does this interact with a message queue that delivers at-least-once?
- What if the side effect spans two services?
- How would you handle a key whose first attempt crashed and left it 'in_progress' forever?
30-second answer
Use cursor-based pagination, not offset. `OFFSET 100000` makes the database scan and discard 100,000 rows, so page 10,000 is catastrophically slow, and rows inserted while the user pages cause items to be skipped or duplicated. A cursor encodes the last seen sort key and becomes a `WHERE (created_at, id) < (?, ?)` predicate that uses the index directly - constant time regardless of depth, and stable under concurrent inserts. For versioning, prefer additive, backwards-compatible changes; when you must break, version in the URL path for public APIs and run both versions concurrently with a published deprecation window.
Offset versus cursor
Offset (LIMIT 20 OFFSET n) | Cursor (keyset) | |
|---|---|---|
| Deep page cost | O(n) - scans and discards | O(log n) - index seek |
| Stable under inserts | No - items shift between pages | Yes |
| Jump to page 500 | Yes | No - sequential only |
| Total count | Easy | Expensive or approximate |
| Good for | Small admin tables | Feeds, timelines, any large collection |
-- Offset: the database materialises and throws away 100,000 rows first.
SELECT * FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000; -- slow, and drifts under concurrent inserts
-- Keyset: a single index seek, constant cost at any depth.
SELECT * FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id) -- tuple comparison
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- The composite index that makes it O(log n):
CREATE INDEX idx_posts_feed ON posts (created_at DESC, id DESC);{
"data": [ { "id": "p_881", "created_at": "2026-07-24T11:02:00Z" } ],
"page": {
"next_cursor": "eyJjIjoiMjAyNi0wNy0yNFQxMTowMjowMFoiLCJpIjoicF84ODEifQ",
"has_more": true
}
}Base64-encode the cursor so clients treat it as opaque. That keeps you free to change the underlying sort key later without a breaking change - and it stops clients from hand-crafting cursors you never intended to support.
Versioning: prefer not to
| Change | Breaking? | How to handle |
|---|---|---|
| Add an optional response field | No | Ship it; clients must ignore unknown fields |
| Add an optional request parameter | No | Ship it with a safe default |
| Remove or rename a field | Yes | Add the new one, dual-write, deprecate, remove after the window |
| Change a field's type | Yes | New field with a new name; never mutate a type in place |
| Tighten validation | Yes | Log-only first, measure who breaks, then enforce |
| Change default sort or page size | Yes, subtly | The worst kind - nothing errors, behaviour just changes |
- URL path versioning (
/v1/users) - ugly but unambiguous, trivially cacheable and routable. The pragmatic default for public APIs. - Header versioning (
Accept: application/vnd.api.v2+json) - cleaner URLs, but harder to test in a browser and easy for proxies to mishandle. - No versioning, additive only - what most internal gRPC and GraphQL systems do. Protobuf's field numbers and GraphQL's
@deprecatedare built for exactly this.
Say these points
- Cursor pagination is O(log n) and stable; offset is O(n) and drifts.
- Always add a unique tie-breaker to the sort and the cursor, using a tuple comparison.
- Make cursors opaque so the sort key stays an implementation detail.
- Additive changes are free; breaking changes need dual running and usage telemetry.
Avoid these mistakes
- Offset pagination on an infinite feed.
- Sorting by a non-unique column and silently skipping rows.
- Returning a total count on a huge table on every page request.
- Removing a v1 endpoint before measuring who still calls it.
Expect these follow-ups
- The client needs 'jump to page 500'. How do you support that without offset?
- How do you paginate results merged from three shards?
- What is the safest way to change a field's type in a public API?
Showing 3 of 3 questions for api-design-rest-grpc-graphql.
Check your understanding
4 questions · no sign-up, nothing stored
Hands-on challenge
Build it - this is what you talk about in a deep-dive round.
Design the API for a booking service
Write the complete HTTP contract for a service that lets users search, hold and confirm seat bookings. Contracts, not code.
Requirements
- Define 5–6 endpoints with methods, paths, request and response shapes.
- Make seat confirmation safely retryable, and write out the idempotency-key flow including the concurrent-duplicate case.
- Paginate search results with a cursor, and specify the sort key and tie-breaker.
- Define a consistent error envelope with machine-readable codes and appropriate status codes.
- Describe how you would add a 'seat preferences' feature six months later without a version bump.
Stretch goals
- Specify rate limits per endpoint and the headers you return when a client is throttled.
- Write the equivalent contract as a `.proto` file and note what changes.