Engineering Atlas › System Design Fundamentals

REST, RPC and GraphQL: picking an API style

16 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

What a style decides

Every style answers four questions. What does the caller name: a resource (/orders/42), a procedure (CancelOrder), or a query ({ order(id: 42) { status } })? Who starts the exchange, client or server? Is there one response, many, or none? How does a failure look, and is it safe to retry? Seen this way, the styles stop looking like brands and start looking like choices.

Resource style (REST)

REST names things, and a small uniform set of methods acts on them. Because method semantics are standardised, every proxy, cache and client library knows which calls are safe to cache or retry; a CDN can cache a GET it has never seen. The cost is that actions ("refund part of this order and notify the buyer") must be bent into resource operations, and a screen that needs five resources needs five round trips or a custom aggregate endpoint.

Procedure style (RPC)

RPC names actions. The caller invokes Refund(order_id, amount) as if it were a local function, and the server routes by method name. gRPC adds a schema in Protobuf, generated clients, deadlines and streaming over HTTP/2. JSON-RPC 2.0 is the minimal version: one JSON object with method, params and id, sent over any transport (JSON-RPC 2.0). It is small enough that new protocols adopt it directly; the Language Server Protocol used by code editors and the Model Context Protocol used by AI agents to call tools are both JSON-RPC 2.0. Protocols built on it may still narrow it: MCP's 2025-06-18 revision removed JSON-RPC batching from the protocol (MCP changelog), so check the protocol's own rules, not just the base spec. RPC fits internal calls and command-shaped operations well. Its danger is that it makes a network call look like a function call, so callers forget timeouts and partial failure.

Query style (GraphQL)

GraphQL names a shape of data. The client sends a query describing exactly which fields of which related objects it needs, and gets JSON of that shape back in one round trip. That suits many clients with different screens over one connected data model. The cost moves to the server: arbitrary queries make load unpredictable, so you need cost limits, and HTTP caching mostly stops working because most clients send every operation as a POST to one URL. The GraphQL-over-HTTP working draft does allow GET for queries, never for mutations (GraphQL over HTTP), but long queries do not fit well in a URL.

That gap is why HTTP gained a new method. QUERY (RFC 10008, June 2026) carries the query in the request body like POST but is defined as safe and idempotent, and its responses are cacheable with a cache key that must include the body (RFC 10008). It gives any query-shaped API, GraphQL or a complex search endpoint, the retry and caching semantics that GET has, once clients, proxies and CDNs support it.

Push styles

When the interesting thing is a change, asking repeatedly is wasteful. Webhooks reverse the direction: the provider makes an HTTP POST to a URL the consumer registered, signed so the consumer can verify it, and retried until the consumer answers 2xx. Server-Sent Events keep one HTTP response open and stream text events from server to browser, with automatic reconnection. WebSockets upgrade an HTTP connection into a two-way channel of frames, for chat, collaborative editing and games. Message brokers (Kafka, RabbitMQ, SQS) decouple producer and consumer in time, so neither needs the other to be up. AsyncAPI plays the role for event channels that OpenAPI plays for HTTP endpoints.

Push styles bring the fan-out and ordering problem shapes with them. A webhook sender must deliver one event to thousands of subscribers, retry the slow ones, and accept that a retried "payment succeeded" may arrive after "payment refunded". The same shapes appear in a chat app delivering a message to every member of a group.

One older style sits outside these families. SOAP, which wraps RPC calls in XML envelopes described by WSDL files, survives in banking, insurance and government integrations; you meet it when integrating with them, rarely when designing new systems.

Down the stack

Layer 1The contract

The styles differ most in where failure is reported and what a retry means. REST reports failure in the HTTP status line, and method semantics say which calls may be retried blindly (GET, PUT, DELETE) and which need an idempotency key (POST). gRPC carries its own status in HTTP/2 trailers (grpc-status), and only some codes, such as UNAVAILABLE, are safe to retry. gRPC has no default retry policy; a service opts in through a service config that lists the retryable codes, a maximum attempt count and a retry-throttling token bucket (gRPC docs). JSON-RPC defines an error object with reserved codes: -32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error, and -32000 to -32099 for server-defined errors; a request without an id is a notification that gets no response at all, even on failure. GraphQL usually answers 200 OK with partial data and an errors array, so a response can be half success. Webhooks are at-least-once: the provider retries on any non-2xx or timeout, often for days. Stripe retries live-mode events with exponential backoff for up to three days, treats a redirect as a failure, and states plainly that it does not guarantee event order (Stripe docs). So receivers must deduplicate by event ID and must not assume order. SSE promises that a reconnecting client sends Last-Event-ID, but replaying from that point is the server's job.

Layer 2The mechanism

On the wire each style is a small convention on top of HTTP or TCP. A gRPC call is an HTTP/2 POST to /package.Service/Method with content-type: application/grpc and each message prefixed by a 1-byte compression flag and a 4-byte big-endian length; the deadline travels as a grpc-timeout header, and grpc-status arrives in trailers even on success (gRPC over HTTP/2). A GraphQL call is a POST /graphql with a JSON body of query, variables and operationName. A JSON-RPC call is {"jsonrpc": "2.0", "method": "add", "params": [2, 3], "id": 1}; an array of them is a batch whose responses are matched by id. An SSE body is text/event-stream lines such as id: 42 and data: {...} separated by blank lines. A WebSocket starts as an HTTP/1.1 GET with Upgrade: websocket and a random Sec-WebSocket-Key; the server answers 101 Switching Protocols with Sec-WebSocket-Accept, the base64 SHA-1 of that key plus a fixed GUID. Then come frames with a 2 to 14 byte header, and client frames are masked with a 4-byte key so cache-poisoning attacks on intermediaries fail (RFC 6455). Webhook signatures are typically an HMAC-SHA256 over a timestamp and the raw body, in a header such as Stripe-Signature: t=...,v1=...; the timestamp lets receivers reject old replays, and Stripe's libraries default to a 5-minute tolerance (Stripe docs). Any framework that re-parses the JSON before verification breaks the signature, because the HMAC is over the exact bytes.

Layer 3The machine

Request and response styles cost a round trip per call, plus TCP and TLS handshakes on a new connection; keep-alive and HTTP/2 multiplexing amortise those, and HPACK shrinks repeated headers to a few bytes, which is why gRPC's many small calls are cheap. Push styles trade round trips for held state. Each open WebSocket or SSE connection is a kernel socket with auto-tuned send and receive buffers, a TLS session, and the application's own per-connection objects: realistically 10 to 50 KB per idle connection, so one million connected clients needs roughly 10 to 50 GB of memory across the fleet, and epoll lets a single thread watch tens of thousands of them because it reports only sockets with activity. Load balancers close idle connections (AWS Application Load Balancer defaults to 60 seconds, configurable from 1 to 4,000), so long-lived streams send heartbeats: a WebSocket ping frame is 2 bytes plus payload, an SSE comment line is a handful. Check what the middlebox counts as activity: an ALB does not treat HTTP/2 PING frames as traffic, so gRPC keepalive pings alone will not hold a connection open through one (AWS docs). Polling has the opposite profile: no held state, but every poll pays a full request, typically 500 bytes to 1 KB of headers each way plus server CPU, even when nothing changed.

Why it's built this way

  1. A network call can fail halfway, so every style must say where errors live and which calls are safe to repeat.
  2. Standard semantics let intermediaries help (caches, proxies, retries), so REST bets on a uniform interface; when callers need actions or shapes instead, RPC and GraphQL trade that help for expressiveness.
  3. Round trips dominate latency on mobile and across regions, so styles that batch (GraphQL, JSON-RPC batches, aggregate endpoints) appear wherever clients are far away.
  4. Most data changes rarely compared with how often clients want to know, so push styles keep a connection or a callback instead of paying for empty polls.
  5. Held connections cost memory and break when proxies time them out, so push protocols add heartbeats, resumption IDs and at-least-once retries.

Diagram

sequenceDiagram
  participant M as Merchant app
  participant P as Provider API
  participant W as Merchant webhook
  M->>P: GET /shipments/7 (poll)
  P-->>M: 200 status=in_transit (no change)
  M->>P: GET /shipments/7 (poll)
  P-->>M: 200 status=in_transit (no change)
  Note over P: shipment delivered
  P->>W: POST event delivered (signed)
  W-->>P: 500 (receiver down)
  P->>W: retry after backoff, same event id
  W-->>P: 200 (dedupe by event id)

Worked example

A logistics provider tracks 1 million active shipments for 50,000 merchants. Each shipment changes status about 5 times over a 2-day life.

With polling every 60 seconds per shipment, merchants send 1,000,000 / 60, about 16,700 requests per second. Status changes happen at 1,000,000 × 5 / 172,800 seconds, about 29 per second. So about 99.8% of polls return nothing new. At roughly 1.5 KB per request and response pair, polling costs about 25 MB/s of traffic and a fleet of API servers sized for 16,700 requests per second, to deliver 29 events per second, each up to 60 seconds late.

With webhooks the provider sends about 29 POSTs per second plus retries, and events arrive in seconds. If 2% of merchant endpoints are down at any moment, the retry queue holds a few thousand events, not millions. The new costs are a durable delivery queue, per-merchant signing secrets, and a page showing failed deliveries.

The merchant dashboard is a third shape: 20,000 people at a time watch a live map, and updates flow one way, so SSE fits. At about 30 KB per held connection that is 600 MB across the SSE tier, with a heartbeat every 30 seconds to stay under a 60-second idle timeout. Internal services call each other with gRPC because they share a Protobuf schema and need deadlines. Four styles, each where it fits.

In the wild

Trade-offs

StyleStrengthsWeaknessesChoose when
RESTUniform semantics, HTTP caching, universal toolingChatty for complex screens, actions fit awkwardlyPublic APIs, CRUD over resources
gRPCTyped schema, codegen, deadlines, streaming, compactNeeds a proxy for browsers, harder to debug by handInternal service-to-service calls
JSON-RPCTiny spec, any transport, batching, notificationsNo schema or HTTP caching built inTool and editor protocols, simple command APIs
GraphQLOne round trip per screen, client picks fieldsCost control, caching and monitoring are harderMany client types over one data graph
WebhooksNo polling, near real-time, simple HTTPAt-least-once, unordered, receivers must be reachableNotifying partners of events
SSEOne-way streaming over plain HTTP, auto-reconnectServer to client only, text onlyLive feeds and dashboards in browsers
WebSocketsTwo-way, low latencyStateful connections, custom protocol on topChat, collaboration, games

Pitfalls in production

Leadership lens

Check yourself

  1. Why can a CDN cache a REST GET it has never seen but not a typical GraphQL request?

    Show answer

    The GET names a resource by URL with standard safe semantics, so the URL is the cache key. GraphQL queries usually travel as POST bodies to one URL, so the cache sees neither a key nor a promise that the call is read-only.

  2. A JSON-RPC batch contains five notifications. What does the server return, and why?

    Show answer

    Nothing. Notifications have no id, so there is nothing to correlate a response with, and the spec says the server must not reply to them, even on error.

  3. Your webhook receiver gets refund.created before charge.succeeded for the same charge. Is the provider broken?

    Show answer

    No. Webhooks are at-least-once and unordered because of retries and parallel delivery. Handle each event idempotently and reconcile against the current state from the API.

  4. When would you pick SSE over WebSockets?

    Show answer

    When data flows only from server to client, such as a live dashboard. SSE is plain HTTP, works through standard proxies, and reconnects with Last-Event-ID for free; WebSockets add two-way framing you would not use.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →