# How to retry a streaming response without replaying received data

> Resume a stream that dies mid-body from the last event id or byte offset, stop on an explicit end, and refuse to retry a stream that carries no ids.

Source: https://voxgig.com/howto/retry-a-streaming-response-without-replaying-data

- Audience: agent-builder
- Level: advanced
- Languages: typescript
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Track the last event id or byte offset your code has handled, and send it on the retry. Use Last-Event-ID for Server-Sent Events, and Range with If-Range for a download, accepting a 206 only at the offset you asked for. Stop on an explicit end event or a 204, never on a clean close. A stream that carries no ids cannot be resumed, so restart it on purpose or give up.

---
## You will need

Node 22.12 or later, which `eventsource` 5 requires, and an SSE or chunked endpoint to call.
Verified 2026-10-15 against Node 22.22.0, `eventsource` 5.1.2, @microsoft/fetch-event-source 2.0.1,
and eventsource-parser 4.1.1. Resuming only works when the server can start from a point:
[the SSE standard](https://html.spec.whatwg.org/multipage/server-sent-events.html) defines
`Last-Event-ID`, and the server decides what to do with it. The backoff between attempts is the
subject of retrying fetch with exponential backoff, linked below.

## 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 an `onerror` that decides | A clean close resolves the promise, so a graceful server shutdown ends the read early | The stream has to be read to its end across deploys |
| [`eventsource` package reconnect](https://github.com/EventSource/eventsource) | A spec-shaped client, and a server that honors `Last-Event-ID` and answers 204 when done | It reconnects after every close, a finished stream included, stops for good on any status but 200, and has no retry budget to set | The server cannot resume, or you must stop after repeated failures |
| [hand-rolled fetch loop with Last-Event-ID or Range](https://github.com/rexxars/eventsource-parser) | You need a budget, an explicit end, a duplicate guard, or byte-range downloads | About a hundred lines to own, and a test for every way a stream can end | A library already matches your server's end-of-stream contract |

The two libraries disagree about what a clean close means. `eventsource` follows the standard and
reconnects, which is right after a deploy and wrong after the server has finished, unless the server
answers the reconnect with 204. fetch-event-source treats a clean close as the end, so a deploy cuts
the read short unless your `onclose` throws, as its README shows. The loop decides on an explicit
end event, and it costs you the code.

## Measure how each client ends a cut stream

The sample server sends ten events and fails five ways. A crash drops the socket every three
events, and a deploy ends the response cleanly every three. A finished stream with no end event
replays from the start on reconnect, one crash ignores `Last-Event-ID`, and the last answers the
first reconnect with 503 and `Retry-After: 1`.

```bash
node compare.mjs
```

```text output
client              events  dupes  requests  how it ended
-- crash: drops every 3 events
eventsource         10      0      4         end event, close()
fetch-event-source  10      0      4         end event, abort()
hand-rolled loop    10      0      4         end event
-- deploy: ends cleanly every 3 events
eventsource         10      0      4         end event, close()
fetch-event-source  3       0      1         returned: the stream closed cleanly
hand-rolled loop    10      0      4         end event
-- finished, no end event, replays on reconnect
eventsource         20      10     2         still going at 20 events
fetch-event-source  10      0      1         returned: the stream closed cleanly
hand-rolled loop    10      0      4         gave up after 3 reconnects without a new event
-- drops every 3, ignores Last-Event-ID
eventsource         20      17     7         still going at 20 events
fetch-event-source  20      17     7         still going at 20 events
hand-rolled loop    3       0      4         gave up after 3 reconnects without a new event
-- drops every 3, first reconnect gets 503
eventsource         3       0      2         closed by the library: 503
fetch-event-source  10      0      5         end event, abort()
hand-rolled loop    10      0      5         end event
```

In the first block, against a server that resumes, all three read ten events once. The deploy is
where they part:
fetch-event-source returned after three events with no error. The third block is the duplicate
stream: the server finished, `eventsource` reconnected as the standard says, and the server sent
everything again. The demo stopped it at 20 events, because the library has no limit of its own.
In the fourth block the server cannot resume at all, so both libraries loop on rows 1 to 3 until
stopped, and the loop gives up. The last block stands in for a load balancer with no instance
ready during a deploy.
[The standard](https://html.spec.whatwg.org/multipage/server-sent-events.html) fails the connection
on any status other than 200, so `eventsource` stopped for good at row 3.

## Track the last id and stop on an explicit end

The loop's core is the event handler. It ends on an `end` event and skips an id it has already
handled. It keeps a handler's failure apart from a dropped connection, and records the id only
after the handler has finished with the event.

```js title="resume.mjs"
for await (const event of events) {
  if (event.event === 'end') return { reason: 'end event', lastId, requests, skipped }
  if (event.id !== undefined && seen.has(event.id)) {
    skipped.push(event.id)
    continue
  }
  // The handler's own failure is the caller's to see, not a dropped connection.
  try { await onEvent(event) } catch (err) { handlerError = err; break }
  delivered++
  if (event.id === undefined) {
    idlessSinceLastId++
  } else {
    idlessSinceLastId = 0
    lastId = event.id
    seen.add(event.id)
    if (seen.size > dedupeWindow) seen.delete(seen.values().next().value)
  }
}
```

A clean close without an `end` event counts as a cut, so the loop reconnects with `Last-Event-ID`.
A 204 on that reconnect means the server has nothing more, which is how the standard tells a
client to stop. A 429, 502, 503, or 504 counts as a reconnect that delivered nothing, and the loop
waits for its `Retry-After`, capped at a minute, before the next one. Three reconnects in a row that
deliver nothing new end the loop with an error, so a server that replays forever costs four
requests rather than an afternoon.

## Resume a download with Range and If-Range

A file has no event ids, so the offset is the count of bytes already written. Ask for the rest
with `Range`, and send the `ETag` in `If-Range` so the server answers 200 with the whole file if it
changed in between. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.5) forbids
a weak tag in `If-Range`, so the loop starts over when the tag is weak.[^1] A 206 is accepted only
when its `Content-Range` starts at the byte the loop asked for. The loop also sends
`Accept-Encoding: identity` on every request. `fetch` decodes a `gzip` body but keeps the encoded
`Content-Length`, and a range counts encoded bytes, so a count of decoded bytes matches neither. When
a server compresses anyway, the loop has no offset it can trust, so it starts over after a cut.

```bash
node demo.mjs
```

```text output
download, cut after every third of the file
  Range + If-Range                    200 206 206     50450 bytes, intact true
  Range + If-Range, file replaced     200 200 206 206 50465 bytes, intact true
  Range only, file replaced           200 206 206 206 50465 bytes, intact false
```

The second line is `If-Range` at work: the file changed after the first cut, the server sent a
full 200, and the loop threw away its partial copy. The third line is the same download without
`If-Range`. The server honored the range against the second version, and the client appended it to
the first, a file that matches neither version and passes every length check.

## Refuse to retry a stream with no ids

A model's token stream is the common case with nothing to resume from. The example stream in
[Anthropic's streaming documentation](https://platform.claude.com/docs/en/build-with-claude/streaming)
carries `event` and `data` lines and no `id`, so a reconnect has no position to send. The loop
refuses rather than ask for the stream again.

```bash
node demo.mjs
```

```text output
model token stream with no ids, cut after 3 deltas
  received "The answer is " in 1 request
  NotResumableError: 3 events arrived after the last id, so a retry would replay them
```

Restarting is a second generation, not a resume. It is a new request, billed as one, and need not
produce the same text, so the caller has to decide between that and giving up. Anthropic documents a third
option, a request that includes the partial response and asks the model to continue.[^2]
[OpenAI's background mode](https://developers.openai.com/api/docs/guides/background) does carry a
cursor, a `sequence_number` on each event, and resumes with a `starting_after` query parameter in
place of `Last-Event-ID`.

## Check it worked

The test runs every scenario, checks that the loop sends `Last-Event-ID: 3`, `6`, and `9` on its
reconnects, and asserts that a handler's exception is not retried. It proves that two 503 responses
in a row are waited out for the `Retry-After` they carry, and that a weak `ETag` never reaches
`If-Range`. A download from a server that compresses anyway starts over instead of resuming.

```bash
node --test stream.test.mjs
```

```text output
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1519.194759
```

## When it goes wrong

Every row arrives twice after the server finishes. `eventsource` reconnected after a clean close, and
the server ignored `Last-Event-ID` and started again. Send an `end` event and close the client on
it, and answer a reconnect past the last id with 204.

A command-line reader exits with code 0 after the first drop, holding three events of ten.
`eventsource` 5.1.2 [calls `unref()` on its reconnect timer](https://github.com/EventSource/eventsource/blob/v5.1.2/src/EventSource.ts)
in Node, so a process with nothing else pending exits during the wait instead of reconnecting.

```bash
node exit.mjs
```

```text output
reader and server in separate processes, server drops every 3 events
  eventsource      events 1 2 3, exited with readyState 0
                   exit code 0, requests at the server 1
  hand-rolled loop events 1 2 3 4 5 6 7 8 9 10, returned: end event
                   exit code 0, requests at the server 4
```

Await a promise that settles on the `end` event, or use a loop whose wait keeps the process alive.

A resumed file fails its checksum. The download sent `Range` without `If-Range`, so a file
replaced between attempts was spliced. Send the `ETag` with every range request.

## When not to do this

Do not resume against a server that keeps no history. `Last-Event-ID` is a request to replay
from a point, and a server that holds nothing past the live edge can only start again or skip
ahead.

Do not write the loop when `eventsource` fits. If your server honors `Last-Event-ID`, answers 204
when it is done, and never fails a reconnect with an error status, the library's behavior is the
standard's, and you test less. Your process also has to stay alive while it waits.

Do not treat a resumed model response as the original. A continuation request produces a new
generation, and an agent that stitches it onto the cut text has two answers joined at a seam.

Do not skip duplicates by ids the server reuses. The guard assumes an id names one event; a server that
numbers each connection from 1 needs a cursor instead.

## Related how-tos

- [Retry fetch calls with exponential backoff in Node.js](/howto/retry-fetch-with-backoff-in-node)

- [Consume Server-Sent Events with fetch and custom headers](/howto/consume-sse-with-fetch-and-custom-headers)

- [Upload a large file with resumable chunks](/howto/upload-a-large-file-with-resumable-chunks)

## Last verified

Verified 2026-10-15 against Node 22.22.0, `eventsource` 5.1.2, @microsoft/fetch-event-source 2.0.1,
and eventsource-parser 4.1.1. Every output block is what the command preceding it printed, against
the server in the sample directory. fetch-event-source ran under Node with a shim for `window` and
`document`. The model streams come from the sample server, which stands in for a model API.

[^1]: The [same section](https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.5) also says
how a server tells the two kinds of `If-Range` value apart. It examines the first three characters
for a double quote. A strong tag starts with one, a weak tag starts `W/"`, and a date starts with
the name of a day, which is the whole of the parsing rule.

[^2]: For Claude 4.6 and later models the documented recovery is a user message that hands back
the partial response. Its sample prompt is `Your previous response was interrupted and ended with
[previous_response]. Continue from where you left off.` The
[streaming guide](https://platform.claude.com/docs/en/build-with-claude/streaming) adds that tool
use and thinking blocks cannot be partially recovered.