← Back to the library
ArchitectureHow it works · Applied · 16 min

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

The questions that decide it, in orderAnswer from the top. Earlier answers rule options out, and later ones choose among what remains.
  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
Eight styles across five dimensionsRequest/response styles on top, streaming in the middle, async at the bottom. Cells describe the default behaviour, not what you can build on top.
Direction / initiatorBest consumerContract and couplingCaching and infra fitDelivery when things fail
RESTClient pulls, syncPublic, partner, mobile, browserOpenAPI; loose, versioned by URL or headerBest: GET caching, ETag, CDNs, any proxyClient retries; idempotency keys for POST
GraphQLClient pulls, sync (subscriptions aside)Many screens and clients over one graphSDL schema; client picks fieldsWeak over POST; GET plus persisted queries helpsPartial data plus errors[]; client retries
gRPCUnary or streaming, any sideInternal services, polyglotProtobuf; strict, code generatedNo HTTP caching; needs HTTP/2-capable LBsDeadlines and status codes; retry policy per method
tRPCClient pulls, syncYour own TS frontend in a monorepoInferred TS types; tight, deploy togetherQueries can be GET; mostly app-level cachingClient retries via TanStack Query
WebSocketBidirectional after client connectsInteractive browser and app sessionsYour own message protocolBypasses HTTP caches; idle timeouts, sticky routingNone built in: seq numbers, acks and replay are yours
SSEServer to client after client connectsBrowser feeds, dashboards, LLM tokensEvent names plus JSON payloadsPlain HTTP; disable proxy bufferingAuto reconnect with Last-Event-ID if ids are durable
WebhooksServer calls the partner's serverThird-party integrationsEvent catalog; signed payloadsOutbound HTTP; receiver must be publicSender retries for hours; receiver dedupes by event id
Message queueProducer to consumers, asyncInternal workers and servicesMessage schema; loose in time and spaceBroker to operate or buy; consumers scale apartAt least once, redelivery, DLQ; ordering varies
Common scenarios and what to start withDefaults, not laws. The failure mode column is what to design for on day one.
Start withWhyFailure mode to watch
Public APIREST + OpenAPI, webhooks for eventsUniversal tooling, HTTP caching, easy to version and documentBreaking changes to old clients; unbounded list endpoints
Mobile appREST or GraphQL via a BFF, plus APNs/FCMFew round trips on bad networks; push wakes a closed appOld app versions live for months; sockets die in background
Internal microservicesgRPC for queries, queue for side effectsStrict contracts and deadlines; async decouples availabilitySynchronous call chains that fail together
Admin dashboardREST or tRPC, polling or SSE for live tilesFew users and simple infra; freshness of seconds is enoughPolling storms when many tabs refresh in sync
Chat / collaborationWebSocket plus HTTP history APILow-latency both ways; history reloads from durable storageLost messages on reconnect without seq and replay
NotificationsSSE in app, APNs/FCM/Web Push when closedOne-way and server-initiated; OS push reaches closed appsPush is best effort: keep an inbox the app can fetch
LLM streamingSSE over POST via fetch streamingOne-way tokens, plain HTTP, works through CDNsProxy buffering; no cancel when the user leaves
File uploadsPresigned URL direct to object storageKeeps large bodies off app servers; resumable multipartUnscanned or oversized files; orphaned uploads
Third-party integrationSigned webhooks plus a REST read APIPartner reacts without polling; can refetch latest stateDuplicates, reordering, receiver downtime, SSRF on URLs
Long-running jobs202 + status URL; webhook or event on finishNo held connection; client can resume by job idJob 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.
Express 5 + browser fetch documentation (opens in new tab)

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.
Express 5 + EventSource documentation (opens in new tab)

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.
Node.js ws documentation (opens in new tab)

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.
Node.js (Standard Webhooks) documentation (opens in new tab)

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.
AWS SDK v3 (SQS) documentation (opens in new tab)

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.

SOURCES

Explainer reviewed