REST, RPC and GraphQL: picking an API style
Open in the interactive app: practice, quiz, mock interview →In one minute
- An API style is the shape of the conversation: what the unit of the interface is (a resource, a procedure, a query, an event or a stream), who starts each exchange, and where errors and retries live.
- Request and response styles (REST, RPC such as gRPC or JSON-RPC, and GraphQL) differ mainly in what the caller names: a thing, an action, or a shape of data.
- Push styles (webhooks, Server-Sent Events, WebSockets, message brokers) exist because polling for changes that rarely happen wastes almost every request.
- Each style moves cost somewhere: REST pushes work into many round trips but gets HTTP caching for free, GraphQL pushes it into the server's query planner, push styles push it into long-lived connections and delivery guarantees.
- Real systems mix styles per boundary: REST or GraphQL at the public edge, gRPC between services, webhooks to partners, events inside.
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
- A network call can fail halfway, so every style must say where errors live and which calls are safe to repeat.
- 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.
- 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.
- 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.
- 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
- GitHub, 2018: a webhook backlog during a 24-hour degradation. A 43-second network partition led Orchestrator to promote West Coast MySQL primaries, and GitHub spent 24 hours 11 minutes restoring and re-replicating. Webhook delivery was paused to protect data integrity: more than 5 million events queued, and about 200,000 payloads outlived their internal time-to-live and were dropped. GitHub afterwards changed failover so it could not promote across regions (GitHub). Webhook receivers need a way to catch up that does not depend on every push arriving.
- Discord, 2026: held connections turn a scale-down into a reconnect storm. A config change halved the number of session pods in one zone, and pods were killed before handing off, so 17% of sessions ended ungracefully. The reconnects hit an untuned rate limit and ran gateways out of memory, and voice and video were mostly down for about 3 hours 17 minutes. Fixes included an admission webhook that blocks scale-down until pods drain, plus retuned rate limits in front of the overloaded voice services (Discord). Push styles move cost into long-lived state, and every deploy has to move that state carefully.
Trade-offs
| Style | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| REST | Uniform semantics, HTTP caching, universal tooling | Chatty for complex screens, actions fit awkwardly | Public APIs, CRUD over resources |
| gRPC | Typed schema, codegen, deadlines, streaming, compact | Needs a proxy for browsers, harder to debug by hand | Internal service-to-service calls |
| JSON-RPC | Tiny spec, any transport, batching, notifications | No schema or HTTP caching built in | Tool and editor protocols, simple command APIs |
| GraphQL | One round trip per screen, client picks fields | Cost control, caching and monitoring are harder | Many client types over one data graph |
| Webhooks | No polling, near real-time, simple HTTP | At-least-once, unordered, receivers must be reachable | Notifying partners of events |
| SSE | One-way streaming over plain HTTP, auto-reconnect | Server to client only, text only | Live feeds and dashboards in browsers |
| WebSockets | Two-way, low latency | Stateful connections, custom protocol on top | Chat, collaboration, games |
Pitfalls in production
- GraphQL or JSON-RPC errors hidden behind 200. Dashboards built on HTTP status codes show 100% success while half the responses carry errors. Emit metrics from the error objects, not from the status line.
- Unsigned or replayable webhooks. Anyone who finds the URL can forge a "payment succeeded". Verify an HMAC over the raw body with a constant-time comparison and reject timestamps older than about five minutes.
- Assuming webhook order. Retries and parallel delivery reorder events. Carry a version or timestamp in each event, or fetch the current state from the API when an event arrives.
- Idle timeouts killing streams. Load balancers and NATs drop quiet connections. Send heartbeats well inside the shortest idle timeout on the path and make clients resume from the last event ID.
Leadership lens
- Choose a style per boundary (public edge, partner integration, service to service, live client updates) and standardise on one per boundary. Three styles used deliberately is fine; seven used by accident is a tax on every team.
- Ask in review: how does a caller learn that something changed, how late can that be, and what happens when the receiver is down for an hour?
- Count the platform cost before adopting a style: GraphQL needs cost analysis and persisted queries, webhooks need a delivery service and a retry dashboard, WebSockets need connection-aware deploys.
Check yourself
Why can a CDN cache a REST
GETit has never seen but not a typical GraphQL request?Show answer
The
GETnames a resource by URL with standard safe semantics, so the URL is the cache key. GraphQL queries usually travel asPOSTbodies to one URL, so the cache sees neither a key nor a promise that the call is read-only.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.Your webhook receiver gets
refund.createdbeforecharge.succeededfor 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.
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-IDfor free; WebSockets add two-way framing you would not use.
Sources and further study
Primary
- gRPC over HTTP/2 protocol: the exact request path, message framing, timeouts and trailers.
- RFC 10008: The HTTP QUERY Method: a safe, idempotent, cacheable request with a body.
- GraphQL over HTTP (working draft): which methods, media types and status codes GraphQL servers should use.
Systems docs
- Stripe: Receive events in your webhook endpoint: retries, ordering, duplicates and signature checks from a large webhook sender.
- gRPC: Retry: opt-in retry policies and retry throttling.
- Model Context Protocol changelog (2025-06-18): a JSON-RPC-based protocol removing batching.
Books
- High Performance Browser Networking, Ilya Grigorik, chapter 16 "Server-Sent Events (SSE)" and chapter 17 "WebSocket": the wire formats and their performance costs.
- Building Microservices (2nd edition), Sam Newman, chapter 4 "Microservice Communication Styles": synchronous blocking, asynchronous, request-response and event-driven styles compared.
- Designing Data-Intensive Applications (2nd edition), Martin Kleppmann and Chris Riccomini, chapter 5 "Encoding and Evolution": dataflow through services, RPC and message brokers.
Other curricula
- donnemartin/system-design-primer: frames the choice as RPC versus REST with a side-by-side table; push styles and GraphQL are not covered.
- Azure: Asynchronous Request-Reply pattern: when to poll a status endpoint and when SSE, WebSockets or webhooks fit better.
Watch
- Top 6 Most Popular API Architecture Styles (ByteByteGo): REST, GraphQL, gRPC, WebSocket, webhooks and SOAP side by side in a few minutes.
- GraphQL, gRPC or REST? Resolving the API Developer's Dilemma (NDC Conferences): Rob Crowley's conference talk on choosing between styles by constraints, not fashion.
Free resources
- JSON-RPC 2.0 specification: the whole protocol on one page, including batches, notifications and error codes.
- REST vs. RPC: what problems are you trying to solve with your APIs? (Google Cloud): a clear comparison of resource and procedure styles from Google's API team.
- Server-sent events (WHATWG HTML standard): the event stream format, reconnection and
Last-Event-ID. - RFC 6455: The WebSocket Protocol: the upgrade handshake, framing and masking.
- Standard Webhooks: a common specification for webhook signing, IDs and retries.
- AsyncAPI concepts: how event-driven APIs are described with channels, messages and operations.