n4nAI

Debugging dropped SSE connections in production LLM apps

Practical steps to diagnose and fix dropped Server-Sent Events streams in production LLM apps, from proxy timeouts to client reconnection.

n4n Team4 min read844 words

Audio narration

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

A streaming LLM response that vanishes mid-token is a terrible user experience, yet it’s common when you ship Server-Sent Events to production. Debugging dropped SSE connections llm pipelines requires separating transport failures from model provider hiccups, and knowing where your stack silently terminates idle streams. SSE is the right choice for one-way token flow over HTTP/2, but it inherits every proxy timeout and buffer rule your HTTP traffic already hits. This guide walks through a concrete remediation sequence you can apply the same day.

Step 1: Reproduce the drop with a minimal client

Before changing infrastructure, confirm the failure is real and not a bug in your UI. Use curl with no buffering and a verbose timeout:

curl -N -v --max-time 120 \
  -H "Accept: text/event-stream" \
  https://your-llm-api/v1/chat/stream

If the stream closes before the model emits its final [DONE] event, note the byte count and TCP state. A sudden transfer closed with outstanding read data remaining from curl means the server or a proxy sent FIN prematurely.

Write a five-line Python consumer to rule out browser-specific issues:

import requests
r = requests.get("https://your-llm-api/v1/chat/stream", stream=True, timeout=120)
for line in r.iter_lines():
    if line:
        print(line.decode())

If both curl and Python drop at the same point, the problem is server-side or intermediary. Debugging dropped sse connections llm starts with this baseline. If the browser fails but these don’t, suspect EventSource restrictions or a service worker stripping headers.

Capture a packet trace during the drop to see who sends FIN:

sudo tcpdump -A -s 0 'host your-llm-api and tcp port 443' -w sse.pcap

Open it in Wireshark and filter for tcp.flags.fin==1. The IP that sends the first FIN is your culprit.

Step 2: Inspect proxy and gateway timeouts

Most production SSE failures happen at the reverse proxy, not the app server. Nginx defaults to proxy_read_timeout 60s. LLM generations routinely exceed that. Set explicit long timeouts and disable buffering:

location /v1/chat/stream {
    proxy_pass http://app:8000;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_read_timeout 3600s;
    proxy_buffering off;
    chunked_transfer_encoding on;
}

Cloud load balancers (ALB, Cloudflare, Fastly) have similar idle thresholds—often 60–300s. Raise them or inject frequent data. If you front calls with a gateway such as n4n.ai, its automatic fallback covers provider degradation, but your link to the gateway still traverses your proxy; the timeout fix above applies to your edge.

Verify with curl -N through the exact production hostname. If the drop disappears, you’ve found it. Don’t forget TLS termination points: a misconfigured Envoy sidecar with a 30s idle timeout will kill SSE just as fast. Check every hop with curl -v against internal URLs.

Step 3: Verify server flush behavior

Your Python or Node server must flush each chunk immediately. FastAPI’s StreamingResponse does this, but any middleware that enables gzip without flush can stall output. Explicitly disable response compression for the stream route.

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio, json

app = FastAPI()

@app.post("/v1/chat/stream")
async def chat(request: Request):
    async def gen():
        for tok in ["Hello", " world", "!"]:
            if await request.is_disconnected():
                return
            yield f"data: {json.dumps({'token': tok})}\n\n"
            await asyncio.sleep(0.1)
    return StreamingResponse(gen(), media_type="text/event-stream")

In Node.js, avoid res.write() without res.flushHeaders() and ensure you don’t use a body parser that buffers. Test locally with uvicorn and the curl from Step 1. If local works but prod fails, the difference is almost always the proxy or TLS terminator. Debugging dropped sse connections llm without checking local-first wastes hours.

Step 4: Implement client-side reconnection with Last-Event-ID

Browsers’ native EventSource cannot send Authorization headers or POST bodies, so most LLM apps use fetch with a reader. You must handle truncation by reconnecting and passing the last seen event ID.

async function streamWithRetry(url: string, body: any, lastId?: string) {
  const headers: Record<string, string> = { "Content-Type": "application/json" };
  if (lastId) headers["Last-Event-ID"] = lastId;
  const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const parts = buffer.split("\n\n");
    buffer = parts.pop() ?? "";
    for (const evt of parts) {
      if (evt.startsWith(":")) continue;
      const idMatch = evt.match(/^id: (\S+)/m);
      if (idMatch) lastId = idMatch[1];
      const data = evt.replace(/^data: /, "").trim();
      if (data === "[DONE]") return;
      // append token to UI
    }
  }
  // if we exit loop without [DONE], reconnect with backoff
  await new Promise(r => setTimeout(r, 500));
  return streamWithRetry(url, body, lastId);
}

Server must emit id: fields per event for this to work. Debugging dropped sse connections llm without event IDs forces full regeneration on reconnect—wasteful and slow. Use an incrementing counter or token hash as the ID.

Step 5: Add server-side heartbeat comments

Intermediaries may kill connections that show no bytes for 30–60s even if proxy_read_timeout is high (some L4 filters count packets, not time). Send SSE comment lines every 15s:

async def gen():
    import asyncio
    try:
        for i, tok in enumerate(tokens()):
            if i % 20 == 0:
                yield ": ping\n\n"
            yield f"id: {i}\ndata: {tok}\n\n"
            await asyncio.sleep(0.05)
    except asyncio.CancelledError:
        return

A comment line (starting with :) is ignored by clients but keeps the TCP socket active. This alone fixes a large class of silent drops where the provider is slow but not dead.

Step 6: Monitor stream truncation

You cannot fix what you don’t measure. Log when a stream ends without [DONE]. In your server generator, catch GeneratorExit or check is_disconnected():

@app.post("/v1/chat/stream")
async def chat(request: Request):
    async def gen():
        try:
            for tok in tokens():
                yield f"data: {tok}\n\n"
            yield "data: [DONE]\n\n"
        except asyncio.CancelledError:
            log.warning("client disconnected before DONE")
            raise
    return StreamingResponse(gen(), media_type="text/event-stream")

Track the rate of truncated streams per provider. If one model endpoint fails more often, the issue is upstream, not your transport. Debugging dropped sse connections llm at scale means alerting when truncation exceeds 1% of total streams. Export a Prometheus counter sse_truncations_total and graph it by route.

Step 7: Absorb provider degradation with fallback routing

Even with perfect transport, providers rate-limit or 500. A gateway that automatically falls back when a provider is degraded removes a whole category of connection resets. Configure your client to hit a single OpenAI-compatible endpoint and let the gateway route. Your SSE code stays identical; only the base URL changes.

If you self-host the fallback logic, wrap the streaming call in a retry that swaps base_url on connection error. Keep the Last-Event-ID so the user doesn’t lose context. Test the fallback by blocking one provider’s IP and confirming the stream completes via the second.

Verify success

Run a load test with 100 concurrent streams lasting 2+ minutes each:

for i in {1..100}; do
  curl -N -o /dev/null -w "%{http_code} %{size_download}\n" \
    https://your-prod/v1/chat/stream &
done
wait

Success criteria:

  • Every curl exits with 200 and downloads the full expected byte count.
  • No transfer closed errors in client logs.
  • Server metrics show truncation rate < 0.1%.
  • Heartbeat comments appear in packet captures (tcpdump -A port 443 | grep ping).

If all hold under load, your SSE pipeline is production-solid. Debugging dropped sse connections llm is rarely about the protocol; it’s about idle timers, buffers, and missing heartbeats. Fix those three and your token streams will survive.

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