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.

Who initiates the connection in each transport Webhook push flows provider to consumer, polling flows consumer to provider on a timer, and a persistent stream keeps one bidirectional socket open. Webhook push: the provider initiates Provider event store HTTPS POST Your edge public URL 202 then queue Consumer worker pool Polling: the consumer initiates on a timer Consumer scheduler GET since=cursor Rate limit ETag cache list events Provider API returns a page Most polls return 304 Not Modified and still cost a round trip Persistent stream: one long-lived connection Consumer holds socket subscribe with resume cursor Provider stream gateway Events arrive on the same socket until it drops
The transport choice is really a choice about who dials whom, and that determines what breaks when one side is unreachable.

Prerequisites

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
Transport comparison matrix Six criteria scored for webhook push, cursor polling and persistent streams, with the strongest option per row highlighted. Criterion Webhooks Polling WebSocket / SSE Latency to consumer under 1 s half the interval under 100 ms Delivery guarantee retried until acked cursor closes gaps gap on disconnect Consumer ops burden public endpoint scheduler only reconnect logic Firewall and NAT inbound required outbound only outbound only Cost at high volume 1 call per event N calls per window 1 socket per client Failure semantics provider retries next poll recovers resume from cursor
No transport wins every row; the highlighted cells show why mixed integrations are common rather than a sign of indecision.

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.

Transport decision tree Three branching questions about inbound reachability, latency budget and live UI needs route an integration to one of four transport recommendations. Consumer can expose a public HTTPS endpoint? no yes Needs updates in under one second? Live UI needs the same events? yes no yes no SSE or WebSocket resume by event id Cursor polling conditional GET Webhooks + SSE durable plus live Webhooks only queue and retry
Inbound reachability is the first cut because it eliminates a transport outright; latency and live-UI needs only refine what remains.

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

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.