How-to › Move data in and out

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.

Audience
API consumer
Level
intermediate
Topic
Stream responses and subscriptions
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
@microsoft/fetch-event-sourceBrowser code that needs headers or a POST body and wants reconnection handled for itWritten 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 otherwiseNode, or an error policy other than retry everything
fetch with eventsource-parserAny runtime with fetch and streams, and a reconnect policy you want to read in your own codeYou write the loop: the retry wait, Last-Event-ID, the 204, the content type check, and a test for each oneA library already matches your runtime and its retry policy suits you
native EventSourceA GET endpoint that authenticates with a cookie, in a browser or in Node behind a flagGET only, no headers, no body, and a retry policy the browser sets and you cannot readThe 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

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

  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. A specification that says probably has left the number to the browsers, and the retry field exists so a server can take it back. ↩︎ Back to text

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

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

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.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.