Which API Style Should I Use?
REST, RPC/gRPC, GraphQL, SSE, WebSockets, webhooks, async jobs — seven shapes, each answering a different question about who the consumer is and how data needs to move. The decision is made by consumer environment and operational budget, not by fashion.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Start from the question, not the acronym
Style debates go wrong when they start with the technology. "Should we use GraphQL?" has no answer; "our mobile app renders one screen from six services and each release lives for eighteen months" does. Each API style is a good answer to a specific shape of problem, and the decision tool below is just those shapes written down. It is deliberately not a flowchart with one exit — most real APIs are a REST core with an SSE endpoint for progress and webhooks for partners, because the consumer tasks differ (Consumer-First Design).
Three questions do most of the work. Who calls it and from where? Browsers, third-party developers and curl all favor plain HTTP and JSON; a fleet of internal services with a shared build pipeline can afford generated clients and binary framing. Which way does data flow, and when? Request/response, server-push, bidirectional, or "come back later" are different shapes, and forcing one into another is where the pathologies live (holding a connection open for a 15-minute report — see Long-Running Operations: 202 and the Job Resource). What does the team operate? A style is also a runtime: WebSockets make the tier stateful, GraphQL makes every query a potential cost problem, gRPC needs a proxy story for browsers.
The recommendations below always come with four attachments: what the style buys, what it costs, what it commits you to operating, and when not to use it. A recommendation without the cost column is an advertisement.
| Need | Style | Why it fits | Trade-offs | Operational implications | When not to use it | Alternatives |
|---|---|---|---|---|---|---|
| Broadly accessible web API, unknown consumers | REST over HTTP/JSON (REST as a Practical Style) | Every language, proxy, cache and curl already speaks it; docs are the contract | Fixed response shapes → over/under-fetching; no built-in type contract | Cheap to run; caching and gateways work unmodified | Hot internal paths where serialization cost dominates; chatty aggregation screens | gRPC internally, BFF for aggregation |
| Strongly typed internal service calls | gRPC / RPC (gRPC: Schema, Codegen and Streams, RPC: Operation-Oriented Contracts) | Generated clients, schema-enforced contracts, binary framing, streaming on HTTP/2 | Browsers need a proxy; payloads unreadable without tooling; proto discipline required | Build pipeline owns codegen; HTTP/2-aware load balancing | Public APIs for third parties; teams without shared build tooling | REST with OpenAPI codegen |
| Client-defined field selection across many entities | GraphQL (GraphQL: Client-Shaped Queries Over One Schema) | One schema, many client shapes; kills endpoint-per-screen sprawl | N+1 and cost control are now your problem; per-field authz; HTTP caching mostly lost | Query cost limits, persisted queries, resolver batching, schema governance | One consumer with stable needs; write-heavy command APIs; teams unable to fund the cost machinery (What GraphQL Costs) | REST plus sparse fieldsets, a BFF |
| Server → browser event stream | SSE (Server-Sent Events) | Plain HTTP, auto-reconnect with Last-Event-ID, works through most proxies | One direction only; text framing; connection-per-client | Long-lived connections on the edge; idle timeouts tuned | Client must send frequent messages back; binary streams | Polling for low-frequency; WebSockets for bidirectional |
| Bidirectional real-time conversation | WebSockets (WebSocket Message Contracts) | Full duplex, low per-message overhead, binary allowed | You now own a message protocol: types, acks, sequence, reconnect | Stateful tier; sticky sessions or a pub/sub fan-out; connection scaling | One-way notifications; anything that fits request/response | SSE + POST; long polling |
| Notify third parties asynchronously | Webhooks (Webhooks: The Inverted Contract) | Push without the consumer polling; decoupled from your request path | At-least-once delivery, retries, signing, consumer outages are now your queue | Delivery pipeline with retry schedule and dead-letter | Consumers who cannot expose an endpoint; strict ordering needs | Polling a feed endpoint; a message queue for internal consumers |
| Work that outlives a request | Async job resource (The Async Job Pattern) | 202 + job id; the client waits on a resource, not a socket | Two round trips minimum; job retention and status semantics to design | Worker fleet, job store, completion notification path | Sub-second operations; results nobody will fetch later | Synchronous with a hard timeout; streaming progress |
The comparison axes
When two styles both seem to fit, compare them on the axes that will matter in year two rather than on the demo. Public friendliness asks whether an unknown developer with curl and a browser can succeed on day one. Type safety asks whether the contract is enforced by tooling or by documentation and hope. Streaming asks whether partial results and long-lived flows are native or bolted on. Caching asks whether existing HTTP infrastructure — CDNs, gateway caches, browser caches — helps for free. Client flexibility asks who decides the response shape. Operational complexity asks what the team must run and tune. Observability asks whether standard tooling sees the operations or only sees POST /graphql (API Metrics: Rate, Errors, Duration, Sizes). Compatibility asks how a change reaches old clients.
No column wins everywhere, which is the point: the table exists to make the loss explicit. Choosing GraphQL means signing up for the cost-control column; choosing gRPC means giving up the public-friendliness column unless you also run a transcoding gateway. A team that picks a style without being able to say which column it just lost has not made a decision yet.
| Axis | REST | gRPC | GraphQL | WebSockets | SSE | Webhooks |
|---|---|---|---|---|---|---|
| Public friendliness | High — curl and docs | Low without a proxy | Medium — needs a client mindset | Medium | High | High for providers, work for consumers |
| Type safety | By docs/OpenAPI | Enforced by proto | Enforced by schema | Whatever you define | Whatever you define | By docs/schema |
| Streaming | Bolt-on | Native (4 modes) | Subscriptions (transport-dependent) | Native | Native, one-way | Event-at-a-time |
| HTTP caching | Native | None | Mostly lost | None | None | N/A |
| Client flexibility | Server decides shape | Server decides shape | Client selects fields | Message protocol decides | Server decides | Provider decides |
| Operational complexity | Low | Medium — codegen, HTTP/2 LB | High — cost control, batching | High — stateful tier | Medium — long connections | High — delivery pipeline |
| Observability | Per-endpoint out of the box | Per-method out of the box | Per-operation only with work | Per-message only with work | Per-stream with work | Per-delivery pipeline |
| Compatibility model | Additive JSON, versions | Field numbers, reserved tags | Additive schema, @deprecated | Message versioning you invent | Event type versioning | Event schema versioning |
Mixed styles are normal; unrecorded choices are not
A payment platform ends up with REST for the public contract, webhooks for settlement events, SSE for a dashboard, and gRPC between the ledger and the risk service. That is not indecision — it is four consumer environments getting four honest answers. What makes it maintainable is a written decision per surface: which style, why, which alternative was rejected, what it costs (Design Principles Without Commandments on recording reasoning). The next engineer then extends a pattern instead of relitigating the acronym war.
The failure mode to watch for is a style chosen for the wrong layer: GraphQL adopted because one mobile screen was chatty (a BFF would have solved it — Backend for Frontend); WebSockets adopted for a notification feed that changes twice an hour; gRPC exposed to partners who then spend a week finding a client library. Each is a good tool applied to a question it does not answer.
- Unknown consumers, browsers, curl → REST; pay with fixed response shapes.
- Internal, typed, high-volume → gRPC; pay with codegen discipline and a browser proxy.
- Many client shapes over one graph → GraphQL; pay with cost control and batching.
- Server push, one-way → SSE; two-way → WebSockets; pay with long-lived connections.
- Third parties must react → webhooks; pay with a delivery pipeline.
- Work outlives the request → async job resource; pay with two round trips and a job store.
Surface: Public Payments API Style: REST over HTTP/JSON, OpenAPI-described Why: third-party developers, curl-first onboarding, CDN-cacheable reads Rejected: gRPC (browser/partner friction), GraphQL (write-heavy command API, cost control burden) Costs: fixed shapes → sparse fieldsets added later if telemetry shows over-fetching Operates: gateway auth + rate limits, per-endpoint metrics, additive-only evolution Revisit when: partner SDKs need streaming, or an internal consumer dominates traffic
Key points
- The style is decided by consumer environment and data-flow shape, not by preference; each style answers a different question.
- Every recommendation carries four attachments: what it buys, what it costs, what it commits you to operating, when not to use it.
- Compare on year-two axes — public friendliness, type safety, streaming, caching, flexibility, operational load, observability, compatibility — and name the column you lose.
- Mixing styles across surfaces is normal; the same API using one style for a task it does not fit is the smell.
- Record the decision, the rejected alternative and the cost per surface so the choice can be revisited when consumers change.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → whiteboard: chooses GraphQL because a competitor did, before listing who calls the API or how often the shape changes.
- 2Mobile team → API: gets flexible queries and immediately writes one that joins users → orders → items with no cost limit.
- 3Partner developers → API: try to integrate with curl, find no per-resource endpoints, and ask for "a normal REST API" in the support channel.
- 4Operations → dashboards: every request is
POST /graphqlwith status 200; latency and error metrics are meaningless per operation. - 5Platform team → roadmap: adds cost analysis, persisted queries and a REST facade — the machinery that would have been named on day one by asking the three questions.
- Consumers in the wrong environment pay integration cost the style was supposed to save (partners fighting gRPC, browsers fighting binary framing).
- The team operates a runtime it never budgeted for: stateful WebSocket tiers, GraphQL cost control, webhook delivery pipelines.
- Observability and caching that came free with the rejected style must be rebuilt by hand.
- The style becomes the architecture: once every client has generated gRPC stubs or GraphQL fragments, changing course is a migration program (API Migration: Running the Change End to End).
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Answer the three questions in writing — who calls it and from where, which way data flows, what the team can operate — before naming a style.
- • Choose per surface, not per organization; a REST core with SSE progress and partner webhooks is a coherent design.
- • Attach the cost column to the recommendation and record the rejected alternative in the decision log.
- • Pick the boring style for unknown consumers; spend novelty budget only where a measured problem (chatty screens, typed internal traffic) demands it.
- • Name the revisit trigger — the consumer or traffic change that would make a different style right.
- • Support tickets asking for "a normal API" or a client library in language X signal a style mismatched to its consumers.
- • A single endpoint carrying all traffic (`/graphql`, `/rpc`) with uniform 200s means per-operation metrics were never built.
- • Connection counts and memory on the edge growing with users indicates a persistent-connection style chosen for a low-frequency need.
- • Internal REST calls dominated by serialization CPU and repeated round trips is the signal that a typed RPC style would pay.
- • Add a second style beside the first for the consumer it serves (SSE next to REST, gRPC internally behind a REST facade) rather than replacing the first.
- • Transcoding gateways let a gRPC core expose REST/JSON to browsers and partners without a second implementation.
- • A GraphQL layer can be introduced as a BFF over existing REST services and retired the same way if it stops earning its cost.
- • Revisit the decision when the recorded trigger fires — a new dominant consumer, a measured chattiness problem, a streaming requirement.
- • Answering the questions honestly takes a design session the "just use REST" or "just use GraphQL" shortcut skips — and is usually cheaper than the migration the shortcut causes.
- • Multiple styles across surfaces means multiple toolchains, docs formats and on-call playbooks; each surface must earn its style.
- • The boring default under-serves genuinely unusual consumers; the point is to notice them from evidence, not to forbid novelty.