API Architecture: REST, GraphQL, RPC, gRPC, WebSockets, Webhooks
Each API style exists because a previous one hurt — REST for cacheable resources, GraphQL for clients that need many shapes, gRPC for fast typed internal calls, WebSockets for server push, webhooks for event delivery across organisations — and each carries a failure mode you inherit the moment you choose it.
Clients and services need a contract for exchanging data, and the shape of that contract decides caching, latency, type safety, and how painful the next version is. Choosing the style that matches the client and the traffic pattern avoids building a workaround layer later.
What each one is, and what hurt before it
REST: resources at URLs, verbs from HTTP, representations in JSON. It exists because HTTP already had caching, proxies, status codes and idempotent verbs, and reinventing them in a custom envelope threw all of that away. Its cost is fixed response shapes: a mobile screen that needs a user, their last three orders and each order’s first item makes four round-trips (under-fetching) or gets a 40 KB user object to read one field (over-fetching).
GraphQL: one endpoint, a typed schema, the client writes the query and gets exactly that shape. It exists because a company with a web app, two mobile apps and a partner API could not keep adding ?include=orders.items variants to REST. Its cost is that the server no longer knows the shape of the work in advance: a naive resolver for users { orders { items } } runs one query per user then one per order (N+1 resolvers), which is why every GraphQL server needs batching (DataLoader) and why HTTP caching mostly stops working — every query is a POST with a different body.
RPC / gRPC: call a function on another machine. gRPC adds Protocol Buffers (a binary, schema-first wire format), generated clients, and HTTP/2 multiplexing with bidirectional streams. It exists because internal service-to-service calls do not need the ceremony of resources; they need type safety, small payloads and low latency — a gRPC call is typically 3–10× smaller on the wire than the equivalent JSON. Its cost is that browsers cannot speak it natively (you need grpc-web and a proxy) and it needs HTTP/2 end to end; a load balancer that terminates HTTP/1.1 silently breaks streaming.
WebSockets: one long-lived, bidirectional TCP connection per client. It exists because polling every second to see if a chat message arrived is 3,600 requests per client per hour for mostly empty responses. Its cost is connection state: the server now holds a socket per user, which fights the stateless scaling in Stateless vs Stateful Services — you need connection routing, heartbeats, reconnect logic, and a way to reach "the server holding user 42’s socket". Server-Sent Events are the one-directional, HTTP-native alternative when only the server needs to push.
Webhooks: the provider calls *your* URL when something happens. They exist because polling a payment provider for "did the charge settle?" is wasteful and slow across organisations. Their cost is that the provider will retry — Stripe retries for up to three days — so your endpoint receives duplicates and must be idempotent (dedupe on the event id), must respond fast (2xx within seconds, do the work on a queue), and must verify the signature, because anyone can POST to a public URL.
The decision matrix
The rows are the questions an interviewer will actually ask. "Public" means third parties you do not control; "internal" means services your organisation deploys together. A finder that walks these questions in order lives at which-api-style; the usual answer for a product is REST at the edge, gRPC inside, WebSockets or SSE for the one screen that needs push, webhooks for partners — and GraphQL only when several client shapes are a real, measured cost.
| REST | GraphQL | gRPC | WebSockets | Webhooks | |
|---|---|---|---|---|---|
| Public vs internal | Both; best public | Public or BFF | Internal | Both | Public (outbound) |
| Browser clients | Native | Native | Needs grpc-web + proxy | Native | n/a (server to server) |
| Streaming | No (SSE alongside) | Subscriptions (over WS) | Yes: uni- and bidirectional | Yes, bidirectional | No; one event per call |
| Type safety | OpenAPI, optional | Schema, built in | Protobuf, built in | None; you define frames | Provider’s schema |
| Latency / payload | Medium; JSON | Medium; JSON, 1 round-trip | Low; binary, HTTP/2 | Lowest per message | Provider-controlled, async |
| Query flexibility | Fixed shapes | Client-defined | Fixed messages | n/a | n/a |
| Versioning | URL or header; breaks explicitly | Additive; deprecate fields | Field numbers; additive | Protocol-level, manual | Event versions per type |
| Caching | HTTP caches, CDN, ETag | Hard; per-field client cache | None built in | None | None |
| Signature failure | Over/under-fetching | N+1 resolvers, no HTTP cache | HTTP/1.1 in the path breaks it | Connection state, reconnect storms | Retries → duplicates; no idempotency |
Versioning and the cost of the next change
Every style has to survive its second version. REST tends toward explicit breaks — /v2/orders — which is honest but means two code paths for a year. GraphQL and Protobuf are designed for additive evolution: add a field, never remove or renumber one, mark old fields deprecated and watch their usage metrics until it hits zero. That works only with the discipline of never reusing a Protobuf field number and never changing a GraphQL field’s type. WebSocket protocols have no help at all; version the frame envelope from day one ({ v: 1, type: "msg", ... }) because you will not be able to add it later without breaking every connected client.
The shared rule: the client you cannot redeploy — a partner, an app-store binary from eighteen months ago — is the one that decides how conservative the contract must be. Internal gRPC between services you deploy together can be aggressive; a public REST API cannot.
1app.post('/webhooks/payments', async (req, res) => {2 if (!verifySignature(req.rawBody, req.headers['x-signature'], SECRET)) return res.sendStatus(401)3 const event = JSON.parse(req.rawBody) as { id: string; type: string; data: unknown }4 5 // idempotent: the provider WILL redeliver; the event id is the dedupe key6 const fresh = await db.insertIgnore('processed_webhooks', { id: event.id, received_at: now() })7 if (!fresh) return res.sendStatus(200) // already handled; ack again, do nothing8 9 await queue.publish('payment-events', event) // work happens off the request path10 res.sendStatus(200) // ack within seconds or it is retried11})Key points
- REST for cacheable public resources; GraphQL when many client shapes are a measured cost; gRPC for typed, fast internal calls; WebSockets for bidirectional push; webhooks for cross-organisation events.
- Each style’s signature failure: over/under-fetching, N+1 resolvers, HTTP/1.1 in a gRPC path, connection state, and webhook duplicates.
- GraphQL and Protobuf evolve additively; REST breaks explicitly; WebSocket protocols need a version field from day one.
- A webhook endpoint must verify the signature, dedupe on event id, acknowledge in seconds, and do the work on a queue.
- The client you cannot redeploy decides how conservative the contract has to be.
The same data in REST, GraphQL, gRPC and WebSocket
GET /users/42 GET /users/42/orders?limit=3 (two round trips; the second waits for the first)
{ "id": 42, "name": "Ada","email": "ada@example.com", "avatar": "…","createdAt": "2019-03-01", "locale": "en-GB","preferences": { … 14 keys … } }[ { "id": 901, "total": 42.50,"items": [ … 6 items … ], "address": { … } },{ "id": 902, "total": 12.00, "items": [ … ] },{ "id": 903, "total": 99.90, "items": [ … ] } ]
How data moves through it
One request or event, hop by hop.
- 1Client → Gateway: TLS terminated, auth checked, the request routed by path or
Content-Typeto a REST, GraphQL or WebSocket upgrade handler (API Gateway). - 2Gateway → Edge service: a REST handler or GraphQL resolver validates input and fans out to internal services.
- 3Edge service → Internal services: gRPC calls over pooled HTTP/2 connections, with deadlines propagated in metadata.
- 4Internal service → Database: the actual query; a GraphQL resolver batches here to avoid N+1.
- 5Provider → Webhook endpoint → Queue → Worker: an external event is verified, deduped, acknowledged and processed asynchronously.
When to use — and when not
- REST: public APIs, resource-shaped data, anything that benefits from CDN and browser caching.
- GraphQL: several first-party clients with different screens, or a partner API where consumers need their own shapes.
- gRPC: internal service-to-service calls where payload size and latency matter and both ends are generated from one schema.
- WebSockets / SSE: chat, presence, live dashboards — anything where polling would be mostly empty responses.
- GraphQL for one client with fixed screens; you pay the resolver and caching cost for flexibility nobody uses.
- gRPC straight to browsers or through infrastructure that terminates HTTP/1.1.
- WebSockets for data that changes every few minutes; a cached REST endpoint polled at that interval is cheaper and stateless.
- Webhooks without idempotent handling; the first provider retry storm will double-process every event.
Tradeoffs
Ratings vary by style: REST is the simplest and most cacheable; gRPC the fastest; WebSockets add the most operational state.
How it fails
- REST under-fetching: a mobile screen makes six sequential calls on a 200 ms link and renders after 1.2 s.
- GraphQL N+1: a
users { orders { items } }query for 50 users runs 1 + 50 + 50×n SQL statements; aDataLoaderbatches it to three. - gRPC behind an HTTP/1.1 load balancer: unary calls work, streams hang or drop, and the failure looks like random timeouts.
- WebSocket reconnect storm: a deploy closes 200,000 sockets at once and all clients reconnect in the same second — add jittered backoff.
- Webhook duplicate: the provider retries after your 5 s timeout, the second delivery ships a second order.
How it scales
- REST and GraphQL scale as stateless HTTP behind a load balancer; REST additionally offloads reads to CDNs (CDN Architecture).
- gRPC scales with HTTP/2 connection reuse — but L4 balancers pin a long-lived connection to one backend, so use L7 (gRPC-aware) balancing or client-side load balancing (Load Balancing).
- WebSockets scale by connection count, not request rate: ~100k–1M sockets per server, a pub/sub layer (Redis) to route messages between servers, and sticky or hash-based routing to find a user’s socket.
- Webhook receivers scale by queue depth; the HTTP handler only enqueues, so a burst of 50,000 events is a backlog, not an outage (Background Jobs and Workers).
How it interacts with databases, queues, caches, APIs and external systems
- Database: REST and gRPC map naturally to fixed queries; GraphQL needs batching and per-resolver cost limits or it becomes an ad-hoc query engine against your tables.
- Queue: webhook and WebSocket inbound events are enqueued rather than processed inline, so the connection path stays fast.
- Cache: REST responses cache at browser, CDN and gateway via
Cache-ControlandETag; GraphQL needs persisted queries to get any of that back (Caching Architecture). - External APIs: consumed as REST or gRPC clients with timeouts and breakers (Circuit Breaker); their events arrive as webhooks.
- Agents and tools: an LLM tool call is an RPC with a JSON schema — the same contract-first discipline as gRPC (Tool Calling Basics).