Choosing how clients and services communicate
Given a feature, how do you decide between REST, GraphQL, gRPC, tRPC, WebSockets, SSE, webhooks and queues, and what will break with each?
A handful of questions settle the choice: who initiates, how fresh the data must be, which direction it flows, who the consumer is, how tightly you can couple, whether responses can be cached, what must survive a failure, and what your team can run. Most products end up with plain HTTP for reads and writes, one push channel for freshness, and a queue behind the scenes for work that must not be lost.
THE MENTAL MODEL
Split every interaction into three shapes. Synchronous request/response is for when the caller cannot continue without an answer. Streaming is for when the caller wants a sequence of answers over one connection. Asynchronous messaging is for when the caller only needs to know the work was accepted. The shape comes first and the protocol second. REST, GraphQL, gRPC and tRPC are all request/response with different contracts. SSE, WebSockets and gRPC streams are streaming with different directions and infrastructure fit. Webhooks and queues are async, with different trust boundaries. Then remember that a connection is not a guarantee. Durability comes from storage: a log you can replay, an outbox, a queue with redelivery. A push channel only tells the client to look again soon. The most common design mistake is letting the push channel carry state that has no durable source of truth behind it.
HOW IT FITS TOGETHER
- Who is the consumer?Browsers can only make HTTP requests, open WebSockets and read SSE. Mobile apps lose sockets in the background. Partners need stable, documented HTTP. Internal services can use anything, including gRPC and queues.
- Does the caller wait?If the caller needs the result to continue, use request/response with a deadline. If it only needs acceptance, return 202 or publish an event and move the work to a queue.
- Who initiates the next message?If the client pulls, use HTTP plus caching. If the server must speak first, either the client holds a connection (SSE, WebSocket) or you call it back (webhook, push notification).
- Which direction, how often?Server to client only: SSE. Frequent messages both ways at low latency: WebSocket. Occasional client actions plus a live feed: HTTP POSTs plus SSE.
- How fresh must it be?Minutes: polling with ETag. Seconds: SSE or long polling. Sub-second interaction: WebSocket. While the app is closed: APNs, FCM or Web Push, which are best effort.
- What must survive failure?Delivery guarantees come from storage: an outbox, a queue with redelivery and a DLQ, a replayable log keyed by sequence number. Pick at-least-once and make handlers idempotent.
- Contract and couplingOpenAPI for public HTTP, SDL for GraphQL, protobuf for gRPC, inferred types for tRPC in one monorepo. The more consumers you don't control, the stricter and slower to change the contract must be.
- Can the team run it at scale?Held connections need sticky routing, pub/sub fan-out, drain-on-deploy and proxy tuning. Queues need DLQ triage. Pick the simplest option that meets the requirements above.
| Direction / initiator | Best consumer | Contract and coupling | Caching and infra fit | Delivery when things fail | |
|---|---|---|---|---|---|
| REST | Client pulls, sync | Public, partner, mobile, browser | OpenAPI; loose, versioned by URL or header | Best: GET caching, ETag, CDNs, any proxy | Client retries; idempotency keys for POST |
| GraphQL | Client pulls, sync (subscriptions aside) | Many screens and clients over one graph | SDL schema; client picks fields | Weak over POST; GET plus persisted queries helps | Partial data plus errors[]; client retries |
| gRPC | Unary or streaming, any side | Internal services, polyglot | Protobuf; strict, code generated | No HTTP caching; needs HTTP/2-capable LBs | Deadlines and status codes; retry policy per method |
| tRPC | Client pulls, sync | Your own TS frontend in a monorepo | Inferred TS types; tight, deploy together | Queries can be GET; mostly app-level caching | Client retries via TanStack Query |
| WebSocket | Bidirectional after client connects | Interactive browser and app sessions | Your own message protocol | Bypasses HTTP caches; idle timeouts, sticky routing | None built in: seq numbers, acks and replay are yours |
| SSE | Server to client after client connects | Browser feeds, dashboards, LLM tokens | Event names plus JSON payloads | Plain HTTP; disable proxy buffering | Auto reconnect with Last-Event-ID if ids are durable |
| Webhooks | Server calls the partner's server | Third-party integrations | Event catalog; signed payloads | Outbound HTTP; receiver must be public | Sender retries for hours; receiver dedupes by event id |
| Message queue | Producer to consumers, async | Internal workers and services | Message schema; loose in time and space | Broker to operate or buy; consumers scale apart | At least once, redelivery, DLQ; ordering varies |
| Start with | Why | Failure mode to watch | |
|---|---|---|---|
| Public API | REST + OpenAPI, webhooks for events | Universal tooling, HTTP caching, easy to version and document | Breaking changes to old clients; unbounded list endpoints |
| Mobile app | REST or GraphQL via a BFF, plus APNs/FCM | Few round trips on bad networks; push wakes a closed app | Old app versions live for months; sockets die in background |
| Internal microservices | gRPC for queries, queue for side effects | Strict contracts and deadlines; async decouples availability | Synchronous call chains that fail together |
| Admin dashboard | REST or tRPC, polling or SSE for live tiles | Few users and simple infra; freshness of seconds is enough | Polling storms when many tabs refresh in sync |
| Chat / collaboration | WebSocket plus HTTP history API | Low-latency both ways; history reloads from durable storage | Lost messages on reconnect without seq and replay |
| Notifications | SSE in app, APNs/FCM/Web Push when closed | One-way and server-initiated; OS push reaches closed apps | Push is best effort: keep an inbox the app can fetch |
| LLM streaming | SSE over POST via fetch streaming | One-way tokens, plain HTTP, works through CDNs | Proxy buffering; no cancel when the user leaves |
| File uploads | Presigned URL direct to object storage | Keeps large bodies off app servers; resumable multipart | Unscanned or oversized files; orphaned uploads |
| Third-party integration | Signed webhooks plus a REST read API | Partner reacts without polling; can refetch latest state | Duplicates, reordering, receiver downtime, SSRF on URLs |
| Long-running jobs | 202 + status URL; webhook or event on finish | No held connection; client can resume by job id | Job runs twice on retry without an idempotency key |
KEY TERMS
- Sync, streaming and async
- Sync request/response couples the caller's latency and availability to yours, which is why it suits reads and user-facing commands. Streaming keeps one connection and costs a held socket per client. Async messaging separates the producer from the consumer in time. The producer only learns that a message was accepted, so outcomes come back through another channel: a status resource, an event or a webhook.
- Direction and initiator
- A browser can only open connections. For the server to speak first, the client must already hold a connection (SSE, WebSocket, long poll), or a third party must deliver for you (APNs, FCM and Web Push services, or a partner's webhook endpoint). This one fact rules out half the options for any server-initiated feature.
- Freshness budget
- Put a number on it. Polling every 30 s is fine for an order that ships in days. Seconds-level freshness for a few thousand users is a single SSE endpoint. Sub-second interaction at large scale means a dedicated realtime tier with pub/sub fan-out. Each step up adds connection state you must route, drain and observe.
- Consumer type sets the floor
- Partners need stable REST and signed webhooks, because they won't regenerate your stubs or run your SDK. Browsers rule out native gRPC (use gRPC-Web or Connect) and custom headers on EventSource and WebSocket. Mobile clients rule out relying on a socket while the app is backgrounded. Internal services can afford strict schemas and generated clients.
- Contract and coupling
- Coupling is a dial. tRPC shares types and needs both sides deployed together. gRPC and GraphQL share a schema and allow additive change. REST with OpenAPI tolerates the widest range of consumers and the slowest upgrades. Queues and webhooks also decouple time, but their payload schema becomes a public contract that consumers read long after it was written.
- Delivery guarantees live in storage
- No transport gives exactly-once. Queues and webhook senders give at-least-once with redelivery. WebSocket and SSE give at-most-once unless you add sequence numbers and a replayable log. Write the event to an outbox in the same transaction as the state change, deliver at least once, and dedupe at the consumer by event id.
- Long-running jobs
- Return 202 Accepted with a Location to a status resource. RFC 9110 says the 202 body ought to describe the current status and point to a status monitor. Clients poll it with backoff or ETag. Partners can register a callback webhook, and internal consumers subscribe to a job.completed event. Require an idempotency key on the creating POST so a retried submit doesn't start a second job.
IN YOUR STACK
TypeScript · Express 5 + browser fetch Client + server
// Server: the ETag comes from the order's version, so an unchanged poll costs one indexed read
app.get("/orders/:id/status", async (req, res) => {
const order = await db.orders.findForUser(req.params.id, req.user.id); // ownership check
if (!order) return res.sendStatus(404);
const etag = `"o${order.id}-v${order.version}"`;
res.set({ ETag: etag, "Cache-Control": "private, no-cache" });
if (req.get("If-None-Match") === etag) return res.status(304).end();
res.json({ status: order.status, updatedAt: order.updatedAt });
});
// Client: conditional GET, jittered interval, paused while the tab is hidden
let etag: string | null = null, timer: ReturnType<typeof setTimeout> | undefined;
async function poll(id: string) {
try {
const res = await fetch(`/orders/${id}/status`, { headers: etag ? { "If-None-Match": etag } : {} });
if (res.status === 200) {
etag = res.headers.get("ETag");
render(await res.json());
} // 304: nothing changed, no body transferred
} catch { /* offline: try again next tick */ }
clearTimeout(timer); // keep a single loop, however often visibilitychange fires
if (document.visibilityState === "visible") timer = setTimeout(() => poll(id), 10_000 + Math.random() * 3_000);
}
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") poll(orderId); });- Setting If-None-Match yourself makes fetch skip the HTTP cache and hand you the raw 304. Leave it out and the browser revalidates for you and returns its cached 200.
- Jitter stops thousands of clients that loaded together from polling in lockstep. Back off further on 429 or 503 and honour Retry-After.
- Polling is the most robust baseline: it uses stateless servers and every proxy and CDN understands it. Upgrade only when the freshness budget requires it.
TypeScript · Express 5 + EventSource Client + server
app.get("/orders/:id/events", async (req, res) => {
if (!(await canSee(req.user, req.params.id))) return res.sendStatus(404);
res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" });
res.flushHeaders();
const send = (e: OrderEvent) => res.write(`id: ${e.seq}\nevent: status\ndata: ${JSON.stringify(e)}\n\n`);
let last = Number(req.get("Last-Event-ID") ?? 0);
const live: OrderEvent[] = [];
let replaying = true;
const off = bus.subscribe(`order:${req.params.id}`, (e) => (replaying ? live.push(e) : send(e)));
for (const e of await events.since(req.params.id, last)) { send(e); last = e.seq; } // fill the gap
live.filter((e) => e.seq > last).forEach(send); // events published during the replay
replaying = false;
const ping = setInterval(() => res.write(": ping\n\n"), 15_000); // beats proxy idle timeouts
req.on("close", () => { clearInterval(ping); off(); });
});
// Browser: reconnects by itself and sends Last-Event-ID; no custom headers, so use cookies
const es = new EventSource(`/orders/${orderId}/events`, { withCredentials: true });
es.addEventListener("status", (e) => render(JSON.parse(e.data)));- Subscribe before replaying and buffer meanwhile. Otherwise an event published between the replay query and the subscribe is lost.
- Ids must be positions in a durable log (events.since). An in-memory counter resets on deploy, and resume then silently skips or repeats events.
- Over HTTP/1.1, browsers allow about 6 connections per origin, shared across tabs. Serve SSE over HTTP/2 or HTTP/3.
TypeScript · Node.js ws Server
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
server.on("upgrade", async (req, socket, head) => {
const user = await authenticate(req).catch(() => null); // cookie + Origin allowlist, or a one-time ticket
if (!user) return socket.destroy();
wss.handleUpgrade(req, socket, head, (ws) => {
const subs = new Map<string, () => void>();
const push = (e: OrderEvent) => {
if (ws.bufferedAmount > 1 << 20) return ws.terminate(); // slow consumer: drop it; it will resync
ws.send(JSON.stringify(e));
};
ws.on("message", async (raw) => {
let msg: { type: string; orderId: string; afterSeq?: number };
try { msg = JSON.parse(raw.toString()); } catch { return ws.close(1003, "bad json"); }
if (msg.type !== "watch" || subs.has(msg.orderId) || !(await canSee(user, msg.orderId))) return;
subs.set(msg.orderId, bus.subscribe(`order:${msg.orderId}`, push)); // then replay; client dedupes by seq
for (const e of await events.since(msg.orderId, msg.afterSeq ?? 0)) push(e);
});
ws.on("close", () => subs.forEach((off) => off()));
});
});- Only worth it if the client also sends frequent messages, for example a support agent typing into the same order. For status updates alone, the SSE version is simpler and gets reconnect for free.
- Subscriptions are per connection, so a reconnect must resend watch with afterSeq. Clients ignore events with seq at or below the last one applied.
- With several instances, bus must be a shared pub/sub (Redis, NATS). Close connections gradually on deploy to avoid a reconnect stampede.
TypeScript · Node.js (Standard Webhooks) Server
import { createHmac } from "node:crypto";
// Runs inside a durable job (one job per delivery), not a request handler
export async function deliver(endpoint: { url: string; secret: string }, event: OrderEvent, attempt: number) {
const id = event.eventId; // stable across retries so the partner can dedupe
const body = JSON.stringify({ type: "order.status_changed", timestamp: event.at, data: event });
const ts = Math.floor(Date.now() / 1000).toString();
const key = Buffer.from(endpoint.secret.replace(/^whsec_/, ""), "base64");
const signature = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
const res = await fetch(endpoint.url, {
method: "POST",
body,
redirect: "manual", // never follow redirects to internal addresses
signal: AbortSignal.timeout(10_000),
headers: { "content-type": "application/json", "webhook-id": id, "webhook-timestamp": ts, "webhook-signature": `v1,${signature}` },
}).catch(() => null);
if (res?.ok) return { done: true };
if (attempt >= 12) return { done: true, deadLetter: true }; // show in the partner's dashboard, allow replay
return { done: false, retryInSeconds: Math.min(2 ** attempt * 30, 6 * 3600) }; // exponential, capped
}- Signed content is id.timestamp.body, HMAC-SHA256, sent as v1,<base64>. Receivers verify against the raw bytes, reject stale timestamps and dedupe on webhook-id.
- Validate partner URLs when they are registered and when they are resolved (https only, no private or link-local IPs). Webhook senders are a textbook SSRF vector.
- Webhooks arrive out of order. Include a sequence number or updatedAt so the receiver can ignore stale events, or send a thin event and let the partner fetch the current state.
TypeScript · AWS SDK v3 (SQS) Server
import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } from "@aws-sdk/client-sqs";
const sqs = new SQSClient({});
const QueueUrl = process.env.ORDER_EVENTS_QUEUE_URL!;
while (running) {
const { Messages = [] } = await sqs.send(new ReceiveMessageCommand({
QueueUrl, MaxNumberOfMessages: 10, WaitTimeSeconds: 20, VisibilityTimeout: 60, // long poll
}));
for (const m of Messages) {
try {
const event = JSON.parse(m.Body!) as OrderEvent;
// At-least-once and (standard queues) unordered: dedupe by id, apply only newer seq
await db.transaction(async (tx) => {
if (!(await tx.processedEvents.insertIfAbsent(event.eventId))) return;
await tx.orderViews.upsertIfNewer(event.orderId, { status: event.status, seq: event.seq });
});
await sqs.send(new DeleteMessageCommand({ QueueUrl, ReceiptHandle: m.ReceiptHandle! }));
} catch (err) {
// Not deleted: it reappears after the visibility timeout; the redrive policy moves it to a DLQ
log.error({ err, messageId: m.MessageId }, "order event failed");
}
}
}- Long polling (WaitTimeSeconds up to 20) cuts empty responses and cost compared with the default short polling.
- Delete only after the side effect commits. Set VisibilityTimeout above the worst-case processing time, or the message is redelivered while the first attempt is still running.
- Configure a redrive policy with maxReceiveCount so one malformed message cannot block or loop forever. Alert on DLQ depth.
WHEN TO REACH FOR WHAT
REST + OpenAPI
Use when The default for public, partner and mobile APIs: resource-shaped data, HTTP caching, curl-ability and consumers you don't control.
Avoid when Every screen needs a different nested slice of many resources and you would end up building dozens of bespoke endpoints. Consider a GraphQL BFF instead.
Polling with ETag / If-None-Match
Use when Freshness in tens of seconds or minutes is fine, clients are many and short-lived, or you want stateless servers and zero new infrastructure.
Avoid when Users expect sub-second updates, or most polls would hit an uncacheable, expensive query.
GraphQL
Use when Several clients compose data from many backends and a team owns the schema, persisted queries, cost limits and DataLoader discipline.
Avoid when Consumers are third parties who need simple HTTP caching, or a few fixed screens would be served more cheaply by REST.
gRPC / Connect
Use when Internal service-to-service calls across languages that need strict contracts, deadlines and streaming. Use Connect when browsers must call the same services.
Avoid when Partners or browsers are the main consumers and you can't run a gRPC-Web or transcoding proxy, or you need CDN caching.
tRPC
Use when One TypeScript monorepo where the frontend and backend deploy together and nobody else calls the API.
Avoid when Mobile apps with long-lived old versions, other languages or external consumers depend on the API.
Server-Sent Events
Use when Server-to-client feeds in the browser: order status, notifications in an open tab, dashboards, LLM token streams. It is plain HTTP with built-in reconnect.
Avoid when The client also sends frequent low-latency messages, or the consumer is a backgrounded mobile app.
WebSockets
Use when Interactive, bidirectional sessions: chat, collaborative editing, multiplayer, live trading UIs, where both sides send often.
Avoid when Traffic is one-way, or you can't afford the work of sticky routing, pub/sub fan-out, heartbeats, replay and drain-on-deploy.
Mobile / Web Push
Use when You must reach a user whose app or tab is closed, and an alert or a background refresh hint is enough.
Avoid when The payload is the source of truth. Push is best effort, throttled and can be disabled, so it should only prompt a fetch from an inbox.
Webhooks
Use when Third-party systems need to react to your events without polling, across an organisational trust boundary.
Avoid when The receiver can't expose a public HTTPS endpoint, or needs strict ordering and replay on its own schedule. Offer an events API they pull with a cursor: still at-least-once, but ordered and resumable.
Message queue / event bus
Use when Internal side effects must survive crashes and run independently of the request: emails, projections, billing, fan-out to several consumers.
Avoid when The caller needs the result to continue. Hiding a synchronous dependency behind a queue only adds latency and a polling loop.
202 Accepted + status resource
Use when A job takes longer than a reasonable request timeout (exports, video processing, bulk imports) and clients may disconnect and come back later.
Avoid when The work reliably finishes in under a second or two. Return the result directly.
WHERE IT BITES
- Choosing by fashion instead of by the questions: a WebSocket tier for updates that change once an hour, or GraphQL for one fixed screen. Each adds infrastructure you must run for years.
- Using a push channel as the source of truth. Sockets drop, push is throttled and SSE ids reset if they aren't durable. Every client needs a way to fetch the current state over HTTP after a reconnect.
- Chaining synchronous calls across services for work that could be async. Availability multiplies (99.9% to the power of 5 is about 99.5%), and one slow dependency ties up every caller's threads.
- Assuming exactly-once anywhere. Queues redeliver, webhook senders retry and clients double-submit. Use idempotency keys on commands and dedupe consumers by event id.
- Ignoring the consumer's lifecycle. Mobile apps run old versions for months and lose sockets in the background, and partners upgrade yearly. Contracts must evolve additively, and freshness must not depend on a live connection.
- Long-running work inside a request. Proxies and load balancers cut connections at 30 to 100 s, users retry, and the job runs twice. Use 202 plus a job id, with an idempotency key on the create call.
CLOSE THE AI. EXPLAIN THIS.
Order status updates must reach the web app, the iOS app (open and closed), a partner's ERP and the internal billing service. For each consumer, name the mechanism, the source of truth it recovers from after a failure, and how duplicates and reordering are handled. Then explain why none of them is a WebSocket unless the web app also needs live chat.WHEN IT BREAKS IN PRODUCTION
SOURCES
Explainer reviewed