An export streams 10,000 rows to your agent and the connection drops at row 3,400. The retry asks for the stream from the start, so rows 1 to 3,400 are processed twice. A deploy ends the response cleanly partway through, and one client library takes the clean close as the end of the data and returns what it has.
What you get
You will end up with a loop that resumes an SSE stream from the last event id and a download from the last byte, and stops on an explicit end. This is for you if your code reads a stream that can be cut.
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 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 | 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 | 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 | 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.
node compare.mjs
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 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.
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 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.
node demo.mjs
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
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.
node demo.mjs
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 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.
node --test stream.test.mjs
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
in Node, so a process with nothing else pending exits during the wait instead of reconnecting.
node exit.mjs
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
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.
Footnotes
-
The same section also says how a server tells the two kinds of
If-Rangevalue apart. It examines the first three characters for a double quote. A strong tag starts with one, a weak tag startsW/", and a date starts with the name of a day, which is the whole of the parsing rule. ↩︎ Back to text -
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 adds that tool use and thinking blocks cannot be partially recovered. ↩︎ Back to text