Webhooks vs Polling vs WebSockets: Choosing an Event Transport
Every integration eventually asks the same question — should the provider push events to me, should I ask for them on a timer, or should I hold a socket open and take them as they happen — and the answer changes the failure model far more than it changes the code. This guide is the transport-selection companion to Sync vs Async Webhooks: where when to use synchronous callbacks vs async webhooks decides whether the producer blocks, this page decides who initiates the connection in the first place. The three answers — webhook push, cursor polling, and a persistent WebSocket or Server-Sent Events stream — differ on latency, on what happens when the consumer is offline, on whether the consumer needs an inbound-reachable address, and on how the bill scales with subscriber count.
Prerequisites
- Node.js 20 or later and TypeScript 5.4, with
tsxfor running the samples directly (npm i -D typescript tsx vitest). - A provider API that offers at least two of the three transports, or a service you are designing where you control which to expose.
- A measured event rate and consumer count. Guesses here invert the cost conclusion, so pull real numbers from your event store.
- A signing scheme already chosen for the push path, such as HMAC signature verification, and a retry policy such as exponential backoff.
Step 1: Score the three transports against your workload
Start with arithmetic rather than preference. Polling cost is driven by consumer count and interval, not by event volume: 800 consumers polling every 30 seconds generate 2.3 million requests a day whether or not anything happened. Webhook cost is driven by event volume and fan-out. Stream cost is driven by concurrent connections, which are cheap in bytes and expensive in file descriptors and load-balancer slots.
// transport-cost.ts — run: npx tsx transport-cost.ts
export type Transport = "webhook" | "polling" | "stream";
export interface Workload {
consumers: number;
eventsPerConsumerPerDay: number;
pollIntervalSeconds: number;
/** Mean provider-side dispatch delay for pushed events, in seconds. */
pushDispatchSeconds: number;
}
export interface Estimate {
transport: Transport;
requestsPerDay: number;
concurrentConnections: number;
medianLatencySeconds: number;
}
export function estimate(w: Workload): Estimate[] {
const pollsPerConsumer = 86_400 / w.pollIntervalSeconds;
const totalEvents = w.consumers * w.eventsPerConsumerPerDay;
return [
{
transport: "webhook",
requestsPerDay: totalEvents,
concurrentConnections: 0,
medianLatencySeconds: w.pushDispatchSeconds,
},
{
transport: "polling",
requestsPerDay: Math.round(w.consumers * pollsPerConsumer),
concurrentConnections: 0,
// On average an event waits half a poll interval before it is seen.
medianLatencySeconds: w.pollIntervalSeconds / 2,
},
{
transport: "stream",
requestsPerDay: totalEvents,
concurrentConnections: w.consumers,
medianLatencySeconds: 0.05,
},
];
}
if (require.main === module) {
const rows = estimate({
consumers: 800,
eventsPerConsumerPerDay: 40,
pollIntervalSeconds: 30,
pushDispatchSeconds: 0.4,
});
for (const r of rows) {
console.log(
`${r.transport.padEnd(8)} req/day=${r.requestsPerDay.toLocaleString()}` +
` conns=${r.concurrentConnections} p50=${r.medianLatencySeconds}s`,
);
}
}
For that workload the numbers are stark: 32,000 pushed requests a day versus 2,304,000 polls for the same 32,000 events — a 72:1 waste ratio, every unit of which still costs TLS handshakes, rate-limit accounting, and log lines on both sides. Now score the qualitative axes, because volume is only one column.
| Criterion | Webhook push | Cursor polling | WebSocket / SSE |
|---|---|---|---|
| Median latency | Sub-second, bounded by dispatch queue depth | Half the poll interval, so 15 s at a 30 s interval | Tens of milliseconds while connected |
| Delivery guarantee | At-least-once with provider-side retries and a dead-letter path | Exactly what the cursor returns; gaps are impossible if the cursor is durable | None on its own; anything sent while disconnected is lost unless replayed |
| Consumer operational burden | Public HTTPS endpoint, signature verification, replay defence, idempotency | A scheduler and a stored cursor; nothing inbound to secure | Reconnect loop, heartbeat handling, backpressure, resume bookkeeping |
| Firewall and NAT posture | Requires inbound reachability; blocks laptops, on-prem, and CI runners | Outbound only; works from anywhere | Outbound only, but needs proxies and load balancers that tolerate long-lived connections |
| Cost at high volume | One request per event; scales with event rate | Requests scale with consumers times poll frequency, independent of events | One connection per consumer held open continuously |
| Failure semantics when the consumer is down | Provider retries, then dead-letters; nothing is lost | Nothing to lose; the next poll resumes from the cursor | Silent gap between disconnect and resume unless the server replays |
Step 2: Expose a webhook endpoint that acknowledges fast
If you are the provider, webhooks are the default because they push cost onto the event rate rather than the subscriber count, and because retries give you a real delivery guarantee. If you are the consumer, the entire contract is: verify, persist, return 202, and process later. Doing work inline turns your business logic latency into the provider’s timeout budget, and a slow handler will get you rate-limited or disabled by subscription management automation.
// receiver.ts — npm i express; run: npx tsx receiver.ts
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.WEBHOOK_SECRET ?? "dev-secret";
const TOLERANCE_SECONDS = 300;
const app = express();
// Keep the exact bytes: re-serialised JSON will not match the signature.
app.use(express.raw({ type: "application/json", limit: "1mb" }));
function verify(rawBody: Buffer, timestamp: string, signature: string): boolean {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const provided = Buffer.from(signature, "hex");
return (
provided.length === expected.length && timingSafeEqual(provided, expected)
);
}
app.post("/webhooks/events", async (req, res) => {
const timestamp = String(req.header("x-timestamp") ?? "");
const signature = String(req.header("x-signature") ?? "");
if (!verify(req.body as Buffer, timestamp, signature)) {
return res.status(401).json({ error: "invalid signature" });
}
const event = JSON.parse((req.body as Buffer).toString("utf8")) as {
id: string;
type: string;
};
// Durable write first, then acknowledge. Unique index on event.id makes
// a provider retry a no-op instead of a duplicate.
const inserted = await enqueue(event.id, event.type, req.body as Buffer);
res.status(202).json({ received: event.id, duplicate: !inserted });
});
async function enqueue(
id: string,
type: string,
payload: Buffer,
): Promise<boolean> {
// Replace with your queue or outbox table insert.
console.log(`queued id=${id} type=${type} bytes=${payload.length}`);
return true;
}
app.listen(8080, () => console.log("listening on :8080"));
That 202 is the whole point of the push transport: the provider’s obligation ends when the byte is durable on your side, and everything after it is your own concurrency problem — which is why one event reaching many internal consumers belongs in designing webhook fan-out architectures rather than inside the request handler.
Step 3: Build a cursor-based polling client
Polling deserves more respect than it usually gets. It is the only transport where the consumer needs no inbound reachability, no signature verification, and no replay defence, and where a consumer that was down for six hours recovers with zero provider cooperation. The failure mode people complain about — missed events — comes from polling by timestamp instead of by an opaque, monotonic cursor.
// poll.ts — run: npx tsx poll.ts
interface PollState {
cursor: string | null;
etag: string | null;
}
interface EventPage {
events: Array<{ id: string; type: string }>;
nextCursor: string | null;
}
const BASE = process.env.API_BASE ?? "https://api.example.com";
async function pollOnce(state: PollState, token: string): Promise<PollState> {
const url = new URL("/v1/events", BASE);
url.searchParams.set("limit", "200");
if (state.cursor) url.searchParams.set("after", state.cursor);
const headers: Record<string, string> = {
authorization: `Bearer ${token}`,
accept: "application/json",
};
if (state.etag) headers["if-none-match"] = state.etag;
const res = await fetch(url, {
headers,
signal: AbortSignal.timeout(10_000),
});
if (res.status === 304) return state; // nothing new, no body transferred
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after") ?? "5");
await sleep(retryAfter * 1000);
return state;
}
if (!res.ok) throw new Error(`poll failed: ${res.status}`);
const page = (await res.json()) as EventPage;
for (const event of page.events) {
await handle(event);
}
// Advance the cursor only after every event on the page is durable.
return {
cursor: page.nextCursor ?? state.cursor,
etag: res.headers.get("etag"),
};
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function handle(event: { id: string; type: string }): Promise<void> {
console.log(`handling ${event.type} ${event.id}`);
}
export async function pollForever(token: string, intervalMs = 30_000) {
let state: PollState = { cursor: await loadCursor(), etag: null };
for (;;) {
try {
state = await pollOnce(state, token);
await saveCursor(state.cursor);
} catch (err) {
console.error("poll error", err);
}
await sleep(intervalMs);
}
}
async function loadCursor(): Promise<string | null> {
return process.env.START_CURSOR ?? null;
}
async function saveCursor(cursor: string | null): Promise<void> {
if (cursor) console.log(`cursor=${cursor}`);
}
The cursor must be persisted in the same store as the processed events, and it must advance only after the page is durable. Get that right and polling is the most forgiving transport in existence; get it wrong and every restart either re-processes a page — survivable if you have idempotency in webhooks — or skips one, which is not.
Step 4: Stream with SSE and resume from the last event id
A persistent stream wins exactly when a human is watching a screen. Sub-100 ms delivery matters for live dashboards, collaborative editing, agent consoles, and trading views; it is irrelevant for a nightly reconciliation job. Prefer Server-Sent Events over raw WebSockets when traffic is one-directional: SSE is plain HTTP, it survives most proxies, and the Last-Event-ID header gives you resume semantics for free.
// stream.ts — Node 20+, run: npx tsx stream.ts
const BASE = process.env.API_BASE ?? "https://api.example.com";
export async function streamEvents(token: string, startId?: string) {
let lastEventId = startId;
let attempt = 0;
for (;;) {
try {
const headers: Record<string, string> = {
authorization: `Bearer ${token}`,
accept: "text/event-stream",
};
if (lastEventId) headers["last-event-id"] = lastEventId;
const res = await fetch(`${BASE}/v1/events/stream`, { headers });
if (!res.ok || !res.body) throw new Error(`stream failed: ${res.status}`);
attempt = 0;
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let split: number;
while ((split = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
const id = frame.match(/^id:\s*(.+)$/m)?.[1];
const data = frame.match(/^data:\s*(.+)$/m)?.[1];
if (!data) continue; // heartbeat or comment frame
await handleStreamed(JSON.parse(data));
if (id) lastEventId = id; // advance only after handling
}
}
throw new Error("stream closed by server");
} catch (err) {
attempt += 1;
const backoff = Math.min(30_000, 2 ** attempt * 250);
const jitter = Math.random() * backoff * 0.3;
console.error(`reconnecting in ${Math.round(backoff + jitter)}ms`, err);
await new Promise((r) => setTimeout(r, backoff + jitter));
}
}
}
async function handleStreamed(event: { id: string; type: string }) {
console.log(`live ${event.type} ${event.id}`);
}
Note what the reconnect loop cannot do: if the server keeps no replay buffer, everything emitted during the disconnect is gone. That is the defining weakness of the stream transport and the reason it is almost never the system of record. Treat the socket as a low-latency accelerator over a durable transport, and apply the same backpressure discipline you would use on a queue when the consumer falls behind.
Step 5: Apply the decision tree per scenario
Three questions resolve almost every real integration: can the consumer accept inbound HTTPS, does anything need sub-second delivery, and does a live UI need the same events a backend job needs.
Encode the tree so the choice is reviewable in code review rather than argued in a design doc:
// choose-transport.ts
export interface ConsumerProfile {
canExposeInboundHttps: boolean;
latencyBudgetSeconds: number;
hasLiveUi: boolean;
eventsPerDay: number;
}
export type Recommendation =
| "webhooks-only"
| "webhooks-plus-stream"
| "cursor-polling"
| "stream-only";
export function chooseTransport(p: ConsumerProfile): Recommendation {
if (!p.canExposeInboundHttps) {
return p.latencyBudgetSeconds < 1 ? "stream-only" : "cursor-polling";
}
return p.hasLiveUi ? "webhooks-plus-stream" : "webhooks-only";
}
export function rationale(p: ConsumerProfile): string {
const choice = chooseTransport(p);
const pollsPerDay = Math.round(86_400 / Math.max(p.latencyBudgetSeconds, 1));
switch (choice) {
case "stream-only":
return "No inbound path and a sub-second budget: hold a socket and replay on resume.";
case "cursor-polling":
return `No inbound path, ${p.latencyBudgetSeconds}s budget: ~${pollsPerDay} polls/day is cheaper than an SSE fleet.`;
case "webhooks-plus-stream":
return "Webhooks are the system of record; the socket only accelerates the UI.";
default:
return `Push ${p.eventsPerDay} events/day with retries; nothing needs a socket.`;
}
}
| Scenario | Recommendation | Why |
|---|---|---|
| Payment provider notifying a SaaS backend | Webhooks with retries and a dead-letter path | Inbound HTTPS exists, volume is event-driven, and durability outranks latency |
| A CLI or desktop agent behind corporate NAT | Cursor polling every 30–60 s | No inbound reachability; nothing is lost across restarts or offline periods |
| A live trading or ops dashboard | SSE stream with a webhook-fed backend | Humans notice 15 s of lag; the durable copy still arrives via the push path |
| CI runner reacting to build events | Cursor polling with a short interval | Runners are ephemeral and unaddressable, and duplicate polls are harmless |
| Multi-tenant platform with 5,000 integrators | Webhooks, with polling as the documented fallback | Polling at that subscriber count is millions of wasted calls per day |
| Internal service inside the same VPC | Webhooks over private DNS, or a broker | Inbound is trivial internally, so the extra socket machinery earns nothing |
Verification and testing
Assert the decision logic in CI so the transport choice cannot drift as profiles change, and probe each transport once against the real provider before you commit.
// choose-transport.test.ts — npx vitest run
import { describe, expect, it } from "vitest";
import { chooseTransport } from "./choose-transport";
const base = {
canExposeInboundHttps: true,
latencyBudgetSeconds: 60,
hasLiveUi: false,
eventsPerDay: 10_000,
};
describe("chooseTransport", () => {
it("prefers webhooks when inbound HTTPS is available", () => {
expect(chooseTransport(base)).toBe("webhooks-only");
});
it("adds a stream when a live UI shares the events", () => {
expect(chooseTransport({ ...base, hasLiveUi: true })).toBe(
"webhooks-plus-stream",
);
});
it("falls back to polling behind NAT", () => {
expect(
chooseTransport({ ...base, canExposeInboundHttps: false }),
).toBe("cursor-polling");
});
it("streams when NAT-bound but sub-second", () => {
expect(
chooseTransport({
...base,
canExposeInboundHttps: false,
latencyBudgetSeconds: 0.5,
}),
).toBe("stream-only");
});
});
Then verify the wire behaviour by hand. Confirm the receiver rejects a tampered body and acknowledges quickly:
curl -i -X POST localhost:8080/webhooks/events \
-H 'content-type: application/json' \
-H 'x-timestamp: 1750000000' -H 'x-signature: deadbeef' \
-d '{"id":"evt_1","type":"payment.succeeded"}'
# expect: HTTP/1.1 401 Unauthorized
Confirm the polling path is actually cheap by checking that a repeat poll with the stored ETag returns 304 with no body, and confirm stream resume by killing the client, publishing two events, and asserting both arrive after reconnect. Whichever transports you keep, wire their latency into the same dashboards described in Webhook Observability & Monitoring so you can compare them on identical percentiles.
Failure modes and gotchas
- Polling by wall-clock timestamp instead of an opaque cursor. Two events written in the same millisecond, or a replica whose clock trails the primary by 200 ms, silently drop one event per boundary. Always page by a monotonic, provider-issued cursor and store it transactionally with the processed rows.
- Treating a WebSocket as durable. Anything emitted while the socket is down is gone unless the server maintains a replay buffer keyed by event id. Ask the provider how far back
Last-Event-IDcan resume; if the answer is “we don’t”, the stream is a cache, not a source of truth. - Shrinking the poll interval to fix latency. Halving the interval doubles the request bill and usually trips the provider’s rate limiter, which then adds far more latency than it removed. Below roughly 10 seconds of budget, switch transports rather than tuning the timer.
- Offering webhooks without a documented catch-up path. Consumers that were down for a maintenance window need a way to fetch what they missed once retries are exhausted. Ship a cursor-based list endpoint alongside your push path so recovery does not require a support ticket.
Frequently Asked Questions
If I run two transports at once, do I process every event twice?
Only if you give them separate handlers. Nominate one transport as the system of record and treat the other as an accelerator, then deduplicate on the provider's event id at the single point where events become durable. Either path may win the race for a given event; whichever arrives second collapses into a no-op against the unique index, which is exactly the behaviour you already need for provider retries.
Does HTTP/2 or long polling change the polling arithmetic?
Multiplexing removes the per-poll handshake but not the rate-limit accounting or the server-side query, and those are where most of the cost actually sits. Long polling is a real change in kind: the request stays open until an event or a timeout, so latency approaches the streaming case while keeping the outbound-only posture. You pay for it with one held connection per consumer, which means you have adopted the stream cost model plus reconnect churn.
How many concurrent SSE connections can one node realistically hold?
File descriptors are rarely the binding constraint; per-connection memory and the load balancer's connection table usually are. Tens of thousands per node is achievable, but every proxy in the path enforces an idle timeout — 60 seconds is a common default — so send a comment heartbeat every 15 to 30 seconds or your carefully tuned socket count becomes irrelevant. Measure with the real proxy chain in place, because a local test against the app server proves nothing about the edge.
Should the poll interval be fixed or adaptive?
Make it adaptive when the event rate is bursty: widen the interval after consecutive empty responses and tighten it when a full page comes back, which trims the wasted request bill without hurting latency during real activity. Whatever the interval, add jitter to it — a fleet of consumers restarting after a deploy will otherwise synchronise into a thundering herd against the list endpoint. Cap the widening at a value your recovery SLA can tolerate, since a quiet integration should still notice an event within a defined window.
My handler finishes in 50 ms — do I still need the queue-then-acknowledge split?
Almost always yes, because the decision is governed by the tail rather than the median. A dependency that occasionally takes five seconds turns into provider timeouts, then retries, then duplicates you have to suppress anyway, and inline work makes the provider's timeout budget your effective capacity ceiling during a spike. Process inline only when the handler touches nothing but local state and you have a measured p99 you are willing to defend.
How do I migrate an existing polling integration to webhooks without a gap?
Run both for at least one full retry window: register the endpoint, keep the poller and its cursor alive, and let id-based deduplication absorb the overlap. Once pushed deliveries account for essentially every first arrival across several days, widen the poll interval rather than deleting the poller. A slow reconciliation poll costs a handful of requests an hour and is the only thing that will notice if the provider quietly disables your subscription.