# How to consume Server-Sent Events with fetch and custom headers

> Read an SSE endpoint that needs a bearer token or a POST body, which EventSource cannot send, and own reconnection, the retry field and Last-Event-ID yourself.

Source: https://voxgig.com/howto/consume-sse-with-fetch-and-custom-headers

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://html.spec.whatwg.org/multipage/server-sent-events.html), 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](https://github.com/Azure/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](https://github.com/rexxars/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](https://developer.mozilla.org/en-US/docs/Web/API/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`](https://nodejs.org/api/cli.html#--experimental-eventsource), which
makes the gap easy to measure. The constructor below is handed a headers option it does not have.

```bash
node --experimental-eventsource --no-warnings native.mjs
```

```text output
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.

```js title="sse.mjs"
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`](https://github.com/rexxars/eventsource-parser) turns decoded text
into events and does nothing else, which is the right amount for a parser. Feeding it through
[`TextDecoderStream`](https://developer.mozilla.org/en-US/docs/Web/API/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.

```js title="demo.mjs"
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()
  },
})
```

```bash
node demo.mjs
```

```text output
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`](https://nodejs.org/api/http.html#servercloseallconnections), 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.

```bash
node pitfall.mjs
```

```text output
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`.

```js title="fes.mjs"
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')
  },
})
```

```bash
node fes.mjs
```

```text output
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](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API)
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.

```js title="sse.test.mjs"
  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')
```

```bash
node --test sse.test.mjs
```

```text output
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`](https://developer.mozilla.org/en-US/docs/Web/API/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](https://developer.mozilla.org/en-US/docs/Web/API/EventSource/EventSource) 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

- [Add a bearer token to fetch without a client library](/howto/bearer-token-fetch-wrapper)

- [Time out fetch calls with AbortSignal in Node.js](/howto/time-out-fetch-with-abortsignal-in-node)

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

[^1]: 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](https://html.spec.whatwg.org/multipage/server-sent-events.html).
A specification that says probably has left the number to the browsers, and the `retry` field
exists so a server can take it back.

[^2]: A 204 is the one status code the SSE standard gives a meaning of its own: the client is to
stop reconnecting. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-204-no-content)
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.

[^3]: The library's [README](https://github.com/Azure/fetch-event-source) says the browser will
silently retry a few times and then stop. The
[standard](https://html.spec.whatwg.org/multipage/server-sent-events.html) 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.