The API decision matrix

What it is

The choice between REST, GraphQL, gRPC, WebSocket, SSE and webhooks, decided by who the consumer is and what shape the interaction has, rather than by which is most modern.

                     BEST WHEN                    WORST WHEN
REST        public API, many unknown        deeply nested reads
            consumers, cacheable reads      needing many round trips

GraphQL     one API, many client shapes,    public API with unknown
            clients you cannot ship         consumers; simple CRUD
            changes to quickly

gRPC        service to service, internal,   browser clients without a
            high volume, low latency        proxy; public APIs

WebSocket   genuinely bidirectional,        one-directional streaming
            low-latency, stateful

SSE         server-to-client streaming      binary payloads; the
            over plain HTTP                 client needs to send
                                            mid-stream

WEBHOOK     server-to-server events to      anything needing a
            systems you do not control      response, or ordering

Commonly confused with a single choice. Most real systems use several, and the useful answer names which surface uses which and why, rather than picking one.

Also commonly confused with a performance question. The dominant factor is usually the consumer relationship: whether you can change the client, whether you know who the clients are, and whether you can require a proxy or a code generator.

The problem it solves

The wrong protocol shows up as a permanent tax rather than as a failure.

REST for a mobile client with deeply nested needs
  -> 8 round trips per screen. On a mobile network at 150 ms
     each, that is 1.2 seconds of pure latency, and the fix
     is either a bespoke aggregate endpoint per screen
     (which multiplies as screens multiply) or a different
     protocol.

GraphQL as a public API
  -> An unknown consumer writes a query that joins six
     resources at depth 9 and takes down the database. You
     cannot rate-limit by endpoint because there is one
     endpoint, and you cannot see the cost until you parse
     the query.

gRPC through an L4 load balancer
  -> HTTP/2 multiplexes over one long-lived connection, so
     an L4 balancer balances CONNECTIONS and every request
     from a client goes to the same backend. Load skews,
     and autoscaling makes it worse.

WebSocket for one-directional token streaming
  -> An upgrade handshake that middleboxes break, no
     built-in reconnection semantics, and none of the
     bidirectionality used.

Mechanics

The questions, in the order that eliminates fastest

1. WHO IS THE CONSUMER?
   Internal service            -> gRPC is available
   Your own frontend           -> GraphQL is available
   Third parties, unknown      -> REST, almost certainly
   A browser without a proxy   -> not raw gRPC

2. CAN YOU CHANGE THE CLIENT?
   Yes, quickly                -> the field is open
   Mobile app in the field     -> the API must be
                                  additive-only for months
   Third party                 -> versioning is a public
                                  commitment

3. WHAT IS THE INTERACTION SHAPE?
   Request/response            -> REST, GraphQL, gRPC
   Server pushes, client
     listens                   -> SSE
   Both push, continuously     -> WebSocket
   Server notifies another
     server                    -> webhooks

4. IS THE READ SHAPE FIXED OR VARIABLE?
   Fixed, few shapes           -> REST
   Highly variable per client  -> GraphQL earns its cost
   Fixed and high volume       -> gRPC

5. DOES CACHING MATTER?
   Yes, heavily                -> REST, because HTTP caching
                                  works on URLs and every
                                  CDN already implements it
   Not really                  -> the others are open

Question 2 is the one people skip and it constrains everything. A mobile client in the field means your API surface is effectively append-only for as long as old versions persist, which is months to years, and that changes the design far more than any performance consideration.

REST: still the default for public APIs

And the reason is not familiarity.

WHAT YOU GET, and it is more than it appears
  HTTP caching, working, at every layer: browser, CDN,
    reverse proxy. This is enormous and the alternatives do
    not have it.
  Every tool understands it: curl, Postman, browser
    devtools, every language's stdlib.
  Idempotency and safety semantics are in the method.
  Status codes that intermediaries act on.
  Rate limiting per endpoint, because there are endpoints.

WHAT IT COSTS
  Over-fetching and under-fetching: an endpoint returns a
  fixed shape, so a client wanting three fields gets forty,
  and a client wanting nested data makes several calls.
  Versioning is a public commitment.

The under-fetching problem is real and the usual fix is worse: bespoke aggregate endpoints per screen (/mobile/home-screen-v3) which multiply as screens multiply and couple the API to the UI. That multiplication is the actual argument for GraphQL, not the query language.

GraphQL: what it buys and what it costs

BUYS
  One request per screen regardless of nesting. The
  under-fetching problem disappears.
  Clients evolve their queries without server changes,
  which is what matters when you cannot ship a client
  quickly.
  A typed schema and introspection.
  Field-level deprecation rather than versioned endpoints.

COSTS, and each is a real system to build
  N+1 by construction, so you need DataLoader batching.
  See: GraphQL N+1.
  Query cost analysis, because an unbounded query can be
  arbitrarily expensive and you must reject it before
  execution.
  HTTP caching does not apply: one POST endpoint, so no
  URL to cache. You need persisted queries plus client-side
  normalised caching to get any of it back.
  Rate limiting must be by query COST rather than by
  request count.
  Errors come back with 200 and a partial result, which
  every client and every monitoring integration has to
  handle deliberately.

The honest summary: GraphQL moves complexity from the client to the server. That is the right trade when you have many client shapes and cannot ship clients quickly, and the wrong one for simple CRUD with one client.

Persisted queries are the mitigation worth knowing: the client sends a hash of a pre-registered query rather than the query text, which restores some caching, removes the arbitrary-query risk entirely, and makes cost analysis a build-time problem. It also means you have effectively re-created endpoints, which is worth saying out loud, and for a first-party client that is usually fine.

gRPC: internal, and the load-balancing trap

BUYS
  Protobuf: compact binary, generated clients, and schema
  evolution rules that make additive changes safe by
  construction.
  HTTP/2 multiplexing: many concurrent requests on one
  connection.
  Streaming in both directions, natively.
  Typically 2 to 10x smaller payloads than JSON and
  meaningfully lower serialisation cost.

COSTS
  Browsers cannot speak it without grpc-web plus a proxy.
  Not human-readable, so debugging needs tooling.
  The load balancing trap below.

The trap, which is the most commonly asked gRPC question:

HTTP/2 multiplexes many requests over ONE long-lived
connection. An L4 load balancer balances CONNECTIONS.

So a client opens one connection, it lands on backend 3, and
EVERY request from that client goes to backend 3 for the
life of the connection.

  With a few high-volume clients, load is badly skewed.
  Autoscaling makes it worse: new backends receive no
  traffic because no new connections are being made.

THE FIXES
  L7 load balancing (Envoy, Linkerd, an L7 ALB) which
    balances REQUESTS.
  Client-side load balancing with service discovery, which
    is what gRPC's own load balancing does.
  Periodic connection recycling (MAX_CONNECTION_AGE), which
    forces rebalancing and is the cheap partial fix.

Naming that HTTP/2's connection reuse is the mechanism, rather than "gRPC needs L7", is the depth signal, because the same problem applies to any HTTP/2 service.

SSE versus WebSocket

The question is whether the client needs to SEND
mid-stream.

  streaming LLM tokens        -> one-directional -> SSE
  a live dashboard            -> one-directional -> SSE
  a collaborative editor      -> bidirectional   -> WebSocket
  a multiplayer game          -> bidirectional   -> WebSocket
  a chat application          -> bidirectional, though SSE
                                 plus POST is viable and
                                 simpler

SSE's advantages are all consequences of being plain HTTP: every proxy, CDN and corporate firewall already handles it, whereas WebSocket's upgrade handshake is a thing middleboxes break. Automatic reconnection with Last-Event-ID is in the protocol. Standard auth, standard compression, standard observability.

The two limitations to know: over HTTP/1.1 browsers cap connections per origin at six and a long-lived SSE stream occupies one, which matters if a page opens several (HTTP/2 multiplexing removes it); and SSE is UTF-8 text only.

And the operational detail that catches people: disable proxy buffering, or the whole response arrives at once after generation completes and streaming is silently defeated.

Webhooks: the one with the most failure modes

Server-to-server event delivery to systems you do not
control, and every property you would want is absent by
default.

  NO ORDERING              events arrive out of order,
                           routinely. Include a sequence
                           number or a timestamp and let the
                           consumer reorder.
  AT-LEAST-ONCE            retries mean duplicates. Include
                           an event id and require
                           idempotent handling.
  NO BACKPRESSURE          a slow consumer cannot slow you
                           down, so you need a retry policy
                           with backoff and eventual
                           dead-lettering.
  SECURITY                 sign the payload (HMAC over the
                           body with a shared secret, plus a
                           timestamp to prevent replay), and
                           document verification.
  THE CONSUMER IS DOWN     retry with exponential backoff
                           for hours, then dead-letter and
                           expose a replay endpoint.

And the alternative worth offering: let the consumer poll. A well-designed cursor-paginated events endpoint removes every problem above (ordering, delivery, backpressure, security) at the cost of latency and consumer effort. For many integrations that is a better trade than it sounds, and offering both is common.

A worked example: one product, five protocols

A B2B SaaS product.

  browser SPA          -> GraphQL
     Many screens with different data shapes, and the
     frontend ships several times a day so schema and client
     evolve together. Persisted queries in production, so
     arbitrary queries are impossible and cost analysis
     happens at build time.

  mobile apps          -> GraphQL, same gateway
     The stronger argument here: we cannot ship a client
     quickly, so a client in the field must be able to
     request what it needs without a server change. And
     mobile networks make round trips expensive, so one
     request per screen matters more.

  public API           -> REST
     Unknown consumers, so we need per-endpoint rate
     limiting, HTTP caching, and a versioning story we can
     commit to publicly. GraphQL here would mean an unknown
     third party can write an arbitrarily expensive query
     and we cannot see the cost until we parse it.

  service to service   -> gRPC
     Internal, high volume, and we control both ends.
     Protobuf's additive-change rules make schema evolution
     safe. Behind an L7 mesh, because of the connection
     balancing problem.

  live notifications   -> SSE
     One-directional, plain HTTP so it works through
     customer proxies, and reconnection is in the protocol.

  customer integrations -> webhooks, AND a polling endpoint
     Signed with HMAC, event ids for idempotency, sequence
     numbers for ordering, exponential backoff with
     dead-lettering and a replay endpoint. And a
     cursor-paginated events API for customers who would
     rather poll, which is a meaningful fraction.

The observation to make: five protocols is not incoherence, it is five different consumer relationships. The public API and the internal services differ in whether you know the consumer; the SPA and the mobile app differ in how fast you can ship a client; notifications and integrations differ in whether the consumer is a browser or a server you do not control.

And the anti-pattern this avoids: using one protocol everywhere means either gRPC on a public API (unusable by third parties), GraphQL internally (paying its complexity with none of its benefit, since you control both ends), or REST for the mobile client (eight round trips per screen).

Production evidence

GitHub's move from a GraphQL-first public API back to offering REST alongside it is a useful data point on GraphQL as a public surface: they kept both, and the REST API remains the one most integrations use.

Netflix's and Facebook's published GraphQL usage is first-party-client-facing, which is the case it was designed for: Facebook built it for their own mobile clients, and the "cannot ship a client quickly" constraint is the original motivation.

gRPC's own documentation on load balancing describes client-side load balancing and the look-aside model precisely because L4 balancing of HTTP/2 connections is a known and documented problem.

Protobuf's schema evolution rules (never reuse a field number, only add optional fields, never change a type) are what make additive changes safe by construction, and the same discipline in JSON is convention rather than enforcement.

The HTML5 EventSource specification defines Last-Event-ID and automatic reconnection, which is why SSE resumption is built in rather than implemented per application.

Stripe's webhook documentation is the reference implementation of the practices above: signed payloads with a timestamp, event ids for idempotency, documented retry schedule, and a dashboard for replay. Their approach is widely copied because the failure modes are universal.

The debate

The case for REST everywhere: universal, cacheable, debuggable with curl, understood by every tool and every developer. The alternatives each solve a real problem and each add a system you must build and operate.

The case for GraphQL: it solves over-fetching and under-fetching properly, and for a product with many client shapes and slow client release cycles the alternative is a proliferation of bespoke endpoints that couples the API to the UI.

The case for gRPC internally: typed contracts, generated clients, compact payloads, and schema evolution rules that make additive changes safe rather than merely conventional. For service-to-service at volume it is straightforwardly better.

The case for one protocol everywhere: consistency has real value; every additional protocol is tooling, expertise, monitoring and a set of failure modes.

My position: REST for public APIs, gRPC internally, GraphQL for first-party clients you cannot ship quickly, SSE for one-directional streaming, and webhooks with a polling alternative.

The dominant variable is the consumer relationship, not performance. Whether you know who the clients are decides REST versus GraphQL for a public surface, because an unknown consumer writing an arbitrarily expensive query is a risk you cannot rate-limit by endpoint. Whether you can ship a client quickly decides whether GraphQL's complexity pays: for a web SPA that deploys daily, the argument is weaker than people think, because you can just add an endpoint. For a mobile client in the field, it is much stronger, because the alternative is shipping a server change and waiting months for adoption.

The GraphQL cost I would state plainly is that it moves complexity from the client to the server, and each piece is a real system: DataLoader batching, query cost analysis, persisted queries, cost-based rate limiting, and error handling that returns 200 with a partial result. That is the right trade with many client shapes and the wrong one for CRUD with one client.

For gRPC the thing I would always mention is the HTTP/2 connection-balancing trap, because it is the most common production surprise: HTTP/2 multiplexes over one long-lived connection, an L4 balancer balances connections, so every request from a client hits the same backend and autoscaling makes it worse because new backends receive no new connections. The fix is L7 or client-side balancing, and connection recycling is the cheap partial one.

And on webhooks, offer a polling endpoint alongside them. Webhooks lack ordering, exactly-once delivery, backpressure and security by default, so every one of those becomes something you build and document. A cursor-paginated events endpoint removes all four at the cost of latency, and a meaningful fraction of integrators prefer it.

Where I would push back on the framing: "which API style should we use" is usually the wrong question, because a real product has several consumer relationships and five protocols is coherent rather than messy. The incoherent version is using one everywhere, which means either an unusable public API, unnecessary complexity internally, or eight round trips per mobile screen.

Follow-up Q&A

"How do you choose between REST, GraphQL and gRPC?" By the consumer relationship rather than by performance. Who is the consumer: an internal service makes gRPC available, unknown third parties mean REST almost certainly, a browser without a proxy rules out raw gRPC. Then, can you change the client quickly? That is the question people skip and it constrains everything, because a mobile app in the field means your API is effectively append-only for months. Then the interaction shape, the read-shape variability, and whether HTTP caching matters.

"Why not GraphQL for a public API?" Because an unknown consumer can write a query joining six resources at depth nine and you cannot see its cost until you parse it. You also cannot rate-limit by endpoint, because there is one endpoint, so rate limiting must be by computed query cost. And HTTP caching does not apply at all, since there is no URL to cache. Persisted queries fix most of that and they require registering queries in advance, which works for first-party clients and not for unknown third parties.

"When does GraphQL actually earn its cost?" When you have many client shapes and cannot ship a client quickly. For a mobile app in the field that is a strong argument: the client can change what it requests without a server deploy, and one request per screen matters more on a mobile network. For a web SPA that deploys several times a day it is weaker than people assume, because you can just add an endpoint. And what it costs is real: DataLoader batching because N+1 is structural, query cost analysis, persisted queries, cost-based rate limiting, and 200-with-partial-errors that every client must handle.

"What's the gRPC load-balancing problem?" HTTP/2 multiplexes many requests over one long-lived connection, and an L4 load balancer balances connections. So a client's connection lands on one backend and every request goes there for the life of the connection, which skews load badly with a few high-volume clients. Autoscaling makes it worse, because new backends receive nothing when no new connections are being made. The fixes are L7 balancing which balances requests, client-side balancing with service discovery, or connection recycling via max connection age as the cheap partial fix. And it is an HTTP/2 property, not a gRPC one.

"SSE or WebSocket?" Whether the client needs to send mid-stream. Streaming LLM tokens or a live dashboard is one-directional, so SSE, and its advantages all follow from being plain HTTP: every proxy and firewall handles it, automatic reconnection with Last-Event-ID is in the protocol, and auth and compression are standard. WebSocket for genuinely bidirectional work like a collaborative editor. And the operational detail people hit: disable proxy buffering, or the whole response arrives at once after generation and streaming is silently defeated.

"What's hard about webhooks?" Everything you would want is absent by default. No ordering, so include a sequence number and let the consumer reorder. At-least-once delivery, so include an event id and require idempotent handling. No backpressure, so you need exponential backoff and eventual dead-lettering with a replay endpoint. And no security, so sign the payload with HMAC over the body plus a timestamp to prevent replay. Every one of those is something you build and document.

"Is there an alternative to webhooks?" A cursor-paginated events endpoint the consumer polls. It removes ordering, delivery, backpressure and security problems entirely, at the cost of latency and some consumer effort. A meaningful fraction of integrators prefer it, and offering both is common and cheap once you have the event log that webhooks are being published from anyway.

"Isn't using five protocols incoherent?" No, it is five different consumer relationships. The public API and internal services differ in whether you know the consumer. The SPA and the mobile app differ in how fast you can ship a client. Notifications and integrations differ in whether the consumer is a browser or a server you do not control. The incoherent version is one protocol everywhere, which means either gRPC on a public API that third parties cannot use, GraphQL internally where you control both ends and get none of its benefit, or REST for mobile with eight round trips per screen.

Common misconceptions

"GraphQL replaces REST." It solves a specific problem, over-fetching and under-fetching with many client shapes, and adds several systems. For simple CRUD with one client it is a net loss.

"gRPC is faster so use it everywhere." Browsers cannot speak it without a proxy, it is not human-readable, and the connection-balancing behaviour surprises people. Internally it is straightforwardly better; publicly it is not available.

"WebSocket is the streaming protocol." For one-directional streaming SSE is simpler, works through middleboxes, and has reconnection in the protocol.

"Webhooks are just HTTP callbacks." They have no ordering, no exactly-once delivery, no backpressure and no security by default, and each is something you build.

"Pick one API style for consistency." A real product has several consumer relationships and the right answer differs per surface.

Interview delivery note

Reframe from technology to consumer immediately, because it is the axis that decides: "I'd choose by the consumer relationship rather than by performance. Who is consuming it, and can I change the client quickly? That second question is the one people skip, and it constrains everything: a mobile app in the field means the API is effectively append-only for months, which matters more than any throughput consideration."

Give the GraphQL trade honestly, in both directions: "GraphQL solves over- and under-fetching properly, and it moves complexity from the client to the server, where each piece is a real system: DataLoader batching because N+1 is structural, query cost analysis, persisted queries, cost-based rate limiting. That's the right trade when you have many client shapes and can't ship a client quickly. For a web SPA that deploys daily it's weaker than people think, because you can just add an endpoint."

Volunteer the gRPC trap, because it is the most common production surprise: "And with gRPC I'd flag the load balancing. HTTP/2 multiplexes over one long-lived connection and an L4 balancer balances connections, so every request from a client hits the same backend, and autoscaling makes it worse because new backends get no new connections. That's an HTTP/2 property rather than a gRPC one, and the fix is L7 or client-side balancing."

Close on the multi-protocol point, because it is the answer people avoid giving: "And I'd expect the answer to be several protocols. REST publicly because I don't know the consumers and I need per-endpoint rate limiting and HTTP caching. gRPC internally because I control both ends. GraphQL for the mobile client I can't ship quickly. SSE for one-directional streaming. Webhooks plus a polling endpoint for integrations. That's five consumer relationships, not incoherence."

Further reading

  • The GraphQL specification and the Apollo documentation on persisted queries and cost analysis.
  • gRPC's load balancing documentation, for client-side and look-aside balancing.
  • The HTML5 EventSource specification, for SSE reconnection and Last-Event-ID.
  • Stripe's webhook documentation, as the reference implementation of signing, idempotency, retries and replay.
  • The Protobuf language guide's rules on schema evolution, for why additive changes are safe by construction.