AI agents

Reconnecting a Stream Without Duplicating Tokens

Antonio Evans10 min read

A split-panel technical diagram in flat vector style.
Key points

Key points

  1. 1Give every streamed chunk a monotonically increasing sequence number, because without it "resume from where we left off" has no anchor.
  2. 2Issue a resume token that encodes the last sequence the client fully processed, and accept it on reconnect as either the SSE Last-Event-ID header or an explicit cursor parameter.
  3. 3Hold the unacknowledged tail in a per-stream ring buffer keyed by sequence, so a reconnect can replay only chunks with a higher number and then hand over to live delivery.
  4. 4When the requested sequence has already been evicted, return a 409 with a restart instruction instead of silently skipping tokens.

The failure you are actually fixing

A dropped stream fails in two directions, and they look different to the user. In one case the client renders the same token twice: the server wrote chunk 151, the connection died before the write was confirmed, and the reconnect replays it. In the other case the client is simply missing chunks 151 and 152 and keeps rendering from 153 onward, which produces a response with a hole in the middle and no error anywhere. Ably's write-up on resume tokens makes the point that duplicates happen when the connection drops after delivery but before acknowledgment, while gaps happen when later messages arrive without the earlier ones.

The companion client-side article covers how the client machine stores the last processed position. This one is about the server contract: what the API has to carry so a reconnecting client can rejoin mid-object and receive exactly the missing tail.

There is a reason this matters more for token streams than for ordinary event feeds. A partial LLM response is not a sequence of independent events; it is a document being assembled. If your UI parses the stream incrementally, a duplicated token corrupts a string or a number, and a gap corrupts structure. The analysis of streaming JSON parsers describes why JSON.parse is all-or-nothing and why teams end up writing a stateful partial parser: once you have one, the parser's state is exactly the thing you must be able to rehydrate after a drop. Sequence numbers are how you tell the parser where it stopped.

Sequence numbers: the anchor for everything else

Every chunk the server emits gets a sequence number assigned at one place in the pipeline, immediately before it enters the per-stream buffer. Three rules make the numbering usable:

  1. One writer per stream. If two goroutines, tasks or threads can append to the same stream, a lock or a single-writer queue must serialize the assignment. Concurrent publishers that each grab "the next number" produce commit-order disagreements, which is precisely the invariant an SSE test should check: each accepted ID is greater than the previous one (SSE reconnect testing).
  2. Monotonic, not necessarily contiguous. Sparse numbering (gaps from filtered chunks, for example) is fine as long as the server documents that replay is "strictly greater than the supplied number." Contiguous numbering is easier to test and easier to reason about in logs, so prefer it unless you have a reason not to.
  3. The number travels twice. Put it in the SSE id: field and in the JSON body of the chunk. The Last-Event-ID reference notes the header value is a UTF-8 string matching the id field, and a widely cited Stack Overflow answer on lost SSE events recommends including the same identifier inside the data packet as well, so that non-SSE transports and client-initiated reconnects can carry it (SO answer on Last-Event-ID).

A chunk on the wire looks like this:

id: 151
event: token
data: {"seq":151,"delta":" production","done":false}

The blank line is part of the protocol, not decoration: SSE dispatches an event only when a blank line terminates it, and a client that records the id the moment the line arrives rather than at dispatch will reconnect from a position it never actually processed. That off-by-one is the single most common duplicate-token bug in hand-rolled clients, and it is worth an explicit test at the parser level (SSE reconnect testing).

The resume token

The resume token is the client's claim about what it has. Keep it dumb and verifiable: an opaque string that the server can decode into (stream_id, last_seq). Do not put secrets in it and do not sign it if you can avoid it; a UUID stream identifier plus an integer sequence is guessable only by someone who already knows the stream ID, and stream IDs should not be authorization tokens anyway.

Two delivery paths need to work:

  • Automatic, for browsers. When an EventSource connection drops, the browser reconnects and sends the stored last event ID in the Last-Event-ID request header, so the server can resume without any client code (Last-Event-ID). This is the cheapest possible client contract: it works even if your client code has bugs, because the browser does the remembering.
  • Explicit, for everything else. Mobile clients, fetch-based readers and SDKs pass ?cursor=<token> on the reconnect request. Ably points out that SSE's built-in header is only the handshake piece of a four-piece problem, and that on distributed infrastructure a reconnecting client can land on an instance that has never heard of the session. The cursor parameter is also what lets a client persist its position across an app restart.

If both are present, the explicit cursor wins and should be validated against the header. Log a mismatch rather than erroring on it, because a mismatch usually means the client's stored position is fresher than the browser's.

Reconnect inputWho sends itServer behavior
No tokenFirst connectionStart at the current position, stream live from seq 1
Last-Event-ID: 151Browser EventSourceReplay seq 152 onward from the buffer, then live
?cursor=<token> decoding to 151SDK, mobile, custom clientSame as above, explicit wins over header
Token for an evicted sequenceAny client409 with restart instruction
Token that does not decodeAny client400, bad cursor
Token for a different stream IDAny client403 or 404, never a silent fresh start

That last row is the one people forget. A cursor from stream A presented on stream B must fail loudly. Silently starting fresh looks like a resume to the client and produces a duplicated response.

The ring buffer and its numbers

The buffer is a fixed-capacity circular structure per stream, holding the unacknowledged tail. You do not need per-token acknowledgments: humans read slower than models generate, so treat "delivered to a live connection" as acknowledged and let the buffer cover only the gap between server writes and client receipt.

Sizing is arithmetic, not vibes. Take the worst realistic case:

  • Longest response you intend to survive: 4,000 tokens.
  • Bytes per chunk, including SSE framing and JSON wrapper: about 45 bytes for a small delta, call it 60 with overhead.
  • That is roughly 240 KB of buffered payload for a full response retained from the start.

Now the decision: do you keep the whole response, or only the last N chunks? Keep the whole thing while the stream is open, because the interesting failure (tab suspended for two minutes, connection reaped by a proxy) can happen at any point, and a buffer that only holds 500 chunks may have evicted the position the client needs. The Sentry Spotlight issue that specifies this feature for their sidecar settles on a 500-event circular buffer and then explicitly lists buffer overflow as an edge case to handle, with the client expected to do a full state reset (Sentry Spotlight #1095). That is the right shape: bounded buffer, documented bound, loud failure past it.

Retention after the stream ends is separate. Keep the buffer for a fixed grace period after the final chunk (a few minutes is generous for a mid-response drop and cheap in memory), then evict. Track two counters per stream: highest_seq and oldest_retained_seq. Any resume request below oldest_retained_seq is a 409.

The HTTP contract

Here is the shape that keeps a reconnecting client correct.

First request.

POST /v1/stream HTTP/1.1
Content-Type: application/json

{"prompt": "...", "stream": true}

Respond with Content-Type: text/event-stream, and emit a first control event carrying the stream ID and the cursor seed:

id: 1
event: start
data: {"stream_id":"9f2c...","seq":1}

Reconnect, browser style. The EventSource cannot replay a POST, so a browser-reconnectable stream needs a GET endpoint keyed by stream ID:

GET /v1/stream/9f2c... HTTP/1.1
Last-Event-ID: 151

Reconnect, SDK style.

GET /v1/stream/9f2c...?cursor=eyJzIjoiOWYyYyIsImwiOjE1MX0 HTTP/1.1

Server-side handling, in order. Read the cursor (explicit parameter, else Last-Event-ID). Decode it. If it does not decode, return 400. If it names a different stream, return 403. Look up the buffer. If the requested sequence is older than oldest_retained_seq, return 409 with a body naming the oldest available sequence, so the client can decide between a full restart and fetching a snapshot. Otherwise write every retained chunk with seq > last_seq in order, then attach the connection to the live feed, with an overlap check so a chunk emitted during replay is not sent twice.

The 409 body.

{"error":"cursor_expired","oldest_available":812,"action":"restart"}

Clients that receive this should discard the partial and start a fresh stream, which the companion article handles. The important property is that the failure is explicit: a 409 is far better than a 200 that quietly starts at a later point, because a silent skip is indistinguishable from a successful resume on the client side.

Heartbeats. Send SSE comment lines (: ping) on an interval shorter than your proxy's idle timeout. Comments do not dispatch events and must never advance the client's stored ID, so they cannot create phantom resume positions (SSE reconnect testing). The classic production incident here is a load balancer closing idle connections at a fixed interval, which is exactly the situation described in the Stack Overflow question about lost events during five-minute connection recycling (SO #58335110). Heartbeats plus a buffer turn that incident into a routine reconnect.

A small server sketch

The logic fits in a few dozen lines in any language. This is TypeScript-style pseudocode for the reconnect handler, written to show the order of checks rather than a framework integration.

function resume(streamId: string, cursor: string, headers: Headers, res: Response) {
  const decoded = decodeCursor(cursor || headers.get('last-event-id') || '');
  if (!decoded) return send(res, 400, { error: 'bad_cursor' });
  if (decoded.streamId !== streamId) return send(res, 403, { error: 'wrong_stream' });

  const stream = registry.get(streamId);
  if (!stream) return send(res, 404, { error: 'unknown_stream' });
  if (decoded.seq < stream.oldestRetainedSeq) {
    return send(res, 409, {
      error: 'cursor_expired',
      oldest_available: stream.oldestRetainedSeq,
      action: 'restart'
    });
  }

  openEventStream(res);
  const replayed = new Set<number>();
  for (const chunk of stream.buffer.after(decoded.seq)) {
    writeEvent(res, chunk.seq, chunk.event, chunk.data);
    replayed.add(chunk.seq);
  }
  stream.subscribe((chunk) => {
    if (replayed.has(chunk.seq)) return;   // overlap guard
    writeEvent(res, chunk.seq, chunk.event, chunk.data);
  });
}

Two details carry most of the correctness. The overlap guard prevents the double-send that happens when a live chunk lands between buffer read and subscription, and the strict after(decoded.seq) (strictly greater, never greater-or-equal) prevents the boundary duplicate. An SSE test invariant worth writing down: the first resumed ID must be greater than the supplied Last-Event-ID (SSE reconnect testing).

Cursor encoding, and why not just use an integer

You could accept a bare integer as the cursor. The reasons to wrap it in a token are practical:

  • The token can carry the stream ID, so a cursor from another stream fails validation instead of landing on the wrong buffer.
  • The token can carry a format version, so you can change the encoding later without breaking deployed clients.
  • The token is opaque, so clients stop treating it as a number they can increment, which they will do, with predictable consequences.

Encode a small JSON object as base64: {"v":1,"s":"<stream_id>","l":151}. Keep the field names short because the token travels in a query string on every reconnect. Validate the version, then the stream ID, then the sequence. The sequence field should be the last sequence the client dispatched to its parser and rendered, not the last one it received, which is the same distinction that makes the SSE id field safe.

One more decision: is the cursor per stream or per conversation? Per stream is simpler and matches the lifetime of the ring buffer. If a conversation spans multiple streamed responses, the client stores a cursor per response and passes it only when resuming that response.

Testing the contract before users find it

Three levels of test cover this, and all three catch different defects: a parser-level test for framing, an in-process integration test for replay state, and a test through the same reverse proxy used in production (SSE reconnect testing). The integration test is the one that catches the bugs a code review misses. Drive it from a script that opens a stream, records the first N chunks, kills the connection, reconnects with the recorded cursor, and asserts that the concatenation of both halves equals the uninterrupted response byte for byte. Then loop N from 0 to the final sequence; the run where N equals the final sequence catches the "reconnect after completion" case, and the run where N is one below oldestRetainedSeq catches the 409 path.

Checklist for the server side:

CheckPass condition
IDs strictly increase on one streamNo accepted ID is less than or equal to its predecessor
Resume is exclusiveFirst replayed ID is greater than the cursor
No gap in the combined halvesJoined response equals the uninterrupted response
Incomplete frames do not advance the cursorReconnect uses the last blank-line-terminated event
Heartbeats are not eventsComment lines never appear as received chunks
Expired cursor fails loudly409 with oldest_available, no silent skip
Wrong-stream cursor rejected403, no fresh stream started
Overlap guard holdsNo chunk written twice when replay meets live

Error handling on the client side follows from this contract. Treat 409 as "restart," 400 as a client bug worth logging, and 5xx or a timeout as "retry with the same cursor after backoff." Retrying with an unchanged cursor is safe precisely because replay is idempotent by sequence: the point of the whole design is that the client never has to guess what the server already sent (error handling and retries for LLM calls).

If you are building the model service behind this contract rather than the transport, the other half of the problem is that the token generator itself must support these semantics. Quanta Inference (https://quantaintelligence.ai/inference) exposes text, image, video, audio and embedding models through one OpenAI-compatible API key with prepaid US dollar pricing, so you can put your own sequenced, buffered stream layer in front of any of them without rewiring providers. The resume contract above is yours to own either way; the buffer, the cursor and the 409 are application logic, not model infrastructure.

FAQ

FAQ

What is the difference between a sequence number and a resume token?

The sequence number is attached by the server to each chunk and is what the buffer is keyed by. The resume token is what the client holds and sends back; it encodes the stream identity and the last sequence the client fully processed. The token is deliberately opaque so clients cannot synthesize or increment it.

Should the server replay the chunk the client already has?

No. Replay strictly chunks whose sequence is greater than the cursor, never greater-or-equal. The greater-or-equal version produces exactly one duplicate at the boundary of every reconnect, which is the hardest kind of duplicate to notice in testing because it only appears after a drop.

What should happen when the cursor is older than the buffer?

Return a 409 with the oldest available sequence and a restart instruction. A 200 response that starts at a later position is a silent gap from the client's point of view, and the client has no way to distinguish it from a correct resume. A loud failure lets the client restart from scratch with the partial marked as unusable.

Does SSE handle reconnection for me?

Partly. A browser EventSource reconnects automatically and sends the stored Last-Event-ID header, so the handshake is free. Everything else is yours to build: the buffer, the replay query, the expiry behavior, and the cursor for clients that are not browsers or that reconnect after an app restart.

How large should the ring buffer be?

Size it from your longest intended response, not from your average one. Roughly 60 bytes per chunk including SSE framing at 4,000 tokens is about 240 KB per open stream, which is cheap enough to keep the full response while the stream lives. Bound it anyway, publish the bound, and make the expiry path an explicit 409 so the bound is a documented contract rather than a surprise.

Sources

Sources

  1. Resume tokens and last-event IDs for LLM streaming
  2. Streaming JSON Parsers: The Gap Between Tokens and ...
  3. SSE Testing Reconnect Last Event ID: Prevent Data Loss
  4. Last-Event-ID - Expert Guide to HTTP headers
  5. Some of Server Sent events lost while EventSource ...
  6. Implement SSE Last-Event-ID Support for Event Stream ...
  7. Error Handling & Retries: Making LLM Calls Reliable
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles