Skip to content

    System Design

    REST, gRPC, GraphQL and WebSockets: Choosing an API Style

    Compare REST, gRPC, GraphQL and WebSockets by contract, errors, evolution, streaming and failure behaviour, then choose a style per conversation.

    By Published 18 min read

    Share

    Flagship lessonintermediateBackend System Design

    Before this: API Design

    What you'll learn: By the end of this lesson you will be able to describe REST-style HTTP APIs, gRPC, GraphQL and WebSockets by what they promise in five areas (contract, how a call travels, how errors are reported, how the interface changes, and what shape of conversation they allow), read the primary sources behind each, predict what each does when a call times out or a connection drops, and choose a style for each conversation in a system instead of one style for everything.

    A restaurant offers four ways to be served. The menu is printed, the same for every table, and any waiter can take an order from it. The supplier's order form is a strict form with numbered boxes that two kitchens agreed on, quick to fill in and easy to check. The build-your-own plate lets the guest list exactly the items wanted and brings them in one trip. The open phone line stays connected, so either side can speak when something changes. Each fits a different conversation, and each fails differently: a printed menu cannot warn you that the soup ran out a minute ago, and a phone line that drops leaves both sides unsure what was heard last.

    Those four are, in order, resource-style HTTP APIs (usually called REST), gRPC, GraphQL and WebSockets. The API Design lesson teaches how to design a good REST API and gives a three-row comparison; ShopStream Stage 8 picks styles in a few bullets. This lesson goes further: what each style promises on the wire, how it reports errors, how it evolves, and what happens when things break.

    Verified against RFC 9110, RFC 6455, the GraphQL specification (October 2021 edition), the gRPC documentation and protocol notes, the Protocol Buffers proto3 guide and MDN, on 2026-11-08.

    The core idea

    An API style is a bundle of five decisions, not a brand. Compare the styles on the same five questions and the choice becomes a reasoned one.

    Decision The question it answers
    Contract What is written down, and who can check a call against it?
    Transport mapping How does one call travel over the network?
    Error model How does a failure reach the caller, and can a generic layer see it?
    Evolution What changes are safe without breaking callers that have not upgraded?
    Conversation shape One request and one reply, a stream, or messages in both directions at any time?

    Three examples run through the lesson, each labelled: a minimal example (one "get order" call written in all four styles), a realistic example (ShopStream's browser, service and courier conversations, in "In practice") and a failure example (an error that a generic monitor cannot see).

    The shared floor: HTTP semantics

    Three of the four styles run on HTTP, so its semantics set the floor. RFC 9110 is the specification.

    • Safe and idempotent methods. A safe method is one whose semantics are essentially read-only (section 9.2.1). A method is idempotent if multiple identical requests have the same intended effect as one; PUT, DELETE and the safe methods are idempotent (section 9.2.2). A client may repeat an idempotent request automatically after a communication failure, and should not automatically retry a non-idempotent one unless it knows the request is actually idempotent or can detect that the original was never applied.
    • GET is where caching lives. The RFC calls GET "the primary mechanism of information retrieval and the focus of almost all performance optimizations" (section 9.3.1). A response to POST is cacheable only with explicit freshness information and a matching Content-Location (section 9.3.3).
    • POST is open-ended. It asks the target resource to process the enclosed representation "according to the resource's own specific semantics" (section 9.3.3).

    Every style below builds on these rules (REST-style APIs), tunnels through one method (gRPC and GraphQL use POST), or leaves them behind after a handshake (WebSockets). What each does with them explains much of its failure behaviour.

    Minimal example: one call, four styles

    The call is "get the status of order o_123". Everything below is illustrative text in each style's own notation.

    Resource-style HTTP. The identifier is in the URL and the method says what to do:

    GET /v1/orders/o_123 HTTP/1.1
    Accept: application/json

    gRPC. The contract is a Protocol Buffers file; by default gRPC uses protocol buffers as its interface definition language:

    service OrderService {
      rpc GetOrder(GetOrderRequest) returns (Order);
    }
    
    message GetOrderRequest { string order_id = 1; }
    message Order {
      string order_id = 1;
      string status = 2;
      int64 total_minor = 3;
    }

    GraphQL. One endpoint, and the client names the fields it wants:

    query {
      order(id: "o_123") {
        status
        totalMinor
      }
    }

    WebSocket. After a handshake, either side sends messages on one open connection. RFC 6455 defines the handshake, framing, ping, pong and close; what a message means is the application's job, so this JSON is an invented convention:

    { "type": "subscribe", "topic": "order.o_123" }

    The first three are pull: the client asks and the server answers. The fourth is a conversation the server can start, which is the first difference to decide.

    Resource-style HTTP APIs

    A resource-style API exposes nouns at URLs and uses HTTP methods and status codes as the vocabulary: GET /v1/orders/o_123 reads, POST /v1/orders creates, and a 201 with a Location header (RFC 9110 section 9.3.3 says the server SHOULD send one when POST creates a resource) points at the result.

    • Contract. Usually a document plus conventions; nothing in HTTP forces a call to match it, so checking is a tool concern.
    • Errors. The status code is the first signal, and every HTTP-aware layer understands it: proxies, load balancers, monitors and retry libraries. A 503 means temporary overload or maintenance and may carry Retry-After (sections 15.6.4 and 10.2.3). RFC 9457 defines a JSON body format, application/problem+json, with members such as type and detail, so errors can be machine-readable.
    • Evolution. Adding fields and endpoints is safe for tolerant clients; removing or reinterpreting them is not.
    • Shape. Request and reply. Long-running work returns 202 Accepted, which RFC 9110 says is "intentionally noncommittal": the work was accepted, not finished, and the representation should point to a status monitor.
    • Strength. The widest tooling, and the style standard HTTP caches handle most naturally.
    • Cost. A screen that needs five resources makes five calls or needs a purpose-built aggregation endpoint; the Backend for Frontend pattern exists for that.

    gRPC

    gRPC is a remote-procedure-call framework: the caller invokes a named method with typed input and gets typed output, as if calling a local function. The core concepts page lists four kinds of call: unary, server streaming, client streaming and bidirectional streaming, each with message order preserved within the call.

    How a call travels. The gRPC over HTTP/2 document specifies the mapping. Every call is an HTTP/2 POST to a path built from the service and method name, with content type application/grpc and an optional grpc-timeout header. The status of the call is not the HTTP status. The reply carries :status 200, and the result arrives in trailers as grpc-status and grpc-message. A call that fails immediately can send a "Trailers-Only" response. The document also says to expect broken deployments that return other HTTP statuses, and requires implementations to synthesise a status when that happens.

    sequenceDiagram accTitle: A gRPC unary call with a deadline accDescr: The client sets a deadline and sends an HTTP/2 POST with a grpc-timeout header. The server sends response headers with HTTP status 200, one message, and trailers carrying grpc-status. If the deadline passes first, the client gives up with DEADLINE_EXCEEDED and the server is told the call was cancelled. participant C as Client participant S as Server C->>S: POST /OrderService/GetOrder, grpc-timeout 800m alt answered in time S-->>C: headers, status 200 S-->>C: one message S-->>C: trailers, grpc-status 0 else deadline passes first Note over C: gives up, DEADLINE_EXCEEDED C--xS: cancel the call Note over S: server must stop the work it started end

    Figure 1. A unary gRPC call and its two endings. How to read this: the status of a successful call lives in trailers, after the message, not in the HTTP status line. If the deadline passes first, the client ends the call and the server is cancelled; the server application still has to stop work it started, because changes made before a cancellation are not rolled back.

    Deadlines and failure. The deadline guide says gRPC sets no deadline by default, so clients should always set a realistic one. Propagation of a deadline to outgoing calls is language-dependent: on by default in some (Java and Go are named) and explicit in others (C++). The status codes page gives each failure its own code: UNAVAILABLE ("most likely a transient condition, which can be corrected by retrying with a backoff"), DEADLINE_EXCEEDED (for state-changing operations the call may still have succeeded), ABORTED (retry at a higher level) and FAILED_PRECONDITION (do not retry until the state is fixed). Retries are configured per method in a service config: the retry guide says retries are enabled by default but there is no default retry policy, that a call is "committed" once response headers arrive, and that retry throttling is a token bucket.

    Evolution. The proto3 guide is explicit. Each field has a number that identifies it in the wire format; it cannot be changed once the type is in use and must never be reused. Adding fields is safe, removing fields is safe if the number is reserved, and changing a field's number is not safe. A parser that meets a field it does not know preserves it, and a missing field reads as the default value. That is why gRPC contracts evolve well between teams that deploy independently, as long as numbers are treated as permanent.

    Browsers. The gRPC-Web project lets browser clients reach gRPC services. Its README says clients connect through a special proxy (Envoy by default), that it supports unary and server-side streaming, and that client-side and bidirectional streaming are not currently supported. The gRPC-Web basics tutorial uses an Envoy proxy and mentions that CORS setup may be needed.

    GraphQL

    GraphQL is a query language and execution model. The specification defines three operation types: query (a read-only fetch), mutation (a write followed by a fetch) and subscription (a long-lived request that fetches data in response to source events). A typed schema describes what can be asked, and the client chooses the fields.

    Execution rules that matter for failure.

    • Fields of a query may be executed in whatever order the executor chooses, normally in parallel. The top-level fields of a mutation are executed serially, in the order written. A mutation with two top-level changes is therefore ordered. The rules specify order, not rollback: they do not say the first change is undone if the second fails.
    • A response is a map that may contain data, errors and extensions. If a request fails before execution (syntax or validation error), data is absent and errors is not empty. If a field error occurs during execution, the field is handled as null, the error is added to the errors list, and the response is a partial result: data and errors can both be present.
    • If a field of a non-null type would be null, the error propagates to the parent field. If every field on the path is non-null, data itself becomes null.

    How a call travels. The spec is largely transport-independent; the practical rules are in the Serving over HTTP page, which describes recommendations aligned with the GraphQL-over-HTTP specification, still a draft at the time of reading. A GraphQL HTTP server must handle POST for queries and mutations and may accept GET for queries only. The request body carries query, optionally operationName and variables. For the response, the page says that if the response has a non-null data, the server should respond with a 2xx status "even if the response includes errors, since HTTP does not yet have a status code representing 'partial success'". It also notes that GET URLs can hit length limits in browsers and CDNs and mentions persisted documents (a short identifier in place of the full query text) as a workaround.

    Strengths and costs.

    • One round trip can fetch what a screen needs.
    • Because the endpoint is one URL reached mostly by POST, and RFC 9110 makes POST responses cacheable only with explicit freshness information, standard HTTP caching has little to work with.
    • Whether a query is expensive depends on what the client wrote, so protection against deep or wide queries has to be added on the server. The spec describes execution, not cost limits. This follows from the design rather than from a cited rule, so treat it as design advice.
    • Evolution is by adding fields and types, and the schema is introspectable, so tooling can check a client's query against it.

    WebSockets

    WebSockets give a long-lived, two-way connection. RFC 6455 describes the problem it replaces: bidirectional communication built from HTTP polling forces many connections, repeats headers on every message and makes the client match replies to requests. A single TCP connection in both directions is the simpler solution.

    How a connection travels. The client sends an HTTP Upgrade request carrying a Sec-WebSocket-Key; the server answers 101 Switching Protocols with Sec-WebSocket-Accept. Any other status means the handshake did not complete. The schemes are ws (default port 80) and wss (default port 443, over TLS). After the handshake, messages travel in frames, a client must mask every frame it sends, and control frames include Ping (the receiver must reply with Pong), Pong and Close. Section 5.5.2 notes that Ping can serve as a keepalive or to check that the other side still responds.

    stateDiagram-v2 accTitle: A WebSocket client connection with reconnect and backoff accDescr: The client connects. On success it is open. A normal close ends it. An abnormal closure leads to a waiting state with a random delay that grows after each failed attempt, then back to connecting. [*] --> Connecting Connecting --> Open: 101 Switching Protocols Connecting --> Waiting: handshake or network failure Open --> Closed: normal close Open --> Waiting: abnormal closure, no close frame Waiting --> Connecting: random delay, longer after each failure Closed --> [*]

    Figure 2. Reconnect as part of the design. How to read this: the protocol does not reconnect for you. Section 7.2.3 of RFC 6455 tells clients to use backoff after abnormal closures, with a first delay chosen randomly (it suggests 0 to 5 seconds as reasonable) and longer delays after failures, for example truncated binary exponential backoff.

    Failure behaviour. Close code 1006 is reserved to mean the connection closed abnormally, without a close frame, and an endpoint must not send it. RFC 6455 warns that if every client reconnects immediately and persistently after a non-transient problem, the server may experience what amounts to a denial-of-service attack. The MDN WebSockets API page adds a limit of the browser interface: it does not support backpressure, so when messages arrive faster than the application handles them, the device can run out of memory, become unresponsive, or both; MDN points to WebTransport when backpressure matters. Backpressure covers what a design can do about it.

    Security. Servers that serve only certain sites should verify the Origin header and answer 403 for an origin they do not expect (section 10.2). The check protects against malicious script running in a trusted browser; it does not stop non-browser clients.

    Evolution. None is built in. The message format is yours, so versioning the messages is your job.

    Server-Sent Events. If only the server needs to push, MDN's guide to server-sent events describes a simpler, one-way option: a response with media type text/event-stream and an EventSource that by default restarts a closed connection, with a retry field to set the reconnection time. When not used over HTTP/2 the limit is six open connections per browser and domain; over HTTP/2 the number of simultaneous streams is negotiated and defaults to 100.

    Comparing the styles

    Resource-style HTTP gRPC GraphQL WebSocket
    Contract Document plus conventions Protobuf file, code generated Typed schema None built in
    One call travels as One method and URL HTTP/2 POST to a method path POST (or GET for queries) to one URL Frames on an open connection
    Where the status lives HTTP status grpc-status in trailers, HTTP 200 errors in the body, often 2xx Close code or your message
    Safe to retry By method (GET, PUT, DELETE) By the method's meaning and UNAVAILABLE Queries yes; mutations by design Your protocol
    Evolution Additive changes Field numbers, reserved Add fields and types Your convention
    Shape Request and reply Unary and three streams Request and reply, subscriptions Two-way, any time
    Caches Standard HTTP caches Not by URL Hard: one endpoint, POST Not applicable
    Browser Native Needs gRPC-Web and a proxy Native over HTTP Native

    Failure example: the error a generic monitor cannot see

    The failure example is not a crash but silence. A shopper's order page runs a GraphQL query with three fields. The loyalty field fails; the other two succeed. By the rule quoted earlier, the server answers with a 2xx status and a body with data and errors. A dashboard that counts HTTP 5xx responses shows a healthy service while a field fails for every user. The same trap exists in gRPC, where an error response still has HTTP status 200 and the real status is in the trailers.

    The fix is to count failures where the protocol puts them: parse errors for GraphQL, read grpc-status for gRPC, and alert on those rates. A generic retry layer is equally blind to a 200 that carried a transient error, so choosing a style is partly choosing where retry and monitoring tools must look. The wider design is in Failure Handling End to End.

    Choosing: a decision flow

    flowchart TD accTitle: Choosing an API style per conversation accDescr: Start with who the caller is. If the server must push updates, choose server-sent events for one-way or WebSockets for two-way. Otherwise for service to service calls with typed contracts choose gRPC. For browsers and partners with many client shapes consider GraphQL, otherwise resource-style HTTP. S["One conversation between two parties"] --> P{"Does the server need to push without being asked?"} P -->|"yes, one way"| SSE["Server-sent events"] P -->|"yes, both ways"| WS["WebSocket"] P -->|"no"| C{"Both ends yours, in a controlled network?"} C -->|"yes"| G["gRPC: typed contract, deadlines, streaming"] C -->|"no"| V{"Many client types need different shapes of the same data?"} V -->|"yes"| GQ["GraphQL, with server-side cost limits"] V -->|"no"| R["Resource-style HTTP: caching, tooling, partners"]

    Figure 3. A first-pass decision per conversation. How to read this: ask the questions for each conversation, not once for the system. The answer for the browser-facing edge is usually different from the answer between two services.

    The flow is a starting point; team skills and tooling can override it, so say so in the decision record.

    The other side of this topic

    The browser sees these styles as a data-fetching choice: how a screen caches, cancels and retries responses, and how it keeps a live view in step. The Frontend System Design track plans a lesson on consuming REST, GraphQL and tRPC from the client; it is not published yet, so there is no link.

    In practice: styles per conversation in ShopStream

    This is the realistic example, an educational scenario on the course's running example. Numbers marked illustrative are not measurements.

    This is a realistic scenario, not a case history.

    The situation

    ShopStream's Stage 8 has seven services behind a gateway. The browse path peaks near 2,300 requests per second and checkout near 58 per second (the course's shared illustrative figures). Three conversations need an API style: the shopper's app reading and placing orders, services calling each other, and live courier tracking. The team asks whether one style should serve all three.

    Starting architecture

    Three ShopStream conversations, three API stylesShopper app to BFF (HTTP, JSON); BFF to Orders (gRPC); BFF to Catalogue (gRPC); Courier app to Tracking (WebSocket); Tracking to BFF (events)INSIDE SHOPSTREAMShopper app: browser and phoneShopper appbrowser and phoneBFF: HTTP and JSONBFFHTTP and JSONOrdersOrdersCatalogueCatalogueTracking: holds connectionsTrackingholds connectionsCourier appCourier appHTTP, JSONgRPCgRPCWebSocketevents

    Figure 4. The proposed split. How to read this: the solid arrows are request and reply; the dashed arrow is a push. The shopper's app never talks gRPC or holds a socket to the order services; only the Tracking service holds long-lived connections.

    Options considered

    Option Gain Cost
    A. HTTP and JSON everywhere, tracking by polling One style, easy to cache Polling load, delayed positions
    B. GraphQL for all client calls Flexible screens Cost limits, weaker caching, new skills; only one or two screens would benefit
    C. HTTP and JSON at the edge, gRPC inside, WebSocket for courier updates Each conversation gets a fitting style Three contracts to maintain and a socket service to operate

    The decision

    Option C. The edge stays HTTP so the CDN and browsers work as they do today. Between services, gRPC gives typed contracts with deadlines that each caller must set explicitly (the guide says none is set by default). Courier positions arrive over a WebSocket because the courier app is a controlled client and the traffic is frequent and two-way. The shopper's tracking view starts with short polling of the BFF and moves to server-sent events when the polling load justifies the change. GraphQL is deferred until several client teams need different shapes of the same data.

    Rollout and verification

    A rollout would start with one gRPC call (catalogue lookups), with a deadline set from the call's measured latency and retries limited to UNAVAILABLE. Verification checks that gateway and BFF metrics read grpc-status, not only the HTTP status, and watches how many tracking connections drop in a rolling deploy and how fast they return.

    The 2 a.m. failure

    The page reads: "tracking connections dropped to zero, then login errors spiking". A deploy restarted every Tracking instance at once. Each courier app lost its connection and reconnected immediately, and the authentication service received the whole fleet in a few seconds. First checks: connection count over time, reconnect attempts per second, the auth service's error rate. Mitigation: stop the rollout and shed reconnects at the gateway by answering the handshake with a non-101 status such as 503, which RFC 6455 treats as a handshake that did not complete. Lasting fix: random first delay and growing backoff in the client (the shape in Figure 2), draining instances gradually, and a connection budget per instance.

    Architect's notes

    • Choose per conversation, and record the five decisions for each in the design document.
    • Treat the contract as the product. Protobuf field numbers and GraphQL fields are promises that outlive the code.
    • Teach generic layers about errors. Monitors, retries and gateways must read grpc-status and GraphQL errors.
    • Set a deadline on every gRPC call, and design reconnect and backoff before launching any socket.
    • Defer GraphQL until the pain is named, and add cost limits the day it ships.

    Common mistakes

    • One style for everything, including conversations it fits badly.
    • Reading only the HTTP status of a gRPC or GraphQL response.
    • Calling GraphQL mutations atomic. The spec orders top-level fields; it does not roll back.
    • Reusing a protobuf field number after deleting a field.
    • Assuming WebSockets reconnect and replay on their own.
    • Leaving gRPC calls without deadlines.

    Key takeaways

    • A style is five decisions: contract, transport mapping, error model, evolution, conversation shape.
    • Resource-style HTTP uses method and status semantics from RFC 9110 and is the style standard caches handle most naturally.
    • gRPC calls are HTTP/2 POSTs whose result lives in trailers; deadlines, status codes and field numbers carry its failure and evolution rules.
    • GraphQL lets the client shape the response, orders top-level mutation fields, and reports field errors in a partial result, usually with a 2xx status.
    • WebSockets give two-way messages after an HTTP upgrade; reconnect, backoff and message format are the application's job, and the browser interface has no backpressure.
    • Server-sent events are the simpler choice when only the server pushes.
    • Choose per conversation, and make monitoring and retry layers read each style's own error signal.

    Practice exercises

    Exercise 1 (30 min). Write the "cancel order" call in all four styles. For each, say what a client should do after a timeout.

    Exercise 2 (45 min). Take the proto in this lesson. Remove status, add currency, and write the reserved line. Which changes are safe, per the proto3 guide?

    Exercise 3 (1 hour). Write the alert rules for GraphQL errors and gRPC grpc-status for the ShopStream order page.

    Exercise 4 (2 hours). Apply the decision flow to a food-delivery app's four conversations (menu browse, order placement, kitchen display, driver position). Where do you override the flow, and why?

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments