Three axes that decide everything
“Which API should I use” is unanswerable until you place the problem on three axes.
- Who initiates? Client-pull (REST, GraphQL, gRPC) or server-push (WebSockets, SSE, webhooks).
- How coupled are the two sides? A public API with a thousand unknown consumers must be loose and versioned. Two services deployed by the same team can share a schema and a binary codec.
- What dominates the cost? Round trips (mobile on 4G), bytes (high-volume internal traffic), or developer time (an internal admin tool).
The landscape at a glance
REST
Resources identified by URLs, manipulated with HTTP methods, transferred as JSON. REST's real superpower is that it is uniform: browsers, CDNs, proxies, API gateways and every HTTP client on earth already understand it without knowing anything about your domain.
GET /v1/orders?status=shipped POST /v1/orders → 201 + Location PATCH /v1/orders/8812 → If-Match: "v3" DELETE /v1/orders/8812 → 204
- Wins: caching, tooling, discoverability, zero client codegen required.
- Loses: over-fetching, under-fetching, N+1 round trips on mobile, verbose JSON.
GraphQL
One endpoint, a typed schema, and the client declares exactly the shape it needs. It exists because product teams got tired of waiting for a backend release every time a screen needed one more field.
query Order($id: ID!) {
order(id: $id) {
id total
customer { name tier }
items { sku qty product { title thumbnail } }
}
}- Wins: no over-fetching, one round trip for a whole screen, introspection, strong typing end-to-end.
- Loses: HTTP caching is largely gone, N+1 resolvers unless you use DataLoader, query cost must be limited or you have a DoS vector.
gRPC
Contract-first RPC: define services and messages in Protobuf, generate client and server code in a dozen languages, transport over HTTP/2 with binary framing and multiplexed streams.
service Orders {
rpc Get (GetOrderRequest) returns (Order);
rpc Watch (WatchRequest) returns (stream OrderEvent);
}- Wins: smallest payloads, lowest latency, native bidirectional streaming, deadlines and cancellation built in.
- Loses: not directly callable from a browser (needs gRPC-Web or a proxy), opaque on the wire, needs a build step.
tRPC and typed RPC
When client and server live in the same TypeScript repo, you can skip the schema language entirely and infer types across the boundary. Zero codegen, instant refactors, compile-time safety on both sides.
The trade is total coupling: both halves must ship together and speak TypeScript. Perfect for a product's own web app, useless as a public contract.
SOAP and why it survives
XML envelopes, WSDL contracts, WS-Security, WS-AtomicTransaction. Verbose and unloved, but banks, insurers, telcos and government systems still run on it because those extensions specify things REST never did: message-level signing and encryption, formal contracts, and distributed transactions.
You will meet SOAP as an integration target, not a design choice. Wrap it in a REST or gRPC adapter at your boundary and never let it leak inward.
WebSockets, SSE and long polling
| Long polling | SSE | WebSockets | |
|---|---|---|---|
| Direction | Server → client (simulated) | Server → client | Bidirectional |
| Protocol | Plain HTTP | Plain HTTP | Upgrade from HTTP, then its own framing |
| Payload | Any | UTF-8 text only | Text or binary |
| Reconnect | Manual | Automatic with Last-Event-ID | Manual |
| Proxy friendliness | Perfect | Good | Sometimes blocked |
| Best for | Fallback, low-frequency | Feeds, notifications, LLM token streams | Chat, collaboration, games, trading |
Webhooks and event-driven APIs
A webhook inverts the call: instead of you polling their API, they POST to your URL when something happens. This is how Stripe, GitHub and Slack notify you.
- Verify signatures. An HMAC of the raw body with a shared secret, compared in constant time.
- Be idempotent. At-least-once delivery is the norm; store the event id and ignore duplicates.
- Return 2xx fast. Enqueue and process asynchronously — providers time out in seconds and retry.
- Expect out-of-order. Compare event timestamps or versions before applying state.
For internal systems the same idea appears as message queues and event streams (Kafka, SQS, NATS): asynchronous, decoupled, buffered against downstream outages.
Comparison table
| Style | Transport | Payload | Schema | Caching | Browser | Streaming |
|---|---|---|---|---|---|---|
| REST | HTTP/1.1 or 2 | JSON | OpenAPI (optional) | Excellent | Native | No |
| GraphQL | HTTP POST | JSON | SDL (required) | Manual / persisted queries | Native | Subscriptions |
| gRPC | HTTP/2 | Protobuf | Proto (required) | None | Via gRPC-Web | Bidirectional |
| tRPC | HTTP | JSON | Inferred TS | Limited | Native | Via subscriptions |
| SOAP | HTTP / MQ | XML | WSDL (required) | None | Awkward | No |
| WebSocket | TCP after upgrade | Any | Yours to define | None | Native | Bidirectional |
| SSE | HTTP | Text events | Yours to define | None | Native | Server → client |
| Webhooks | HTTP POST | JSON | Provider-defined | n/a | n/a | Push |
How to choose
| Situation | Pick | Because |
|---|---|---|
| Public API for unknown consumers | REST + OpenAPI | Universally consumable, cacheable, self-documenting |
| Mobile app with many screen shapes | GraphQL | One round trip per screen, no over-fetch on a slow network |
| Chatty internal microservices | gRPC | Binary payloads, HTTP/2 multiplexing, deadlines |
| Full-stack TypeScript product | tRPC | End-to-end types without codegen |
| Live collaborative editing | WebSockets | Genuinely bidirectional, low latency |
| Notifications / token streaming | SSE | One-directional over plain HTTP with auto-reconnect |
| Notifying third parties of events | Webhooks | No polling, near-real-time, standard practice |
| Decoupling internal workloads | Queue / event stream | Buffering, retries, independent scaling |
Interview framing
“We expose REST with OpenAPI at the public edge because it caches at the CDN and any consumer can use it. The mobile clients hit a GraphQL BFF so a screen is one round trip instead of six. Internally, services talk gRPC over HTTP/2 with Protobuf and per-call deadlines, since payload size and tail latency dominate at that volume. Live order status is pushed over SSE, and we emit signed, idempotent webhooks to partners for asynchronous integration.”