DEV Community

Cover image for Surviving Dropped SSE Connections in Multi-Agent Streaming UIs
Ken
Ken

Posted on

Surviving Dropped SSE Connections in Multi-Agent Streaming UIs

Nothing triggers a 3:00 AM on-call page quite like silent stream truncation. In multi-agent systems, network drops rarely surface as clean 500 errors; the socket quietly severs mid-sentence, clears the spinner, and presents partial output as completed work. Silent state corruption enters unnoticed.

Evaluating THU-MAIC/OpenMAIC highlights this operational hazard. OpenMAIC orchestrates multi-agent collaborative classrooms over Next.js 16, React 19, and TypeScript 5[1]. When agents concurrently plan lessons, revise slides, and stream state transitions into a shared run, stream termination becomes a distributed state boundary.

The Anatomy of Silent Failure

Toy tutorials encourage dangerous shortcuts: call fetch, append chunks, and mark done = true on EOF. Production networks fail with far more nuance:

  • Reverse proxies drop idle connections or buffer chunks without flushing.
  • Mobile roaming silently drops TCP ACK packets.
  • Upstream providers terminate abruptly, stranding partial JSON.
  • An AbortController races with an in-flight chunk.

Treating EOF as implicit success makes an aborted run masquerade as completed. The fix requires strict separation: transport state must never dictate run state.

Transport manages bytes, framing, and retries. Run state manages domain lifecycle events (run.started, message.delta, run.completed, run.failed). Only an explicit terminal event can transition the UI to completed. Any EOF without that terminal event is an interrupted state.

This discipline is vital when integrating @ai-sdk/react. Decouple the durable run identifier and event cursor from rendered UI state. Inlining cursors inside message strings risks duplicate tokens or dropped stage transitions upon reconnection.

A Resilient Client Boundary

The following hook handles newline-delimited SSE payloads, monotonic cursor tracking, exponential backoff, and clean cancellation via AbortSignal. Adapt the endpoint and event schema to your OpenMAIC deployment:

import { useCallback, useEffect, useRef, useState } from "react";

type RunState =
  | "idle"
  | "running"
  | "completed"
  | "interrupted"
  | "failed";

type ClassroomEvent =
  | { type: "run.started"; runId: string }
  | {
      type: "message.delta";
      agentId: string;
      text: string;
      cursor: number;
    }
  | { type: "run.completed"; cursor: number }
  | { type: "run.failed"; message: string };

export function useClassroomRun(runId: string | null) {
  const [state, setState] = useState<RunState>("idle");
  const [events, setEvents] = useState<ClassroomEvent[]>([]);
  const cursor = useRef(0);
  const controller = useRef<AbortController | null>(null);
  const terminal = useRef(false);

  const connect = useCallback(async () => {
    if (!runId) return;

    controller.current?.abort();

    const abort = new AbortController();
    controller.current = abort;
    terminal.current = false;
    setState("running");

    for (let attempt = 0; attempt < 5 && !terminal.current; attempt++) {
      try {
        const response = await fetch(
          `/api/classrooms/${runId}/events?after=${cursor.current}`,
          {
            headers: { Accept: "text/event-stream" },
            signal: abort.signal,
          },
        );

        if (!response.ok || !response.body) {
          throw new Error(`stream ${response.status}`);
        }

        const reader = response.body
          .pipeThrough(new TextDecoderStream())
          .getReader();

        let buffer = "";

        while (true) {
          const part = await reader.read();

          buffer += part.value ?? "";

          const lines = buffer.split("\n");
          buffer = lines.pop() ?? "";

          for (const line of lines) {
            if (!line.startsWith("data:")) continue;

            let event: ClassroomEvent;

            try {
              event = JSON.parse(line.slice(5).trim());
            } catch {
              throw new Error(
                "invalid event JSON; reconnecting from last cursor",
              );
            }

            if ("cursor" in event) {
              cursor.current = Math.max(cursor.current, event.cursor);
            }

            setEvents((old) => [...old, event]);

            if (event.type === "run.completed") {
              terminal.current = true;
              setState("completed");
            }

            if (event.type === "run.failed") {
              terminal.current = true;
              setState("failed");
            }
          }

          if (part.done) break;
        }

        if (!terminal.current) {
          throw new Error("unexpected EOF");
        }
      } catch {
        if (abort.signal.aborted) return;

        if (attempt === 4) {
          setState("interrupted");
          return;
        }

        await new Promise((resolve) =>
          setTimeout(resolve, 500 * 2 ** attempt),
        );
      }
    }
  }, [runId]);

  useEffect(() => {
    return () => controller.current?.abort();
  }, []);

  return {
    state,
    events,
    reconnect: connect,
    cancel: () => controller.current?.abort(),
  };
}
Enter fullscreen mode Exit fullscreen mode

The core architectural invariant is monotonic recovery: reconnections request events strictly after the last acknowledged cursor, while incomplete chunk fragments remain isolated in the buffer.

High-frequency token bursts introduce a secondary bottleneck: re-rendering React on every chunk freezes the main thread. Accumulate deltas in a ref and flush on scheduled animation frames. A single-frame render lag beats an unresponsive UI that drops user clicks or abort signals.

Relay Configuration and Transport Realities

When deploying OpenAI-compatible upstreams or a high-throughput proxy layer, your edge gateway configuration must preserve raw SSE framing and disable intermediary buffering:

runtime: nodejs

streaming:
  content_type: text/event-stream
  cache_control: no-cache, no-transform
  connection: keep-alive
  proxy_buffering: off
  heartbeat_seconds: 15
  replay_cursor: required
Enter fullscreen mode Exit fullscreen mode

Config directives are no silver bullet. no-transform prevents proxies from altering chunks, but cannot unblock an upstream that buffers internally. Heartbeats keep firewalls alive at the expense of bandwidth. Durable replay cursors guarantee resume accuracy, but demand server-side retention budgets and de-duplication.

Connecting Vercel AI SDK chat interfaces directly to B-Lost's unbuffered SSE relay eliminates reverse-proxy buffering delays and stabilizes token delivery. Still, an external relay remains an operational dependency: verify upstream timeout behavior, heartbeat frequency, and retention policies. OpenMAIC supports OpenAI-compatible backends out of the box[1], but upstream streaming contracts demand verification under synthetic packet loss.

My production adoption test is simple: kill the client connection after a known cursor, reconnect, and verify that every event replays without duplication, the UI holds interrupted until the explicit terminal event, and cancellation halts both the browser reader and server run.

The hardest operational dilemma in multi-agent orchestration is balancing edge replay buffers against backend memory pressure when hundreds of concurrent agents stream simultaneously.

How is your team tackling stream recovery when multi-agent runs outlive volatile mobile connections? Are you holding replay logs in Redis, offloading stream reassembly to edge workers, or falling back to polling? Drop your architecture and battle scars in the comments below.

Disclosure: Multi-model API relays and compute for this evaluation are sponsored by b-lost.com β€” an AI gateway offering 0.8x official pricing, native prompt caching, and zero user-data retention. All observations reflect independent developer testing.

Sources

[1] THU-MAIC/OpenMAIC README

Top comments (0)