Plain HyperText Transfer Protocol (HTTP) has one stubborn rule: the browser asks, the server answers, and then the line goes dead. So how does a chat notification, a live score, or a stream of tokens from a language model reach a page that never asked again? This is Part 1 of a three-part tour of the browser's networking beyond request–response — and it starts with the three ways a server can reach you.
Beyond HTTP is a three-part series. Part 1 — server push (you are here). Part 2 — two-way channels: WebSockets & WebTransport. Part 3 — browser to browser: WebRTC.
The mental model: request–response hangs up
A normal HTTP exchange is a phone call the browser ends the moment it has its answer. The browser dials (sends a request), the server speaks (sends a response), and both sides hang up. The connection is gone. If something interesting happens on the server one second later — a new message, a price change, a finished job — the server has no open line to tell you. It can only wait for the browser to call again.
That is the whole problem this post is about. Everything that follows is a different answer to one question: how does the server get a word in after the browser has stopped asking? There are three practical answers in the browser today, and they differ mostly in how far they stray from ordinary HTTP:
- Server-Sent Events — keep the one call open and let the server keep talking.
- Web Push — reach the user even when no page is open at all.
- Beacon — let the page get one last word in as it disappears.
Before any of them, though, it is worth seeing the clumsy thing they all replace.
The baseline they replace: polling
Before push existed, the only way to "get" new data was to keep asking for it — a pattern called polling. The browser sets a timer and re-requests every few seconds: anything new? anything new? anything new? Most of those calls come back empty, and each one still costs a full request and response round trip.
Long-polling is the clever hack that made polling bearable. Instead of answering "nothing new" immediately, the server holds the request open until it actually has something to say (or a timeout hits), then responds. The browser immediately re-requests, and the cycle repeats. It feels real-time, but you are still paying for a new HTTP request on every single message, and juggling connection timeouts by hand.
Polling works, and for slow-changing data it is perfectly fine. But it wastes requests, adds latency (news waits for the next poll), and scales badly. The rest of this post is about doing better.
Server-Sent Events: keep the line open
Server-Sent Events (SSE) turn a single HTTP response into a long-lived, one-way stream the server writes to whenever it likes. The browser makes one ordinary request; the server answers with the Content-Type: text/event-stream header and then simply never finishes the response — it keeps the connection open and appends messages as they occur.
On the client, the whole API is one object, EventSource:
const stream = new EventSource('/api/notifications');
stream.onmessage = (event) => {
console.log('server said:', event.data);
};
stream.addEventListener('price', (event) => {
updateTicker(JSON.parse(event.data));
});
On the server, the wire format is deliberately trivial — UTF-8 (8-bit Unicode Transformation Format) text, one field per line, a blank line to end each message:
HTTP/1.1 200 OK
Content-Type: text/event-stream
event: price
data: {"symbol":"ACME","value":42.10}
id: 1027
data: a plain message with no event name
Three things make SSE more than "long-polling that stays open":
- Automatic reconnection. If the connection drops, the browser reconnects on its own — you write no retry loop.
- Resumable with
Last-Event-ID. If your messages carry anid:field, the browser sends the last one it saw back in aLast-Event-IDheader on reconnect, so the server can replay what was missed. - Named events. An
event:field lets one stream carry several logical channels (price,notification,heartbeat), each with its own listener.
The catch is in the name: server-sent. SSE is strictly one-way — the server talks, the browser only listens. To send data up, the browser makes ordinary separate HTTP requests. It also carries only UTF-8 text, not binary. For a live feed that is exactly the shape you want, and it is why SSE quietly became the default transport for streaming tokens out of a language model: the server emits words as they are generated, the page renders them as they arrive, and reconnection is free.
Web Push: reach a tab that isn't open
Web Push delivers a message to the user even when your site has no page open — the browser itself receives it on your behalf. This is a genuinely different capability from SSE: SSE needs a live page holding a connection; Push works when every tab is closed and the browser is in the background.
It is also the most moving parts, because a fourth party is involved. The message travels from your server to a push service run by the browser vendor (such as Firefox's or Chrome's), which holds it and wakes the user's browser. A background Service Worker — a script the browser runs without a page — receives it and shows a notification.
The pieces fit together like this:
- The user grants permission, and the browser hands you a subscription: an opaque URL at the push service plus encryption keys unique to that user and site.
- Your server stores that subscription. To send a notification, it POSTs an encrypted payload to that URL.
- It signs the request using VAPID (Voluntary Application Server Identification for Web Push), a keypair that proves to the push service the message came from you — so nobody else can spam your subscribers.
- The push service delivers it; the Service Worker's
pushevent fires and callsshowNotification().
// In the Service Worker — runs with no page open:
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, { body: data.body }),
);
});
Because delivery is encrypted end to end and gated behind an explicit permission prompt, Push is the right tool for occasional, important messages — a direct message, a delivery update — and the wrong tool for a high-frequency live feed. It optimizes for reach, not throughput.
Beacon: one last word on the way out
The Beacon API sends a small, fire-and-forget request that survives the page being closed. It solves a narrow but real problem: you want to record something as the user leaves — an analytics event, a "how long did they stay" ping — but a normal request made during page unload is usually killed before it completes, because the browser is busy tearing everything down.
navigator.sendBeacon() hands the request to the browser to send after the page is gone, and returns immediately:
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/analytics', JSON.stringify({ event: 'leave' }));
}
});
You get no response back — that is the trade. Beacon is not a channel and not real-time; it is the one-directional counterpart to everything above, letting the page push a final byte to the server instead of the other way around. It rounds out the picture: sometimes "beyond request–response" just means "a request that outlives its page."
Which one when
The three mechanisms don't compete so much as cover different distances — pick by direction, frequency, and whether a page has to be open.| Mechanism | Direction | Needs an open page? | Best for | Setup cost |
|---|---|---|---|---|
| Long-polling | Server → browser | Yes | A fallback when nothing better is available | |
| Server-Sent Events | Server → browser | Yes | Live feeds, token streaming, dashboards | |
| Web Push | Server → browser | Rare, important notifications | ||
| Beacon | Browser → server | Yes (on exit) | Analytics as the user leaves |
— green means simpler or more capable for the job; red means more moving parts.
A quick decision path for the common case — "the server has something to tell the page":
Where this leaves us
Every mechanism here is still fundamentally HTTP: SSE is one long response, Push is an HTTP POST to a relay, Beacon is a parting request. They let the server reach the browser, but the browser is mostly a listener. The moment the page needs to talk back as freely as the server does — a chat, a game, a collaborative editor — one-way push isn't enough, and you need a real two-way channel. That is Part 2: WebSockets and WebTransport.
Glossary
| Term | What it is |
|---|---|
| HTTP | HyperText Transfer Protocol — the browser's default request–response protocol. |
| SSE | Server-Sent Events — a one-way stream of text from server to browser over one HTTP response. |
EventSource | The browser API that opens and manages an SSE connection. |
| Long-polling | Holding a request open until data exists, then re-requesting — polling made to feel live. |
| Service Worker | A script the browser runs in the background, with no page open, to receive push and cache assets. |
| Web Push | Delivery of a message to the browser via a vendor push service, even when no page is open. |
| VAPID | Voluntary Application Server Identification — a keypair proving a push message came from your server. |
| Beacon | A fire-and-forget request that completes after its page is gone, via navigator.sendBeacon(). |
References
- Using server-sent events — MDN — the practical guide to
EventSourceand thetext/event-streamformat. - Server-sent events — WHATWG HTML Living Standard — the normative spec, including
Last-Event-IDand reconnection. - Push API — MDN — subscriptions, permissions, and the Service Worker
pushevent. - RFC 8030 — Generic Event Delivery Using HTTP Push — the Web Push protocol between your server and the push service.
- RFC 8292 — VAPID for Web Push — how a server identifies itself to a push service.
- Beacon API — MDN —
navigator.sendBeacon()and its unload-survival guarantee.
