How-to › Move data in and out

How to pick SSE, WebSockets, or long polling for a live endpoint#

Serve one job-progress feed as SSE, WebSocket, long poll, and short poll, restart the proxy in front of it, and choose by what survives and what each event costs.

Audience
API producer
Level
beginner
Topic
Stream responses and subscriptions
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Long pollingA proxy or CDN in the path buffers or cuts responses that stay openA full request and response per event, 283 bytes against 41 for SSE hereEvents arrive several times a second
Server-Sent EventsOne-way feeds to browsers, scripts, and agents that speak plain HTTPSix connections per browser and domain over HTTP/1.1, and text frames onlyThe client streams messages back, or payloads are binary
Short polling with ETag and 304Updates every few seconds, where the latest state is all the client needsA request every interval whether or not anything changed, and missed intermediate statesEvery event matters, or latency must beat the interval
WebSocketsTwo-way traffic, such as a client streaming its own messages or binary framesReconnect, resume, and auth are your code, and a browser cannot attach a bearer tokenTraffic 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

QuestionAnswerPick
Which way does data flow?Server to client onlySSE
Both ways, as a streamWebSockets
What sits in the path?Proxies and a CDN you configureSSE, with buffering off
A proxy you cannot configure that holds responsesLong polling
How often do events arrive?Several a secondSSE or WebSockets
Every few seconds, and only the latest mattersShort polling with an ETag
Who is the client?A browserSSE through EventSource
A script or a coding agentSSE 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.

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

  1. The MDN page on EventSource records 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

  2. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.