Networking · 16 min
Server-Sent Events
Build a live score page from one HTTP response that never ends: a few lines of plain text per event, a browser that reconnects by itself, and an id that lets it catch up on what it missed.
Tokyo play Osaka tonight, and 100,000 fans have your score page open. In minute 54 Tokyo score. Every one of those pages should show 2-1 straight away, without anyone pressing refresh.
But on the web the browser asks and the server answers. The server can’t speak first. So how does the news of a goal get to a page that didn’t ask for it?
This article builds the answer by hand, one piece at a time, using nothing but ordinary HTTP. By the end you will have built Server-Sent Events, the browser’s own way to receive a live feed, and you will know when to pick something else.
Start with what you know: ask again and again
The first thing most of us write is polling: every 5 seconds, the page asks GET /scores?after=9 (“anything after update 9?”) and the server answers with the new updates, or an empty list.
It works, and it costs a lot. Each fan sends 60 ÷ 5 = 12 requests a minute. Over a 90-minute match that is 12 × 90 = 1,080 requests per fan. With 100,000 fans, the servers answer 100,000 ÷ 5 = 20,000 requests every second.
And very few of those answers carry news. A match has maybe 40 updates: goals, cards, substitutions and a line of commentary every few minutes. So 1,040 of each fan’s 1,080 requests, about 96%, come back with nothing.
Guess first: A goal can happen at any moment between two polls. With a poll every 5 seconds, how long does a fan wait, on average, to see it?
About 2.5 seconds, half the interval. A goal right after a poll waits almost 5 s; one just before a poll waits almost nothing; on average it waits half way. Add the time for the answer to travel back. To halve the wait you must poll twice as often, which doubles the 20,000 requests a second.
Polling makes you trade freshness for load, and both get worse with more fans. What you want is for the server to send when there is news, and stay silent otherwise.
Step 1: a response that never ends
Here is the trick. The browser sends one ordinary request, GET /scores, with the header Accept: text/event-stream (“I want a live feed”). The server answers 200 OK and sends the headers, then doesn’t finish. Normally the browser knows when a body ends, because the server gave its length in a Content-Length header or closed the connection. This server does neither, and leaves the response open.
The browser is used to receiving a body bit by bit, because big pages arrive that way. So it keeps reading. Whenever there is news, the server writes a few more bytes into the open response. A goal in minute 54 is written in minute 54 and reaches the fan one network trip later, tens of milliseconds, not seconds.
Nothing new happens on the network. It is one HTTP request riding on one TCP connection, the same as any page load. It is just a very long response.
Step 2: a text format both sides agree on
An open response is just a flow of bytes. The browser needs to know where one piece of news ends and the next begins. So we agree on a tiny text format, one field per line.
Here is the start of the score stream. Look for the empty line after each event: that is what tells the browser the event is complete.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
retry: 3000
id: 8
data: Half time.
data: Tokyo 1, Osaka 1
: ping
id: 9
data: Second half under way
id: 10
event: goal
data: {"min":54,"team":"Tokyo","score":"2-1"}
There are only four field names, plus comments:
- data: the message itself. Several data lines in one event are joined with a newline, so event 8 arrives as two lines of text.
- event: a name for the kind of message, like goal. Leave it out and the event is called message.
- id: a label for this event. It matters when the connection breaks (step 4).
- retry: how many milliseconds the browser should wait before reconnecting (step 4).
- A line starting with a colon is a comment. The browser ignores it. Servers send one now and then, like : ping, to keep a quiet connection alive (more on that later).
A blank line only fires an event if a data line came before it. So retry: 3000 and : ping, each followed by a blank line, never reach your code: the browser remembers the retry value and ignores the comment.
Every event is tiny. The goal event above is 66 bytes, counting the newline at the end of each of its four lines. A polling request’s headers alone (cookies, browser name, accepted formats) are often several times that, mostly to hear “nothing new”.
Step 3: let the browser read it: EventSource
You could read the stream with fetch and split lines yourself. You don’t have to: every modern browser has a built-in reader for exactly this format.
Here is the whole client for the score page. Notice that goal events go only to the listener named goal; onmessage gets only the events with no event: line. An event whose name has no listener is dropped, so a real page would add one for card too.
const scores = new EventSource('/scores');
scores.addEventListener('goal', (e) => {
const goal = JSON.parse(e.data);
showScore(goal.score); // "2-1"
});
scores.onmessage = (e) => addCommentary(e.data);Bytes don’t arrive in neat events. The network delivers them in pieces of any size, so one piece can end in the middle of a line, like data: {"min":5. EventSource keeps that half line until the rest arrives. It acts only when it reaches the empty line, never before.
Step 4: when the connection drops
A fan on a train goes into a tunnel. The connection breaks. With polling that would barely matter: the next poll would just fail. With a stream, the page goes silent and stays silent. So EventSource does something on its own.
How long it waits is up to the browser: a few seconds by default (Chrome waits 3 s). The server can set it with a retry line, like retry: 3000 at the top of our stream. If a try fails because there is still no network, the browser waits again and tries again.
The fan is back online. But a new stream starts from now. What about the goal that was scored while the train was in the tunnel?
Guess first: The fan’s last event was id 9. During the tunnel, the server published events 10 and 11, both Tokyo goals. The browser reconnects. How can the server know which events to send again?
The browser tells it. EventSource remembers the id of the last event it received, and when it reconnects it adds a header to the request: Last-Event-ID: 9. The server looks up everything after 9 and writes events 10 and 11 into the new stream straight away, before carrying on with live news. You write none of this in the browser; it is built in.
Step 5: catch up with ids
For this to work, the server has to keep recent events. A simple way: keep the last few hundred events of each match in memory, or in Redis (an in-memory store many servers can share).
Here is the server side in short. Look at the three jobs: send headers, replay what the fan missed, then keep writing.
on GET /scores:
send headers (200, Content-Type: text/event-stream)
last = request.header("Last-Event-ID") # missing on the first visit
if last:
for event in recent_events where event.id > last:
write(event) # catch up
subscribe(write) # live news for this match; never call end()What if the fan was away so long that event 10 has already fallen out of the buffer? The server can tell: the oldest id it still has is bigger than 10. It can’t replay the gap. It should send the current state instead, such as one event with the full score, so the page is right even if some commentary is lost.
Watch a match arrive, drop it, and catch up
- Press Start the stream. Under On the wire, the text arrives a few characters at a time. A half-finished line shows in grey, tagged “waiting for the rest of this line”. An event appears under What the page gets only when its blank line arrives.
- When the clock shows 50′ (or within a minute after), press Drop the connection. The network is down for 8 s, which is 8 match minutes, and the browser tries again every 3 s, so it gets through on the third try. The new request carries Last-Event-ID, and everything you missed, including the goals at minutes 54 and 59, arrives right after the retry line, marked resent. (Last-Event-ID is the last event that fully arrived, so if you pressed mid-event, that one is resent too.)
- Untick Server resends from Last-Event-ID, then press Drop the connection again at about minute 72. The browser still sends Last-Event-ID, but the server ignores it: Osaka’s goal at minute 77 never arrives and the scoreboard stays at 3-1, even when the full-time message says Tokyo 3, Osaka 2.
- Press Reset, then Start the stream, and switch Watch to Polling every 5 s to see the polling client’s requests. Let the match run without drops, then compare the columns under Same match, two clients. When the demo stops, a few seconds after FT, it is 20 requests against 1, and an average delay of about 2.2 s against about 0.4 s.
The match in the demo is sped up, so there is news every few seconds and most polls find something. In a real match most polls come back empty, as worked out above.
What gets in the way
A stream that stays open for 90 minutes meets problems a normal request never does. These are the ones that catch people out.
Proxies that hold on to your bytes. Many sites put nginx (a web server often placed in front of the app) between the browser and the app. By default nginx collects the app’s response in a buffer and passes it on in larger pieces. For a stream, that means the goal sits in nginx until more events pile up. Have the app send the header X-Accel-Buffering: no on the stream’s response, or turn off proxy_buffering, so each event goes straight through. Compression can do the same thing: gzip shrinks text best in big blocks, so a gzip step may wait for more bytes before passing any on.
Idle timeouts. Proxies and load balancers close connections that have been silent for a while; nginx gives up after 60 seconds with nothing from the app. A long spell with no news looks like a dead connection. That is what the : ping comment is for: the HTML standard, which also defines EventSource, suggests one about every 15 seconds. The demo sends one after 5 quiet seconds so you can see them.
Six connections per site on HTTP/1.1. Over HTTP/1.1, Chrome and Firefox open at most 6 connections to one domain, across all tabs. Each open stream holds one of them for as long as the page is open. A fan with the score page in 6 tabs has used them all, and the 7th tab’s stream waits, along with every other request to that site from any tab.
HTTP/2 removes that limit. It carries many requests over one TCP connection at the same time, side by side, so an open event stream no longer blocks anything else. The two sides agree how many requests may be open at once on that connection, usually 100. If you serve SSE, serve it over HTTP/2.
One direction only. The server talks; the browser listens. When the fan taps “follow this match”, that goes up as an ordinary POST. For a score page that is fine, because fans read far more than they write.
Text only. Every event is UTF-8 text. Binary data, such as an image, has to be turned into text first (base64, which writes every 3 bytes as 4 letters), so it gets about a third bigger.
Polling, SSE or WebSockets?
WebSockets are the other common way to keep a connection open. A WebSocket starts as an HTTP request, then turns the connection into a two-way channel, so both sides can send at any time, text or binary. WebSockets have their own page; here is how the three compare.
| Polling every 5 s | Server-Sent Events | WebSockets | |
|---|---|---|---|
| Who sends news | Server, only when asked | Server, whenever it has news | Either side, any time |
| Average wait for news | Half the interval (2.5 s) plus one trip | One network trip | One network trip |
| Requests per fan, 90 minutes | 1,080 | 1, plus one per reconnect | 1, plus one per reconnect |
| What it carries | Anything HTTP can | UTF-8 text | Text or binary |
| Reconnect and catch up | Nothing to reconnect | Built into the browser | You write it yourself |
| Good for | Rare updates, the simplest setup | Scores, notifications, progress bars, AI replies typed out word by word | Chat, multiplayer games, editing a document together |
Pick by direction first. If news flows one way, from server to page, SSE gives you the push of a WebSocket with plain HTTP, built-in reconnects and catch-up. If the browser must also send a steady flow of messages, use a WebSocket. If updates are rare and a few seconds late is fine, polling is still the simplest thing that works.
Back to the 100,000 fans
Now put it together for tonight’s match, against polling’s 20,000 requests a second. With SSE: one request per fan, so 100,000 streams held open. A goal is 66 bytes written to each of them, and reaches each fan one network trip later. A fan who loses signal tries again every 3 s until the network is back, sends Last-Event-ID, and gets the goal anyway.
The cost moves from requests to open connections. Servers that hold idle connections cheaply, such as Node.js, Go or nginx, keep tens of thousands each, so 100,000 fans means a handful of servers. The goal then has to reach all of them: the score service publishes it once to a shared channel, such as Redis pub/sub (publish once, every subscriber gets a copy), and each server writes it to its own fans. Serve the stream over HTTP/2, switch off proxy buffering, send a ping every 15 seconds, and give every event an id that keeps growing.
Remember
- Server-Sent Events is one HTTP response the server never finishes, writing a few lines of text per event.
- Each event is lines like data:, event: and id:, ended by an empty line; a line starting with a colon is a keep-alive comment.
- EventSource reconnects by itself after retry ms and sends Last-Event-ID, so the server can replay what was missed.
- Polling every N seconds makes news wait N ÷ 2 seconds on average, and most polls come back empty.
- SSE is one direction and text only; on HTTP/1.1 it uses up one of 6 connections per site, so serve it over HTTP/2.
See it in a design
- Live comments: Push comments and reactions to millions of live viewers with SSE and a dispatcher.
- Notification system: One API for push, email and SMS with preferences, per-channel queues and retries.
- Stock exchange matching engine: Sequencer plus single-threaded, in-memory order books, with trades fanned out to market data and clearing.
Further reading and videos
- Using server-sent events (MDN)
- EventSource (MDN)
- Server-sent events (HTML Living Standard (WHATWG))
- ngx_http_proxy_module (proxy_buffering) (nginx docs)