Your job-progress endpoint pushes updates over a WebSocket, and a deploy restarts the proxy in front of it. Every open socket drops, so the progress bar stops at 40% while the job runs on to completion. The reconnect loop, the resume point, and the fresh credential you write next are features Server-Sent Events had from the start.
What you get
You will end up with a choice for one live endpoint, made from the same feed served four ways and a proxy restart that shows which clients come back. This is for you if a REST endpoint needs to start pushing.
Short answer
Use Server-Sent Events when updates flow one way: it is plain HTTP, and the standard has
EventSource reconnect and resume from Last-Event-ID. Use WebSockets only when the client also
sends a stream of messages, because reconnect, resume, and token auth become your code. Use long
polling when something in the path breaks streaming responses. Then restart the proxy in a test
and count the events that arrive.
You will need
Node 22.3 or later, which ships a WebSocket client and, behind --experimental-eventsource, an
EventSource. The server side uses the ws package. Bring a REST endpoint that already serves the
state you want to push, and enough HTTP to read a response header.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Long polling | A proxy or CDN in the path buffers or cuts responses that stay open | A full request and response per event, 283 bytes against 41 for SSE here | Events arrive several times a second |
| Server-Sent Events | One-way feeds to browsers, scripts, and agents that speak plain HTTP | Six connections per browser and domain over HTTP/1.1, and text frames only | The client streams messages back, or payloads are binary |
Short polling with ETag and 304 | Updates every few seconds, where the latest state is all the client needs | A request every interval whether or not anything changed, and missed intermediate states | Every event matters, or latency must beat the interval |
| WebSockets | Two-way traffic, such as a client streaming its own messages or binary frames | Reconnect, resume, and auth are your code, and a browser cannot attach a bearer token | Traffic flows one way |
SSE is the default for a progress feed, because the server talks and the client listens. It costs you the six-connection ceiling on HTTP/1.1, which HTTP/2 lifts.1 WebSockets buy a second direction this endpoint does not use, and charge for it in client code. Long polling works through anything that forwards HTTP and pays a full round trip for every event.2
Serve one feed four ways
The feed is a list of events with increasing ids. Each mechanism reads the same list, so the comparison measures transport alone.
if (url.pathname === '/progress/sse') {
const last = Number(req.headers['last-event-id']) || 0
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-store',
'x-accel-buffering': 'no', // tells nginx not to buffer this response
})
res.write('retry: 100\n\n')
for (const e of after(last)) res.write(frame(e))
const send = (e) => res.write(frame(e))
listeners.add(send)
// A comment line now and then, so no proxy closes the stream for being idle.
const beat = setInterval(() => res.write(': ping\n\n'), heartbeatMs)
res.on('close', () => { listeners.delete(send); clearInterval(beat) })
return
}
if (url.pathname === '/progress/poll') {
const since = Number(url.searchParams.get('after')) || 0
const reply = () => {
clearTimeout(timer)
listeners.delete(wake)
if (!res.writableEnded) res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(after(since)))
}
const wake = () => reply()
const timer = setTimeout(reply, holdMs)
if (after(since).length) return reply()
listeners.add(wake)
res.on('close', () => { clearTimeout(timer); listeners.delete(wake) })
return
}
if (url.pathname === '/progress') {
if (req.headers['if-none-match'] === etag()) return res.writeHead(304, { etag: etag() }).end()
const state = events.at(-1) ?? { id: 0, percent: 0 }
return res.writeHead(200, { 'content-type': 'application/json', etag: etag() }).end(JSON.stringify(state))
}
The SSE branch is the one to read closely. retry: 100 sets the client’s reconnect delay.
Last-Event-ID on a reconnect replays what the client missed. The comment line every fifteen
seconds keeps idle proxies from cutting the stream. The long-poll branch answers at once when there
is news and holds otherwise, and the snapshot branch answers 304 while its ETag still matches.
Authenticate the WebSocket with a ticket
The browser’s
WebSocket constructor
takes a URL and a list of subprotocols, which leaves a bearer token nowhere to go. The usual
answer is a ticket: a short-lived, single-use value fetched over an authenticated POST and passed
in the query string.
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, 'http://feed.internal')
const ticket = url.searchParams.get('ticket') ?? ''
const expires = tickets.get(ticket)
tickets.delete(ticket) // single use, whether or not it is still valid
if (url.pathname !== '/progress/ws' || !expires || expires < now()) {
socket.end('HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n')
return
}
wss.handleUpgrade(req, socket, head, (ws) => {
const since = Number(url.searchParams.get('after')) || 0
for (const e of after(since)) ws.send(JSON.stringify(e))
const send = (e) => ws.send(JSON.stringify(e))
listeners.add(send)
const beat = setInterval(() => ws.ping(), heartbeatMs) // the same, for WebSocket
ws.on('close', () => { listeners.delete(send); clearInterval(beat) })
})
})
The ticket is deleted on first use, valid or not, so one leaked from a log opens nothing. SSE needs
none of this in a browser, where EventSource sends the page’s cookies. A script that needs a header
reads the same stream with fetch, as the how-to on consuming SSE with custom headers shows.
Restart the proxy and count what arrives
restart.mjs connects six clients through a proxy, publishes four events, and restarts the proxy.
Events 5 and 6 go out while it is down. It runs twice: once with the proxy down for 20 ms, inside
the 100 ms SSE retry interval, and once for 300 ms, longer than it.
node --experimental-eventsource --no-warnings restart.mjs
client proxy down 20 ms proxy down 300 ms
SSE, EventSource 10 of 10 4 of 10, lost 5-10
SSE, fetch loop 10 of 10 10 of 10
WebSocket 4 of 10, lost 5-10 4 of 10, lost 5-10
WebSocket, hand-built retry 10 of 10 10 of 10
long polling 10 of 10 10 of 10
short polling, ETag state only, 100% state only, 100%
The plain WebSocket lost everything after the drop, both times. Its hand-built twin in
ws-client.mjs survived, at the price of 46 lines: a reconnect loop with backoff, a resume point
the server honors, and a fresh ticket on every attempt. Long polling survived without trying,
because its loop already retries.
Node’s EventSource survived the blip but not the outage: when its one reconnect attempt hit a
refused connection, it closed for good. The
HTML standard tells it to
reestablish the connection after a network error, unless it knows that to be futile. Node 22.22.0
and 22.23.3 take their EventSource from undici 6, which closes instead, and both printed this
table. undici changed this in 7.11.0, so on Node 22 a
script reading SSE needs the fetch loop that survived both runs.
The proxy here is proxy.mjs, a Node stand-in for nginx. Run with PROXY=nginx, the same file
spawns nginx 1.24.0 with nginx.conf. In the session that wrote this page it printed the same
table. The CI check does not run that mode, because it depends on an nginx binary.
Count the bytes each event costs
Message rate is the other half of the choice. overhead.mjs counts the bytes one event costs at the
server’s socket, with minimal request headers, so these are floors.
node --experimental-eventsource --no-warnings overhead.mjs
one progress event, such as {"id":4,"percent":40}, counted at the server
SSE 41 bytes down an open connection
WebSocket 23 bytes down an open connection
long polling 86 bytes up and 197 down, a round trip per event
short polling 93 bytes up and 124 down for every poll that finds nothing new
A long poll spends seven times the bytes of an SSE frame on one event, and a round trip of latency before the next one can arrive. Short polling pays 217 bytes for every poll that finds nothing, so its cost scales with the polling rate, not with the events.
Decide from four questions
| Question | Answer | Pick |
|---|---|---|
| Which way does data flow? | Server to client only | SSE |
| Both ways, as a stream | WebSockets | |
| What sits in the path? | Proxies and a CDN you configure | SSE, with buffering off |
| A proxy you cannot configure that holds responses | Long polling | |
| How often do events arrive? | Several a second | SSE or WebSockets |
| Every few seconds, and only the latest matters | Short polling with an ETag | |
| Who is the client? | A browser | SSE through EventSource |
| A script or a coding agent | SSE or long polling, both plain HTTP |
A coding agent drives curl and fetch, and both read SSE and long polls as ordinary responses.
curl’s WebSocket notes record that command-line WebSocket
support has not been started, so an agent reaching a WebSocket needs a library first.
Check it worked
The suite pins the server’s side of each mechanism, the ticket rules, the WebSocket client’s resume
and its shutdown, and the EventSource behavior the restart exposed. That test reads the undici
version Node bundles, so a release that changes the behavior fails here first.
// Node's EventSource comes from undici. Node 22.22.0 and 22.23.3 bundle undici 6, whose EventSource
// closes for good when one reconnect is refused; undici 7.11.0 made it keep retrying.
const [major, minor] = process.versions.undici.split('.').map(Number)
const keepsRetrying = major > 7 || (major === 7 && minor >= 11)
test('the built-in EventSource stays closed after a refused reconnect, before undici 7.11', async (ctx) => {
const t = await start()
const proxy = createProxy({ host: '127.0.0.1', port: t.feed.server.address().port })
await proxy.start()
const source = new EventSource(`http://127.0.0.1:${proxy.port}/progress/sse`)
ctx.after(async () => { source.close(); await proxy.stop(); t.close() })
await new Promise((resolve) => { source.onopen = resolve })
await proxy.stop()
await sleep(300) // the 100 ms retry finds nothing listening
await proxy.start()
await sleep(300)
const expected = keepsRetrying ? EventSource.OPEN : EventSource.CLOSED
assert.equal(source.readyState, expected, `undici ${process.versions.undici}`)
})
node --experimental-eventsource --test feed.test.mjs
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2002.096971
When it goes wrong
The WebSocket closes every minute although both ends are healthy. nginx closes a connection it forwards after
60 seconds without data. Send pings, as the feed
does every fifteen seconds, or raise proxy_read_timeout.
# Server-Sent Events: with buffering off, nginx passes each event on as it arrives.
# The feed also sends X-Accel-Buffering: no, which asks for the same per response.
location /progress/sse {
proxy_pass http://feed;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_read_timeout 1h;
}
# WebSocket: Upgrade and Connection are hop-by-hop, so pass them on by hand.
location /progress/ws {
proxy_pass http://feed;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 1h;
}
The WebSocket handshake through nginx fails with a plain HTTP response. Upgrade and Connection
are hop-by-hop headers, so nginx drops them unless the location sets them again, as this configuration does.
A user with six tabs open gets a seventh that never loads. Each tab holds an SSE connection, and
browsers allow six HTTP/1.1 connections per domain. Serve the stream over HTTP/2, or share one connection between tabs
with a BroadcastChannel.
When not to do this
Do not build a push channel for a job that takes thirty seconds and has three states. Polling the status endpoint with backoff costs a few requests and needs no proxy changes at all.
Do not pick WebSockets for a one-way feed because a framework makes them convenient. The restart run shows the bill: reconnect, resume, and authentication arrive as your code, and nginx needs two extra headers and a timeout.
Do not trust a client library’s reconnect without restarting the proxy under it. The standard says
EventSource reconnects, but the one in Node 22.22.0 and 22.23.3 gave up after one refused attempt,
which only a test that drops the connection revealed.
Do not let a ticket outlive its first use. A query string reaches access logs, so the ticket must expire in seconds and stop working once it has opened a socket.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0 and 22.23.3, whose EventSource comes from undici 6.23.0
and 6.28.1, and ws 8.22.0. Every output block is what the command before it printed, on both Node
versions. The nginx run used nginx 1.24.0, in the writing session and again in review, and is
described rather than captured.
Footnotes
-
The MDN page on
EventSourcerecords that the six-connection limit was marked “Won’t fix” in both Chrome and Firefox. Over HTTP/2 the number of concurrent streams is negotiated instead, with a default of 100. The limit applies per browser and domain, so six tabs on one site can starve a seventh while a second site is unaffected. ↩︎ Back to text -
RFC 6202 catalogs the known issues of long polling. Its abstract says HTTP is often used for server-initiated messages, and adds in parentheses that some would call it abuse. It was published in April 2011, eight months before RFC 6455 standardized the protocol meant to make the abuse unnecessary. It puts long polling’s worst-case latency at over three network transits. ↩︎ Back to text