n4nAI

SSE reconnection with Last-Event-ID for long LLM completions

Hands-on guide to SSE reconnection with Last-Event-ID for long LLM completions: resume dropped streams without token duplication, including client code.

n4n Team4 min read844 words

Audio narration

Coming soon — every post will get a voice note here.

Long LLM completions over Server-Sent Events fail when a proxy times out or the client switches networks. Implementing sse reconnection last-event-id llm logic lets you resume a stream from the last delivered token instead of restarting the request and paying for duplicated generation. This how-to walks through the server and client changes required to make that work against an OpenAI-compatible streaming endpoint.

Why naive retries waste tokens

A naive client that catches a fetch error and re-issues the chat completion request will get a fresh stream starting from token zero. For a 2,000-token completion that dies at token 1,800, you just burned 1,800 tokens of duplicate compute and your UI shows a jarring reset. The SSE protocol already defines a clean recovery mechanism: every event can carry an id, and the client can send Last-Event-ID on reconnect. The hard part is that most LLM providers are stateless—they don’t buffer your in-flight completion—so resumption requires either a stateful proxy or client-side checkpointing of delivered text.

Step 1: Emit proper SSE frames with id and retry

Your streaming endpoint must assign a monotonically increasing id to each chunk and send the retry: directive once at the start. The OpenAI streaming format wraps content in data: lines; you keep that but prefix an id:.

# FastAPI-style generator (illustrative SSE framing)
async def stream_completion(request):
    yield "retry: 2000\n\n"
    for i, chunk in enumerate(generate_tokens(request.prompt)):
        # chunk is raw token text
        payload = json.dumps({"choices": [{"delta": {"content": chunk}}]})
        yield f"id: {i}\n"
        yield f"data: {payload}\n\n"

The id must be a string the server can later map to an offset. For LLM streams, the chunk index is sufficient if the server buffers the sequence. If you front requests with a gateway, ensure it does not strip the id field—some middleboxes rewrite SSE.

Step 2: Parse the stream and persist the last ID

On the client, use fetch with a streaming body reader. EventSource does not allow custom headers, so you cannot set Last-Event-ID manually; you must parse SSE yourself or use a polyfill that supports the header. Below is a minimal parser that records the latest ID.

let lastEventId = null;
const textDecoder = new TextDecoder();

async function readSSE(reader, onToken) {
  let buffer = "";
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += textDecoder.decode(value, { stream: true });
    const events = buffer.split("\n\n");
    buffer = events.pop();
    for (const evt of events) {
      const lines = evt.split("\n");
      let id, data = "";
      for (const line of lines) {
        if (line.startsWith("id: ")) id = line.slice(4);
        else if (line.startsWith("data: ")) data += line.slice(6);
      }
      if (id) lastEventId = id;
      if (data) {
        const json = JSON.parse(data);
        const token = json.choices?.[0]?.delta?.content;
        if (token) onToken(token);
      }
    }
  }
}

Persist lastEventId to memory (or sessionStorage for tab restores). This is the core of sse reconnection last-event-id llm resilience: without storing the ID, you have nothing to send back.

Step 3: Reconnect using the Last-Event-ID header

When the stream breaks, open a new request and pass the saved ID in the Last-Event-ID header. The server should seek to that offset and emit only subsequent chunks.

async function connect(prompt, onToken) {
  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${API_KEY}`,
  };
  if (lastEventId) headers["Last-Event-ID"] = lastEventId;

  const res = await fetch("/v1/chat/completions", {
    method: "POST",
    headers,
    body: JSON.stringify({
      model: "gpt-4o-mini",
      messages: prompt,
      stream: true,
    }),
  });

  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const reader = res.body.getReader();
  await readSSE(reader, onToken);
}

Wrap this in a reconnect loop with exponential backoff. Start at 500 ms, double up to 8 s, and add jitter.

async function withReconnect(prompt, onToken) {
  let attempt = 0;
  while (true) {
    try {
      await connect(prompt, onToken);
      return; // stream completed cleanly
    } catch (err) {
      attempt++;
      const delay = Math.min(8000, 500 * 2 ** attempt) + Math.random() * 300;
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}

Browser limitation with EventSource

Native EventSource automatically sends Last-Event-ID on reconnect, but only if the browser initiated the reconnect itself after a network drop—and it cannot send the Authorization header or a JSON body. For LLM APIs you need POST with a bearer token, so fetch plus manual parsing is the pragmatic path.

Step 4: Make the server replay from the offset

Stateless LLM endpoints will ignore Last-Event-ID and regenerate from scratch. You need a stateful layer that buffers the completion keyed by a stream ID (not just the chunk index, because the same index may appear across different requests). Two patterns work:

  1. Server-side buffer: The proxy assigns a stream_id, maps Last-Event-ID to an in-memory ring of sent chunks, and replays missing ones before continuing live generation.
  2. Client-side checkpoint: The client sends the full prompt plus the already-received text as a prefix parameter (if the model supports prompt continuation). This avoids server state but costs input tokens for the prefix.

If you front your models with an OpenAI-compatible gateway such as n4n.ai, confirm it buffers the completion and respects Last-Event-ID; it already honors client routing directives and forwards provider cache-control hints, but replay is a separate concern you must validate. Without buffer support, sse reconnection last-event-id llm handling degrades to a full restart.

Step 5: Verify with a forced disconnect

You cannot claim reconnection works until you break the pipe on purpose. Use a local proxy that drops the connection after N bytes, or simply kill your Wi-Fi mid-stream in a test harness.

A minimal Node verification script:

let received = [];
let firstId = null;

await withReconnect(
  [{ role: "user", content: "Write a 500-word essay." }],
  (tok) => {
    received.push(tok);
    if (!firstId) firstId = lastEventId;
  }
);

// After forced disconnect at ~30%:
console.log("Chunks after reconnect start:", received.length);
console.log("Last ID at drop:", firstId);

Success criteria:

  • The concatenated string after recovery is a strict suffix of the full completion (no repeated sentences).
  • lastEventId advances monotonically; the reconnect request includes the header (inspect DevTools network tab).
  • Token usage from your metering (or provider bill) shows one generation, not two. If you see duplicate input+output tokens, the server ignored the offset.

For a Python client, the same logic applies with requests streaming:

import json, requests

last_id = None
def stream():
    global last_id
    headers = {"Authorization": f"Bearer {KEY}"}
    if last_id:
        headers["Last-Event-ID"] = last_id
    with requests.post(URL, json=payload, headers=headers, stream=True) as r:
        for line in r.iter_lines():
            if line.startswith(b"id: "):
                last_id = line[4:].decode()
            elif line.startswith(b"data: "):
                data = json.loads(line[6:])
                yield data["choices"][0]["delta"].get("content", "")

Run this in a loop, unplug the network after a few seconds, and assert the yielded text has no overlap with a pre-disconnect snapshot.

Edge cases that bite in production

Multiline data: fields are legal; your parser must accumulate until a blank line, not split on every \n. The retry: value is a hint—clients can ignore it. If your gateway does automatic fallback when a provider is degraded, a reconnect may hit a different upstream model; pin the model name and stream ID so the replay buffer stays valid. Finally, set a max reconnect age: if lastEventId is older than your buffer TTL, fail loud rather than silently regenerating.

Solid sse reconnection last-event-id llm support is not free, but the token savings on long outputs and the UX win of uninterrupted text make it worth the stateful proxy or checkpointing work.

Tagsssereconnectionstreamingllm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All websockets vs sse for llm streaming posts →