Polling, SSE, WebSockets, push and webhooks: choosing a channel
When the server has something new, how should the client find out, and what does each option cost?
Every real-time mechanism trades latency against connections held, infrastructure friction and battery. Pick by direction (who talks), how fresh the data must be, whether the client is even running, and what your proxies and load balancers already handle well.
THE MENTAL MODEL
There are only three shapes. The client asks repeatedly (short and long polling), the client holds a pipe open and the server writes into it (SSE, WebSockets, WebTransport, gRPC streams), or a third party wakes the receiver up (Web Push and APNs/FCM for devices, webhooks for servers). Polling is stateless and cheap to operate but either stale or wasteful. Held connections give sub-second latency but turn every client into server state: memory per connection, sticky routing, idle timeouts, draining on deploy, and a reconnect-and-resume protocol you must design. Push through a third party is the only option when the app is closed, and it is best effort: a hint to go fetch, not a data channel. In all three shapes the stream is a notification path; the source of truth is still an API you can re-read after any gap.
HOW IT FITS TOGETHER
| Direction | Transport | Latency | Reconnect & resume | Best for | |
|---|---|---|---|---|---|
| Short polling | Client pulls | Plain HTTP requests | Up to one interval | Trivial: next request; ETag avoids resending data | Slow-changing data, dashboards, job status |
| Long polling | Client pulls, server holds | HTTP request held until data or timeout | Low, plus a re-request gap | Client sends its cursor on every request | Fallback where streaming is blocked |
| Server-Sent Events | Server to client | One long HTTP response, text/event-stream | Low (sub-second) | Built in: auto reconnect sends Last-Event-ID | Feeds, notifications, progress, LLM token streams |
| WebSockets | Bidirectional | Upgraded HTTP/1.1 (or HTTP/2 CONNECT), TCP | Lowest over TCP | None built in: your own seq numbers and replay | Chat, collaboration, trading, multiplayer lobbies |
| WebTransport | Bidirectional, many streams | HTTP/3 over QUIC (UDP) | Lowest; no cross-stream head-of-line blocking | None built in; QUIC migrates across networks | Games, media, high-rate telemetry |
| Web Push | Server to browser via push service | Push service to service worker | Seconds; best effort | Push service queues for TTL; no ordering | Re-engagement while the site is closed |
| Mobile push (APNs/FCM) | Server to device via OS vendor | Vendor's persistent device connection | Seconds; throttled | Collapse keys keep latest; delivery not guaranteed | Alerts and background sync triggers |
| Webhooks | Server to server | HTTP POST to your endpoint | Seconds; retries can take hours | Sender retries; you dedupe by event id | Integrations: payments, SaaS events |
| gRPC streaming | Server, client or bidi streams | HTTP/2 (gRPC-Web: HTTP/1.1 or 2 via proxy) | Low | None built in; resume by token in the request | Service-to-service, native mobile clients |
- Browser → Long-poll endpointGET /poll?after=41 (held open)
- Long-poll endpoint → Browser200 [event 42] after 8 sThe response ends. Event 43 published now waits for the next request.
- Browser → Long-poll endpointGET /poll?after=42The cursor makes the gap lossless, as long as the server keeps a backlog.
- Long-poll endpoint → Browser204 after 25 s with no dataTimeout below the proxy's idle limit; the empty cycle repeats.
- Browser → SSE endpointGET /events Accept: text/event-stream
- SSE endpoint → Browser200, then id: 42 data: {...}
- SSE endpoint → Browserid: 43 data: {...} (same response)
- SSE endpoint → Browser: keepalive comment every 15 sComments are ignored by EventSource but reset proxy idle timers.
- Browser → SSE endpointNetwork drop; retry after retry: msEventSource reconnects by itself unless the server answered with a non-200 or wrong content type.
- Browser → SSE endpointGET /events Last-Event-ID: 43
- SSE endpoint → BrowserReplay id: 44, 45, then liveOnly works if ids are durable positions in a log the server can read from.
KEY TERMS
- Direction and who initiates
- Browsers can only open connections, never accept them. Anything server-initiated either rides a connection the client opened (SSE, WebSockets, WebTransport) or goes through an intermediary that already holds one (the browser's push service, APNs, FCM). Servers receiving events from other servers use webhooks because both sides can accept requests.
- Connections are state
- Each held connection costs memory, a file descriptor and often a goroutine, fiber or buffer on every proxy in the path. Deploys must drain them, load balancers must route reconnects anywhere, and fan-out across nodes needs a pub/sub backbone. Polling keeps servers stateless at the cost of requests per second.
- Proxies and HTTP versions
- SSE is plain HTTP, so it passes through CDNs and proxies once response buffering is off. Over HTTP/1.1 browsers allow about six connections per origin, shared across tabs, which SSE can exhaust; HTTP/2 multiplexes streams and removes that limit. WebSockets need Upgrade forwarding; WebTransport needs HTTP/3 and UDP end to end.
- Resume, not just reconnect
- Reconnecting restores the pipe; resuming restores the data. SSE carries the last id automatically in Last-Event-ID. Everything else needs an explicit cursor or sequence number, a bounded server backlog, and a reset signal that tells the client to refetch a snapshot when its cursor is too old.
- Battery and background
- Mobile OSes suspend background apps and their sockets. A held WebSocket on a phone is a foreground-only feature; delivery while backgrounded goes through APNs or FCM, which batch and throttle (FCM downgrades high-priority messages that do not produce visible notifications).
- Web Push
- The page subscribes through PushManager with your VAPID public key and gets an endpoint on the browser vendor's push service. Your server POSTs encrypted payloads there; the browser wakes your service worker, which must show a notification. On iOS and iPadOS 16.4+ this works only for web apps added to the Home Screen, and permission must come from a user gesture.
- Webhooks
- Server-to-server callbacks with at-least-once delivery, retries and no ordering guarantee. Verify a signature over the raw body, reject stale timestamps, dedupe by event id, acknowledge fast and process asynchronously.
IN YOUR STACK
JavaScript · fetch (browser) Client
const MIN = 5_000, MAX = 60_000;
let delay = MIN;
let etag = null;
async function poll() {
try {
const res = await fetch("/api/jobs/42", { headers: etag ? { "If-None-Match": etag } : {} });
if (res.status === 304) {
delay = Math.min(delay * 1.5, MAX); // unchanged: slow down
} else if (res.ok) {
etag = res.headers.get("ETag");
render(await res.json());
delay = MIN; // changed: speed back up
} else {
const retryAfter = Number(res.headers.get("Retry-After")) * 1000 || 0;
delay = Math.max(Math.min(delay * 2, MAX), retryAfter); // 429/5xx: back off
}
} catch {
delay = Math.min(delay * 2, MAX); // offline or network error
}
const jittered = delay * (0.8 + Math.random() * 0.4); // desynchronize clients
setTimeout(poll, document.hidden ? MAX : jittered);
}
function render(job) { document.querySelector("#job-state").textContent = job.state; }
poll();- Setting If-None-Match yourself makes fetch bypass its own cache handling and hand you the 304, so an unchanged resource costs a header round trip and no body.
- Jitter and Retry-After keep thousands of tabs from polling in lockstep after an outage (a self-inflicted retry storm).
- Short polling is often the right answer: it is cacheable, stateless and debuggable. Reach for streaming when the interval you would need gets below a few seconds.
JavaScript · Node.js + Express Client + server
import express from "express";
import { EventEmitter } from "node:events";
const app = express(), bus = new EventEmitter().setMaxListeners(0);
const log = []; // { seq, data }; use a shared log (Redis stream, table) across instances
app.get("/poll", (req, res) => {
const after = Number(req.query.after ?? 0);
const pending = () => log.filter((e) => e.seq > after);
if (pending().length) return res.json(pending()); // catch up immediately
const onEvent = () => { cleanup(); res.json(pending()); };
const timer = setTimeout(() => { cleanup(); res.status(204).end(); }, 25_000); // < proxy idle timeout
const cleanup = () => { clearTimeout(timer); bus.off("event", onEvent); };
bus.on("event", onEvent);
res.on("close", cleanup); // client went away
});
export function publish(data) { log.push({ seq: (log.at(-1)?.seq ?? 0) + 1, data }); bus.emit("event"); }
app.listen(3000);
// client
let after = 0;
for (;;) {
try {
const res = await fetch(`/poll?after=${after}`);
if (res.status === 200) for (const e of await res.json()) { after = e.seq; console.log(e.data); }
} catch { await new Promise((r) => setTimeout(r, 1000 + Math.random() * 4000)); }
}- The cursor (after) is what makes long polling lossless: events published between responses are picked up by the next request.
- Every waiting client is an open request on the server and each proxy. Set the hold time below the shortest idle timeout in the path.
- Socket.IO and similar libraries fall back to long polling automatically; that path needs sticky sessions when you run several instances.
JavaScript · Node.js http + EventSource Client + server
import { createServer } from "node:http";
const history = [], clients = new Set(); // history: bounded { id, data } window; a durable log in production
const send = (res, e) => res.write(`id: ${e.id}\nevent: order\ndata: ${JSON.stringify(e.data)}\n\n`);
createServer((req, res) => {
if (req.url !== "/events") return res.writeHead(404).end();
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" });
res.write("retry: 5000\n\n"); // reconnect delay hint for EventSource
const lastId = Number(req.headers["last-event-id"] ?? 0);
if (lastId && history.length && lastId < history[0].id - 1) res.write("event: reset\ndata: {}\n\n");
for (const e of history) if (e.id > lastId) send(res, e); // replay the gap
clients.add(res);
const ping = setInterval(() => res.write(": ping\n\n"), 15_000);
res.on("close", () => { clearInterval(ping); clients.delete(res); });
}).listen(3000);
export function publish(data) {
const e = { id: (history.at(-1)?.id ?? 0) + 1, data };
history.push(e); if (history.length > 1000) history.shift();
for (const res of clients) send(res, e);
}
// browser: reconnects on its own and sends Last-Event-ID
const source = new EventSource("/events");
source.addEventListener("order", (e) => console.log(e.lastEventId, JSON.parse(e.data)));
source.addEventListener("reset", () => location.reload()); // too far behind: refetch a snapshot- Serve over HTTP/2 or HTTP/3: over HTTP/1.1 each EventSource uses one of roughly six connections per origin, shared by all tabs.
- X-Accel-Buffering: no disables nginx response buffering for this response; any buffering proxy or compression middleware that waits for a full body will hold events back.
- EventSource cannot set headers (only cookies via withCredentials) and does not reconnect after a non-200 response. Use a fetch-based reader if you need Authorization headers or POST.
JavaScript · Push API + web-push (Node) Client + server
// page: subscribe from a user gesture (required on Safari/iOS)
const VAPID_PUBLIC_KEY = "BExamplePublicKeyBase64Url"; // from webpush.generateVAPIDKeys()
document.querySelector("#enable-alerts").addEventListener("click", async () => {
const reg = await navigator.serviceWorker.register("/sw.js");
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY });
await fetch("/push/subscriptions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(sub) });
});
// sw.js: must show a notification for every push
self.addEventListener("push", (event) => {
const { title, body } = event.data.json();
event.waitUntil(self.registration.showNotification(title, { body }));
});
// server
import webpush from "web-push";
webpush.setVapidDetails("mailto:ops@example.com", process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY);
const subscriptions = new Map(); // endpoint -> subscription; persist per user
export async function notify(sub, payload) {
try {
await webpush.sendNotification(sub, JSON.stringify(payload), { TTL: 3600, urgency: "high", topic: "order-status" });
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 410) subscriptions.delete(sub.endpoint); // gone
else throw err;
}
}- TTL bounds how long the push service queues the message for an offline device; topic replaces an undelivered message with the same topic, so users see the latest status only.
- On iOS/iPadOS 16.4+ Web Push requires the site to be added to the Home Screen. iOS and iPadOS 18.4+ also support Declarative Web Push, a JSON payload format the browser can display without a service worker.
- Treat the endpoint as a secret capability URL and handle pushsubscriptionchange in the service worker to re-register.
JavaScript · Express (Standard Webhooks) Server
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = Buffer.from(process.env.WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
const seen = new Set(); // use a unique constraint on event id in production
const queue = []; // hand off to a real queue
const app = express();
app.post("/webhooks/billing", express.raw({ type: "application/json" }), (req, res) => {
const id = req.get("webhook-id"), ts = req.get("webhook-timestamp");
if (!id || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400); // replay window
const expected = createHmac("sha256", secret).update(`${id}.${ts}.${req.body}`).digest();
const valid = (req.get("webhook-signature") ?? "").split(" ").some((entry) => {
const sig = Buffer.from(entry.split(",")[1] ?? "", "base64");
return sig.length === expected.length && timingSafeEqual(sig, expected);
});
if (!valid) return res.sendStatus(401);
if (!seen.has(id)) { seen.add(id); queue.push(JSON.parse(req.body)); } // dedupe retries
res.sendStatus(200); // ack fast; slow handlers trigger sender timeouts and retries
});
app.listen(3000);- Verify the signature over the raw bytes (express.raw), never over re-serialized JSON.
- This follows the Standard Webhooks scheme (HMAC-SHA256 over id.timestamp.body, space-separated v1 signatures for key rotation). Providers such as Stripe and GitHub use their own headers; use their SDK verifiers.
- Delivery is at least once and unordered: process idempotently and compare event timestamps or versions before overwriting state.
JavaScript · WebTransport (browser) Client
// Baseline 2026 (Safari 26.4 shipped it last). Still feature-detect and keep a WebSocket fallback.
export async function connect(url, onUpdate) {
if (!("WebTransport" in globalThis)) throw new Error("no WebTransport: use WebSocket fallback");
const wt = new WebTransport(url); // https:// URL of an HTTP/3 endpoint
await wt.ready;
wt.closed.catch((err) => console.warn("transport closed", err)); // reconnect + resync here
// Datagrams: unreliable, unordered, latest-wins (cursor positions, game input)
const datagrams = typeof wt.datagrams.createWritable === "function" ? wt.datagrams.createWritable() : wt.datagrams.writable;
const dgWriter = datagrams.getWriter();
const sendPosition = (x, y) => dgWriter.write(new Float32Array([x, y]));
// Bidirectional stream: reliable and ordered, independent of other streams
const stream = await wt.createBidirectionalStream();
const writer = stream.writable.getWriter();
await writer.write(new TextEncoder().encode(JSON.stringify({ type: "subscribe", room: "lobby" })));
const reader = stream.readable.pipeThrough(new TextDecoderStream()).getReader();
(async () => {
for (let r = await reader.read(); !r.done; r = await reader.read()) onUpdate(r.value);
})();
return { sendPosition, close: () => wt.close({ closeCode: 0, reason: "bye" }) };
}- Newly available, not yet widely available: older browsers, many corporate networks that block UDP, and load balancers without HTTP/3 pass-through all need the fallback path.
- Streams carry bytes, not messages; frame your own messages (length prefix or newline-delimited) because one read can return part of a message or several.
- The server side needs an HTTP/3 stack with WebTransport support; Node.js has none built in, so this usually means a Go, Rust or C++ service or a supporting edge.
WHEN TO REACH FOR WHAT
Short polling
Use when Freshness of 5 to 60 seconds is fine, clients are few or the response is cacheable, or you want zero new infrastructure.
Avoid when You need sub-second updates for many clients; the request rate and mostly-empty responses dominate cost.
Long polling
Use when As a fallback when streaming is blocked by a proxy, or a quick upgrade of an existing polling API with a cursor.
Avoid when High event rates: every batch costs a full request and the re-request gap adds latency. Prefer SSE.
Server-Sent Events
Use when Server-to-client feeds: notifications, progress, dashboards, LLM token streams. You want HTTP auth, CDNs and Last-Event-ID resume for free.
Avoid when The client sends frequent low-latency messages, you need binary payloads, or you are stuck on HTTP/1.1 with many tabs.
WebSockets
Use when Frequent messages in both directions with low latency: chat, collaborative editing, trading UIs.
Avoid when Traffic is one-way, or your infrastructure (serverless, some proxies) handles long-lived upgrades poorly. You will build resume, heartbeats and backpressure yourself.
WebTransport
Use when Many independent streams or unreliable datagrams matter: games, live media, high-rate telemetry where stale data should be dropped.
Avoid when You cannot run HTTP/3 and UDP end to end, or need broad support on older browsers and locked-down networks today.
Web Push / APNs / FCM
Use when The user must be reached while the app or tab is closed, for user-visible alerts or a background fetch trigger.
Avoid when You need ordered, guaranteed or high-frequency delivery; treat push as a hint and fetch the real data over the API.
Webhooks
Use when Notifying another server or a customer's backend about events in your system, or receiving them from a provider.
Avoid when The receiver is a browser or mobile app, or cannot expose a public HTTPS endpoint; offer an events API with a cursor instead.
gRPC streaming / gRPC-Web
Use when Service-to-service or native mobile clients already on protobuf contracts and HTTP/2.
Avoid when Browsers need client or bidirectional streaming: gRPC-Web supports unary and server streaming only and needs a proxy such as Envoy.
WHERE IT BITES
- Reconnecting without resuming: the stream comes back but every event during the gap is lost. Give events durable ids or sequence numbers and refetch a snapshot when the backlog no longer covers the client's cursor.
- Buffering in the path: compression middleware, nginx proxy_buffering or a CDN that waits for a complete response turns SSE and long polling into one late burst.
- Idle timeouts shorter than your quiet periods (load balancers often default to around 60 seconds). Send heartbeats or comments, and set long-poll hold times below the shortest timeout.
- Reconnect storms after a deploy or outage: every client retries at once. Use exponential backoff with jitter on the client and drain connections gradually on the server.
- Using push notifications as a data channel. APNs, FCM and Web Push collapse, delay and drop messages, and FCM deprioritizes high-priority messages that do not show a notification.
- Holding one connection per tab per feature. Multiplex topics over one stream, share it across tabs (SharedWorker or BroadcastChannel), and close it when the page is hidden for long.
CLOSE THE AI. EXPLAIN THIS.
A dashboard shows order status for 50,000 users, updated a few times per minute, and must still alert users when the tab is closed. Choose the mechanism for the open tab and for the closed one, and describe what happens to the open tab's data when a deploy drops every connection.WHEN IT BREAKS IN PRODUCTION
SOURCES
- MDN: Using server-sent events (opens in new tab)Checked
- MDN: WebTransport (opens in new tab)Checked
- WebKit: Features in Safari 26.4 (opens in new tab)Checked
- WebKit: Web Push for Web Apps on iOS and iPadOS (opens in new tab)Checked
- Firebase: Android message priority (opens in new tab)Checked
- gRPC-Web (opens in new tab)Checked
Explainer reviewed