Server-Sent Events, WebSockets and Webhooks: Picking the Right Channel
Server sent events, WebSockets or webhooks? A practitioner’s guide to picking the right realtime channel, with SSE code, proxy gotchas and webhook signature
A client once asked us for “realtime” on their dashboard. What they actually wanted was for an export button to stop lying about its progress. One server-sent events endpoint and about forty lines of code later, done. The other quote they’d received proposed a WebSocket gateway, Redis pub/sub and sticky sessions on the load balancer, for a feature that pushes four messages over ninety seconds.
That gap is the whole problem. Three technologies get lumped together under realtime web work, and they don’t do the same job. Two of them talk to browsers. One of them doesn’t talk to browsers at all. Picking wrong costs you either months of infrastructure you didn’t need or a rewrite six weeks after launch.
Here’s how we decide, with the failure modes we’ve actually hit in production.
- Server-sent events are one-way server to browser over plain HTTP, with automatic reconnection and event IDs built into the browser. For dashboards, notifications, progress bars and LLM token streaming, they’re almost always the right answer.
- WebSockets earn their complexity when the client sends frequently too: collaborative editing, multiplayer cursors, trading, live chat with typing indicators. Below roughly one client message per second, the bidirectional channel is overhead you pay for and don’t use.
- Webhooks are not a browser technology. They are server to server HTTP callbacks. Comparing websockets vs webhooks is a category error you’ll see in a lot of listicles. You will often need a webhook and a browser channel in the same feature.
- The thing that breaks SSE in production is never the code. It’s the infrastructure: proxy buffering, 60 second idle timeouts on load balancers, and the six connection per origin limit on HTTP/1.1.
- Polling every 5 to 10 seconds is a legitimate production choice, not a failure. At low concurrency it costs less than you think and it survives every proxy, CDN and corporate firewall on earth.
Three channels, three different jobs
Strip away the noise and the distinction is about direction and endpoint.
- SSE: server pushes to a browser. One direction. Plain HTTP response that never closes,
Content-Type: text/event-stream. The client talks back with normal fetch requests. - WebSockets: full duplex between a browser and a server over a connection upgraded from HTTP. Both ends push whenever they like.
- Webhooks: an third-party server makes an ordinary POST to a URL you own, when something happens. No persistent connection. No browser involved.
Stripe cannot send a WebSocket frame to a user’s laptop. It POSTs to your server. If the user’s screen needs to update when that POST lands, you need a second channel from your server to their browser. That architecture, webhook inbound and SSE outbound, covers a surprising share of what people mean when they say realtime.

When server sent events are the right call
Default to SSE for anything that is mostly server talking. It’s HTTP, so your existing auth cookies, middleware, logging and rate limiting all work unchanged. No separate server process, no separate port, no new protocol in your threat model.
A minimal Node endpoint:
app.get('/api/stream/job/:id', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
// Tells nginx not to buffer this response, even if proxy_buffering is on
'X-Accel-Buffering': 'no'
});
res.flushHeaders();
// Tell the browser how long to wait before reconnecting after a drop
res.write('retry: 3000\n\n');
const heartbeat = setInterval(() => res.write(': ping\n\n'), 20000);
const unsubscribe = jobs.subscribe(req.params.id, (event) => {
// id: lets the browser resume via Last-Event-ID after a reconnect
res.write(id: ${event.seq}\n);
res.write(event: ${event.type}\n);
res.write(data: ${JSON.stringify(event.payload)}\n\n);
});
req.on('close', () => { clearInterval(heartbeat); unsubscribe(); });
});
Client side, the browser handles reconnection for you:
const es = new EventSource('/api/stream/job/42');
es.addEventListener('progress', (e) => {
const { percent } = JSON.parse(e.data);
bar.style.width = percent + '%';
});
es.addEventListener('done', () => es.close()); // nothing closes it for you
es.onerror = () => {
// Fires on every drop. EventSource retries automatically; only surface
// a UI state if readyState is CLOSED (a 4xx, not a network blip).
if (es.readyState === EventSource.CLOSED) showOfflineBanner();
};
Two details that bite people. First, EventSource can’t set request headers, so no Authorization: Bearer. Cookies work. Query string tokens work but end up in access logs. If you need headers, drop EventSource and read the stream yourself with fetch plus a ReadableStream reader, or use Microsoft’s fetch-event-source library. Second, on reconnect the browser sends back Last-Event-ID with the last id: it saw. Ignore that header and you will silently lose every event that happened during the gap. You won’t notice in dev because your laptop never drops the connection.
The infrastructure problems nobody warns you about
SSE code is easy. Keeping a long-lived HTTP response alive through everything between your app and the user is where the time goes.
Buffering
nginx with proxy_buffering on (the default) will happily hold your events until the buffer fills. For a progress stream that means nothing arrives for thirty seconds and then everything arrives at once. The X-Accel-Buffering: no header above fixes it for nginx specifically. Add Cache-Control: no-transform so intermediaries don’t try to gzip-and-chunk your stream into uselessness.
Idle timeouts
AWS Application Load Balancer defaults to a 60 second idle timeout. Various PaaS proxies sit around 55 to 100 seconds. If your stream is quiet for longer than that, the connection dies. A comment-line heartbeat (: ping\n\n) every 15 to 25 seconds solves it and costs 8 bytes. Send it even when you think the stream is busy.
Connection limits
Over HTTP/1.1 browsers allow six concurrent connections per origin. One SSE stream per open tab means a user with six dashboard tabs has locked the origin and page loads hang. This is the single most common SSE bug report we’ve seen, and it’s nearly invisible in testing. HTTP/2 fixes it through multiplexing (nginx allows 128 concurrent streams by default), so serve SSE over HTTP/2 or HTTP/3, always. If you can’t, use a BroadcastChannel or a shared worker so all tabs share one stream.
Serverless
Function platforms cap execution duration, and a connection that must live for ten minutes is a poor fit for a billing model priced per invocation second. Streaming an LLM response for 30 seconds is fine. Holding a notification channel open all afternoon is not. That’s a case where you either move to a long-running process or fall back to polling.
WebSockets vs webhooks is the wrong comparison
These two get paired constantly in search results and they solve unrelated problems. The real decision is SSE versus WebSockets for the browser, and webhooks versus polling an API for server-to-server integration.
Reach for WebSockets when the client genuinely sends high-frequency messages: cursor positions, CRDT operations, keystroke-level presence, a game loop. At that point the request overhead of individual fetch calls becomes real and the duplex channel pays for itself. Below about one client message per second, SSE plus ordinary POSTs performs the same and costs far less to operate.
What you take on with WebSockets, honestly:
- No automatic reconnection. You write the exponential backoff, the jitter, the resume logic. Expect 150 lines before it’s correct, or adopt a library.
- Browsers don’t expose ping/pong frames to JavaScript, so you build an application-level heartbeat anyway to detect half-open connections.
- Horizontal scaling needs a fanout layer. Two app servers, user A connected to one and user B to the other, means Redis pub/sub or similar. SSE needs this too, but SSE at least lets you cheat with a shorter-lived connection.
- Your HTTP middleware doesn’t apply. Auth happens once at upgrade time, so a token that expires mid-session needs explicit handling.
Some teams run a hosted gateway (Pusher, Ably, Supabase Realtime) to skip all that. Reasonable call. Price it at your real concurrency before committing, though, because per-connection billing climbs fast once a marketing page starts opening sockets it doesn’t need.
Webhooks: the delivery guarantees you have to build yourself
A webhook endpoint is a public POST handler that strangers can call, so treat it like one. Three non-negotiables: verify the signature, respond fast, and be idempotent.
<?php
$payload = filegetcontents('php://input'); // raw body, before any parsing
$sig = $SERVER['HTTPX_SIGNATURE'] ?? '';
$expected = hashhmac('sha256', $payload, WEBHOOKSECRET);
// Constant time compare: prevents timing attacks on the signature
if (!hash_equals($expected, $sig)) {
httpresponsecode(401);
exit;
}
$event = json_decode($payload, true);
// Idempotency: unique index on event_id makes replays a no-op
$stmt = $pdo->prepare(
'INSERT IGNORE INTO webhookevents (eventid, type, payload, received_at)
VALUES (?, ?, ?, NOW())'
);
$stmt->execute([$event['id'], $event['type'], $payload]);
httpresponsecode(202); // acknowledge now, process on a queue
Note the 202 before any business logic runs. Most providers time out somewhere between 5 and 30 seconds and treat a slow response as a failure, which triggers a retry, which means your “send welcome email” job runs twice. Stripe retries failed deliveries with exponential backoff for up to around three days. GitHub doesn’t automatically retry at all. Read your provider’s actual policy, because assuming either direction will burn you.
Also: parse the raw body for signature verification before your framework touches it. Laravel, Express with express.json() and WordPress REST all re-serialise JSON in ways that change whitespace and break HMAC. We’ve debugged that specific failure on three separate client projects. The same discipline applies on the outbound side when you’re handling payments from a static site, where the webhook is the only trustworthy confirmation that money moved.
If you’re on a static build with no backend at all and just need form submissions routed somewhere, WebForms does the webhook fanout for you to Slack, Telegram or your own endpoint, which saves standing up a server purely to receive a POST.
Bridging a webhook to a live browser
The pattern comes up constantly: a payment confirms, a background render finishes, an AI job completes, and a user is sitting on a page waiting. The provider webhooks your server. Your server now has to reach that specific browser.
The shape we ship most often:
- Webhook lands, signature verified, row written, 202 returned.
- A queue worker processes the event and updates canonical state.
- The worker publishes to Redis on a channel keyed by user or job ID.
- The SSE endpoint, already subscribed to that channel, writes the event to the open stream.
- The browser updates. Total latency from webhook to pixel: typically 50 to 200ms.
Keep the browser channel dumb. It carries “job 42 changed, state is ready”, not the full payload. The client then fetches canonical data over normal HTTP. That way a missed event is recoverable with a refresh instead of leaving the UI permanently wrong, and you don’t have two sources of truth drifting apart. While you’re there, design the disconnected state properly rather than leaving a spinner forever, which is the same discipline as designing for 404s, 500s and empty states.
Polling is underrated and you should price it first
Before any of this, do the arithmetic. 500 concurrent users polling a cached endpoint every 10 seconds is 50 requests per second. A single modest server handles that without noticing, especially if the handler is a conditional GET returning 304 Not Modified most of the time. Zero persistent connections, zero proxy configuration, works through every corporate firewall, and it keeps working when your Redis falls over.
Polling stops making sense when latency below a second actually matters to the user, when concurrency reaches a few thousand, or when the payload is expensive to compute and you can’t cache it. Those are real thresholds, not excuses. We have replaced two WebSocket implementations with 8 second polling on client sites and nobody, including the client, could tell the difference in the UI.
The honest heuristic: use ETag plus conditional polling until something measurably hurts, then move the hot path to SSE, and only reach for WebSockets when the browser genuinely needs to shout back.
Frequently Asked Questions
Do server-sent events work in all browsers in 2026?
Yes. EventSource is supported in every current browser including Safari and mobile Safari, and has been for years. The only notable gap is Internet Explorer, which is irrelevant now, and the fact that EventSource cannot send custom request headers, which is a design limitation rather than a support gap.
How many SSE connections can one server hold?
With an event-driven runtime like Node or Go, tens of thousands per instance, limited mainly by file descriptors and memory per connection (plan for a few KB each plus whatever state you keep). With a process-per-request model like PHP-FPM or classic Apache, you will exhaust your worker pool at a few hundred, which is why SSE on traditional PHP hosting is usually a bad idea.
Can I use SSE to stream LLM responses?
Yes, and it’s the standard approach. OpenAI, Anthropic and most inference APIs stream tokens over SSE themselves, so you’re usually proxying one event stream into another. Keep your heartbeat running during long thinking pauses, and send a terminal event so the client knows the difference between finished and dropped.
What happens to webhooks if my server is down for an hour?
It depends entirely on the sender. Providers with retry policies, like Stripe, will back off and keep trying for hours or days, so you recover automatically. Providers without retries drop the event permanently, which is why any webhook integration carrying financial or state-critical data needs a reconciliation job that periodically pulls the provider’s API and fills gaps.
Should I use WebSockets just to future-proof the architecture?
No. You’ll pay for reconnection logic, a fanout layer and sticky session configuration immediately, in exchange for capability you might use later. Migrating from SSE to WebSockets later is a contained piece of work because the event payloads and the server-side pub/sub stay the same, so build the simple thing and keep the message format transport-agnostic.
Making the call
Write down the direction of data and the message rate. If the browser receives and rarely sends, use SSE over HTTP/2 with heartbeats and Last-Event-ID resume. If the browser sends more than about once a second, use WebSockets and budget a week for reconnection, auth refresh and horizontal scaling. If the sender is another company’s server, it’s a webhook, and the work is signature verification, a 202 within a second, and idempotency keyed on the provider’s event ID.
If the answer is ambiguous, ship conditional polling this week. It’s cheap to replace, it never goes down for reasons you can’t see, and you’ll learn your real concurrency numbers before you commit to infrastructure that assumes them.


