The events endpoint wants a bearer token, and EventSource has nowhere to put one. Its
constructor takes a URL and a withCredentials flag, so the request goes out with cookies alone
and the server answers 401. Putting the token in the query string instead logs it on every proxy
between you and the server.
What you get
You will end up with a consumer built on fetch that sends any header or body. It resumes from
the last event id after the server restarts, and stops when the server says there is nothing
more. This is for you if the stream you need sits behind a token the browser API cannot send.
Short answer
EventSource sends a GET with cookies and nothing else. When the endpoint needs an Authorization
header or a body, fetch the stream yourself and parse it with eventsource-parser, or let
@microsoft/fetch-event-source do both. Either way you own three things the browser did for you:
reconnecting after a drop, waiting the interval in the retry field, and sending Last-Event-ID.
Stop on a 204 and on any Content-Type that is not text/event-stream.
You will need
Node 22 or later, and an SSE endpoint that requires a bearer token. The sample runs its own on
127.0.0.1, so nothing here needs an account. The stream format, the retry field and the
Last-Event-ID header are all defined in
the HTML standard, which is
also where the reconnection rules you are about to own are written down.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| @microsoft/fetch-event-source | Browser code that needs headers or a POST body and wants reconnection handled for it | Written for the browser, so it reaches for window and document, retries every error after a second unless onerror throws, and drops the stream while the tab is hidden unless you say otherwise | Node, or an error policy other than retry everything |
| fetch with eventsource-parser | Any runtime with fetch and streams, and a reconnect policy you want to read in your own code | You write the loop: the retry wait, Last-Event-ID, the 204, the content type check, and a test for each one | A library already matches your runtime and its retry policy suits you |
| native EventSource | A GET endpoint that authenticates with a cookie, in a browser or in Node behind a flag | GET only, no headers, no body, and a retry policy the browser sets and you cannot read | The endpoint needs a header, a body or any method other than GET |
The split is between an API that does everything and lets you set nothing, and two that let you
set everything and do less. EventSource reconnects, waits the server’s interval and sends the last
id again, and it will not send a header. The parser does none of those and runs anywhere. The
library does all of them in a browser, with a default error policy that retries a 204 forever
until you override it, which the pitfall section below shows.
Watch the native API drop the header
Node ships the browser’s EventSource behind
--experimental-eventsource, which
makes the gap easy to measure. The constructor below is handed a headers option it does not have.
node --experimental-eventsource --no-warnings native.mjs
requests: 1
authorization header on the wire: none
readyState: CLOSED
The option was ignored, the request carried no token, and the server answered 401. The standard
says a response whose status is not 200 fails the connection, so the source went straight to
CLOSED and made no second request. That is the correct behavior for a client that cannot fix
the request, and it is the reason a token has to travel some other way.
Fetch the stream and parse it
Fetch gives you the headers, the method, and the body. What it does not give you is a loop, so the function below is mostly the loop.
export async function consume(url, {
method = 'GET',
headers = {},
body,
lastEventId = null,
retryMs = 1000,
minRetryMs = 100,
signal,
onEvent,
onReconnect = () => {},
fetch: doFetch = fetch,
sleep = wait,
} = {}) {
let retry = retryMs
while (!signal?.aborted) {
let res
try {
res = await doFetch(url, {
method,
body,
signal,
headers: {
accept: 'text/event-stream',
...headers,
...(lastEventId !== null && { 'last-event-id': lastEventId }),
},
})
} catch (err) {
// An abort while the connection is still being made is a stop, the same as one mid-stream.
if (signal?.aborted) break
throw err
}
if (res.status === 204) return { reason: '204, the server has no more events', lastEventId }
if (res.status !== 200) {
throw new SseError(`${url} answered ${res.status}`, { status: res.status })
}
const type = res.headers.get('content-type') ?? ''
if (!type.startsWith('text/event-stream')) {
throw new SseError(`${url} answered ${type || 'no content-type'}, not text/event-stream`,
{ status: 200, contentType: type })
}
let failed = null
try {
const events = res.body
.pipeThrough(new TextDecoderStream())
// A server that sends retry: 0 has asked for a hot loop. The floor is the client's.
.pipeThrough(new EventSourceParserStream({ onRetry: (ms) => { retry = Math.max(ms, minRetryMs) } }))
for await (const event of events) {
// A handler's error is the caller's, not a dropped connection. The id moves only once
// the handler is done, so the event it failed on is not marked as seen.
try { await onEvent(event) } catch (err) { failed = err; break }
if (event.id) lastEventId = event.id
if (signal?.aborted) break
}
} catch {
// The connection dropped mid-stream. That is the case reconnecting exists for.
}
if (failed) throw failed
if (signal?.aborted) break
onReconnect({ retry, lastEventId })
await sleep(retry)
}
return { reason: 'aborted', lastEventId }
}
Three lines carry the obligations EventSource used to meet. lastEventId is updated from every
event that carries an id, once onEvent has finished with it. It goes out as Last-Event-ID on
the next request, so a reconnect asks for the rows after the last one handled. onRetry
overwrites the wait with whatever the server put in its retry field, floored at
minRetryMs.1 And the catch with nothing in it is the reconnect: a body stream that errors
is a dropped connection, and the loop goes round again. An error thrown by onEvent is not a
drop. It stops the loop and reaches the caller, and an abort while the connection is still being
made ends the loop the same way an abort mid-stream does.
EventSourceParserStream turns decoded text
into events and does nothing else, which is the right amount for a parser. Feeding it through
TextDecoderStream matters
because a chunk boundary can fall inside a multi-byte character, and a decoder with state handles
that where a per-chunk toString would not.
Restart the server and watch the id come back
The demo sends a POST with a token and a JSON body. When row 3 arrives it stops the server and starts another on the same port, which is what a deploy looks like from the client’s side.
const controller = new AbortController()
const result = await consume(`${server.url}/events`, {
method: 'POST',
headers: { authorization: 'Bearer tok_live_1', 'content-type': 'application/json' },
body: JSON.stringify({ since: '2026-09-01' }),
signal: controller.signal,
onReconnect: ({ retry, lastEventId }) =>
console.log(`connection dropped, reconnecting in ${retry} ms from id ${lastEventId}`),
async onEvent(event) {
if (event.event === 'done') return controller.abort()
console.log(`row ${event.id}`)
if (event.id === '3') await restart()
},
})
node demo.mjs
row 1
row 2
row 3
server restarted
connection dropped, reconnecting in 100 ms from id 3
row 4
row 5
row 6
stopped: aborted, last id 6
requests the server saw
1 POST /events authorization: Bearer tok_live_1 last-event-id: none body: {"since":"2026-09-01"}
2 POST /events authorization: Bearer tok_live_1 last-event-id: 3 body: {"since":"2026-09-01"}
The second request is the whole page in one line. It is a POST, it carries the token, it carries
the body, and it carries Last-Event-ID: 3, so the restarted server resumes at row 4 and nothing
is repeated. The 100 ms wait is the server’s number, from a retry: 100 line at the top of the
stream. The restart itself uses
server.closeAllConnections, which
cuts the socket dead rather than ending the response, because a crash does not send a goodbye.
The stream ends when the client aborts on a done event. A server that ends the response cleanly
is not telling the client to stop, and a client that treats a clean end as the end reconnects
anyway. The signal is how the client says it is finished.
Stop on a 204 and on the wrong content type
A loop that reconnects on everything treats two responses as failures when neither is one.
A 204 is the server saying there will be no more events.2 A 200 with text/html is a proxy
serving a maintenance page in place of a service that is down. Reconnecting to it every second is
a small denial of service against your own infrastructure.
node pitfall.mjs
naive loop /nothing (204) 5 requests, gave up counting at 5
consume /nothing (204) 1 request, returned: 204, the server has no more events
naive loop /maintenance (200 text/html) 5 requests, gave up counting at 5
consume /maintenance (200 text/html) 1 request, threw: /maintenance answered text/html, not text/event-stream
The naive loop was capped at five for the demo. Without the cap it runs until the process is
killed, which is how a stream consumer becomes the top entry in somebody’s access log. The two
checks that stop it are the status === 204 return and the Content-Type test, in that order,
because a 204 carries no Content-Type and would fail the second check for the wrong reason.
Let the library own the loop
@microsoft/fetch-event-source does the reconnect, the retry field and Last-Event-ID for you,
and the same restart works through it. It is written for a browser, so under Node it needs
window and document to exist, and it needs the fetch option set because its default is
window.fetch.
await fetchEventSource(`${server.url}/events`, {
method: 'POST',
headers: { authorization: 'Bearer tok_live_1', 'content-type': 'application/json' },
body: JSON.stringify({ since: '2026-09-01' }),
openWhenHidden: true, // there is no document to be hidden here
fetch,
signal: controller.signal,
async onmessage(event) {
if (!event.data) return // the retry-only block at the top of the stream arrives as an empty message
if (event.event === 'done') return controller.abort()
rows.push(event.id)
if (event.id === '3') {
await server.stop()
server = await startServer({ port, log })
}
},
onerror() {
console.log('dropped, the library reconnects on its own')
},
})
node fes.mjs
dropped, the library reconnects on its own
rows 1,2,3,4,5,6
reconnect carried last-event-id 3 and authorization Bearer tok_live_1
/nothing with the default onopen: 5 attempts, stopped only because onerror threw
/nothing with an onopen that checks the status: 1 attempt, stopped: 204: no more events
The first block is the library doing its job. The second is its default error policy meeting a
204. Its onopen checks for text/event-stream, a 204 carries no Content-Type, so the check
throws, and its onerror treats every error as a reason to try again after a second. The demo
shortens that second to 20 ms and throws on the fifth attempt, because throwing from onerror is
the only way to stop it. The fix is an onopen of your own that recognizes the 204 and throws
something onerror knows to throw again.
openWhenHidden is the other default to know about. Without it the library closes the stream when
the document is hidden
and reopens it with the last id when the tab comes back. That is a good default for a dashboard
and a surprising one for a page that is meant to keep a log.
Check it worked
The test that matters restarts the server mid-stream and asserts on the request log rather than on the client’s opinion of itself.
assert.deepEqual(ids, [1, 2, 3, 4, 5, 6], 'every row once, none repeated after the restart')
assert.equal(log.length, 2, 'one reconnect')
assert.equal(log[1].lastEventId, '3')
assert.equal(log[1].auth, 'Bearer tok_live_1')
node --test sse.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 545.904862
Five more pin the POST body on the reconnect, the 204, the content type, a 401 that is not
retried, and the retry field reaching the sleep. The last three pin a retry: 0 that is
floored, a handler that throws, and an abort during the connect. The sleep test injects sleep
and asserts it was called once with 250, the number the server sent.
When it goes wrong
Rows repeat after every drop. The reconnect request has no Last-Event-ID, because the id was
tracked in a variable the loop reset, or because the server’s events carry no id field at all.
Log the reconnect request’s headers, and if the server sends no ids, there is nothing to resume
from and the server has to be fixed.
The consumer reconnects in a tight loop. Either the server sends retry: 0 or the loop treats a
204 as a drop. Read the pitfall section, and floor the wait, as minRetryMs does at 100 ms, if a
server you do not control sends zero.
Events arrive with mangled characters after a busy stream. The chunks were decoded one at a time
and a multi-byte character was split across two. Decode through TextDecoderStream, which keeps
the partial bytes until the rest arrive.
The stream never ends. The server finished cleanly and the loop reconnected, as it should, because
a clean end is not a stop signal. Abort the signal when your own end condition arrives, and use an
AbortController so a
timeout can share it.
When not to do this
Do not leave EventSource if you do not have to. When the endpoint takes a cookie and a GET, the
browser API already
does reconnection, the retry field and Last-Event-ID with no code of yours to test. Every line
on this page is a line you would otherwise not own.
Do not put the token in the query string to keep EventSource. It lands in access logs, browser
history and Referer headers, and a stream URL is fetched many times over its life.
Do not retry a 401 or a 403. The reconnect will carry the same token and get the same answer, and a loop that retries authentication failures is indistinguishable from an attack from the server’s side. Surface it and let the caller fetch a new token.
Do not ship the library into a Node service without reading its source. It works there with a two-line shim, as shown, and the shim is a sign that the maintainers did not have your runtime in mind.3
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2, eventsource-parser 4.1.1 and
@microsoft/fetch-event-source 2.0.1. Every output block is what the preceding command printed,
against the server in the sample directory. The library ran under Node with a shim for window
and document, so its behavior around a hidden tab was read from its source and not exercised.
Footnotes
-
The standard’s default for the reconnection time is, in its own words, an implementation-defined value, probably in the region of a few seconds. A specification that says probably has left the number to the browsers, and the
retryfield exists so a server can take it back. ↩︎ Back to text -
A 204 is the one status code the SSE standard gives a meaning of its own: the client is to stop reconnecting. RFC 9110 defines it as a success with nothing to send, which is a fair summary of a stream that has ended. It is also, by construction, a response with no Content-Type, so a client that checks the Content-Type first sees a failure where the server sent a goodbye. ↩︎ Back to text
-
The library’s README says the browser will silently retry a few times and then stop. The standard sets no number of attempts, and reconnects until told to stop with a 204. One of the two documents is describing something the other did not write down. ↩︎ Back to text