End-to-End Coordination

The full round-trip of one sendMessage from click to DOM update / the full reference of 25 UIMessageChunk types / the four hops of bidirectional abort / the end-to-end resume implementation pattern / four error path classes / SSE environment-layer traps

Why this page exists

The previous five pages are split by side — four on the send side, one on the receive side. But the most painful real-world issues are vertical, cutting across both sides — “what actually happens between click and DOM update”, “why did the client stop but the server is still burning tokens”, “how does resume really land in production”. None of the five pages answers these completely on its own.

This page stitches the five together into an end-to-end view:

  • The full call chain of a single sendMessage (client + server + transport)
  • Every UIMessageChunk type’s meaning and trigger (the single source of truth for the wire protocol)
  • Three genuinely two-sided protocols — abort / resume / error — that cannot be debugged from one side only

Nothing from the first five pages is repeated; this page only covers “how the pieces fit together”.

The full round-trip of one sendMessage

Starting from the most common scenario: the user types in a textarea and clicks submit. Below is the complete call chain from click to DOM update.

sequenceDiagram actor U as User participant UI as React Component participant Hook as useChat participant Chat as AbstractChat participant T as DefaultChatTransport participant F as fetch / Browser participant R as Server Route participant ST as streamText / Agent participant M as LLM Provider rect rgba(187, 247, 208, 0.18) Note over U,Chat: ① click → client state U->>UI: click submit UI->>Hook: sendMessage({ text }) Hook->>Chat: sendMessage(message, opts) Chat->>Chat: pushMessage(userMsg)<br/>status = submitted end rect rgba(254, 243, 199, 0.18) Note over Chat,R: ② network handoff Chat->>T: sendMessages({ trigger, chatId,<br/>messages, abortSignal }) T->>F: fetch POST /api/chat<br/>body: { messages, ... }<br/>signal: abortSignal F->>R: HTTP POST (SSE) R->>R: await convertToModelMessages(uiMessages) R->>ST: streamText({ model, messages,<br/>abortSignal: request.signal }) ST->>M: provider call (streaming) Note over R,F: HTTP connection handshaken — first byte not flushed yet Note over Chat: status still submitted end rect rgba(199, 210, 254, 0.18) Note over Chat,M: ③ streaming back M-->>ST: first chunk ST-->>R: text-start / text-delta ... R-->>F: SSE frame: data: {...} F-->>T: ReadableStream(Uint8Array) T->>T: processResponseStream()<br/>→ ReadableStream(UIMessageChunk) T-->>Chat: stream of UIMessageChunk Chat->>Chat: first chunk arrives<br/>status = streaming end loop every chunk Chat->>Chat: dispatch by type<br/>(parts updated incrementally) Chat-->>Hook: notify subscribers Hook-->>UI: re-render (throttled) UI-->>U: DOM update end rect rgba(52, 211, 153, 0.18) Note over U,M: ④ finish ST-->>R: finish chunk R-->>T: SSE stream ends T-->>Chat: finish Chat->>Chat: status = ready<br/>onFinish({ message, ... }) Hook-->>UI: final re-render end

Where each hop lives in the source:

HopLocationKey call
UI → hookyour componentsendMessage({ text })
hook → Chat@ai-sdk/react dist/index.jsAbstractChat.sendMessage
Chat → transportai@6.0.134 AbstractChat.makeRequesttransport.sendMessages(opts)
transport → wireDefaultChatTransport.sendMessagesfetch(api, { method: 'POST', body, signal })
wire → serveryour route handlerreq.json()messages: UIMessage[]
message bridgeai@6.0.134 convertToModelMessagesUIMessage[]ModelMessage[]
model callai@6.0.134 streamText (dist:6441)returns StreamTextResult
to UI streamStreamTextResult.toUIMessageStream (dist:7839)fullStream.pipeThrough(TransformStream)
SSE wraptoUIMessageStreamResponse / createUIMessageStreamResponsenew Response(stream, { headers: sseHeaders })
server → wireNode / Bun / Edge runtimeHTTP stream write
wire → clientHttpChatTransport.processResponseStreamparses SSE data: ...\n\n frames → UIMessageChunk
chunk → stateAbstractChat internal chunk dispatchpushMessage / replaceMessage
state → DOMReact subscription + throttleuseSyncExternalStore + re-render

UIMessageChunk full-type reference

This is the only wire protocol between client and server — every SSE frame parses into one of these types. The complete union is at ai@6.0.134/dist/index.d.ts:2158-2273.

Grouped by role (7 groups):

1. Stream lifecycle control

typeWhen emittedClient handling
startFirst chunk of the stream; carries messageId and message-level metadataCreate a new assistant message object
start-stepEvery agent step startsOpen a new step on the current message
finish-stepEvery agent step endsTriggers L3 onStepFinish; step boundary marker
finishStream ends; carries finishReasonTriggers L3 onFinish; status → ready
abortServer received an abort signalonFinish({ isAbort: true, ... })
errorServer caught an exception; errorText comes from the onError return valueonError(new Error(errorText)) + isError: true

2. Text content

typeMeaningClient handling
text-startA text part starts, id tags this partPush a TextUIPart into message.parts, text=""
text-deltaIncremental chunkAppend to the corresponding id’s TextUIPart.text
text-endThis text part endsMark complete (optional: trigger tree-shake)

Same id for start/delta/end = one text segment. Different ids = different segments (e.g. model emits a text block → calls a tool → emits another text block = two text parts with different ids).

3. Reasoning / Thinking

typeMeaningClient handling
reasoning-start / reasoning-delta / reasoning-endAnthropic thinking block / OpenAI reasoning traceSame structure as text; collapsible in UI

4. Tool calls

typeMeaningClient handling (ToolUIPart.state)
tool-input-startTool call starts, params streaming instate = 'input-streaming'
tool-input-deltaParams text delta (a chunk of the JSON blob)Accumulate into the “generating” form of input
tool-input-availableParams completestate = 'input-streaming' → 'input-available', input parsed
tool-input-errorParams parse or schema validation failedstate = 'output-error', errorText injected
tool-approval-requestTool requires human approval (rare)state = 'approval-requested'
tool-output-availableTool execution completestate = 'output-available', output injected
tool-output-errorTool execution failedstate = 'output-error'
tool-output-deniedApproval deniedstate = 'approval-responded' (denied)

dynamic-tool variants: if the tool is runtime-discovered (MCP / dynamic registration), the chunk carries dynamic: true, and the client should render it as a DynamicToolUIPart (type: 'dynamic-tool') rather than the static tool-${name} form.

5. Sources / Files

typeMeaning
source-urlA cited URL (RAG / search)
source-documentA cited document (PDF / markdown)
fileA file produced as part of this response (image / audio / …)

6. Custom business events

{
  type: `data-${string}`;   // e.g. data-progress, data-todos-update, data-run-init
  data: unknown;            // business-layer-defined shape
  id?: string;
  transient?: boolean;      // true = client only sees it in onData, not written to message.parts
}

Full detail of this protocol: UI Stream Orchestration → Custom data-* event protocol and useChat → onData.

7. Message-level metadata

typeMeaning
message-metadataAttach metadata to the current message (message-level, not part-level)

Cheat sheet

A total of 25 types (counting data-${string} as one):

GroupCountTypes
Lifecycle control6start / start-step / finish-step / finish / abort / error
Text3text-start / text-delta / text-end
Reasoning3reasoning-start / reasoning-delta / reasoning-end
Tools8tool-input-start / tool-input-delta / tool-input-available / tool-input-error / tool-approval-request / tool-output-available / tool-output-error / tool-output-denied
Sources / Files3source-url / source-document / file
Custom1data-${string}
Message metadata1message-metadata

Bidirectional abort — the easiest hop to drop

The user clicks stop, or closes the tab, or their network drops. That signal must propagate all the way from client through server to LLM provider. Drop any single hop and you get:

  • Client says “stopped”, but the server is still burning tokens
  • LLM call completes and writes to DB, but the user is already gone
  • Long tool operations (shell commands, HTTP requests) can’t be aborted

The full four-hop chain:

Abort signal — 4-hop bidirectional propagation Drop any hop and client says "stopped" while server keeps burning tokens 1. Client — chat.stop() user clicks stop button / closes tab 2. AbstractChat — AbortController.abort() the internal controller's signal is passed to transport.sendMessages() 3. DefaultChatTransport — fetch(api, { signal }) browser aborts the HTTP request; network connection severed wire: network severance 4. Server route — request.signal enters aborted state Hono: c.req.raw.signal — Next.js Route Handler: req.signal 5. streamText({ abortSignal: request.signal }) LLM provider call aborted; billing stops 6. tool.execute(input, { abortSignal }) forwarded to downstream fetch / subprocess — long-running tools clean up Hops 4-6 are where 99% of leaks happen. Most commonly: forgetting abortSignal on streamText / tool.execute.

Dropping each hop: symptoms

Dropped hopSymptom
Client’s chat.stop() not calledUser pressed stop but stream continues — check the stop button handler
fetch signal not passedClient aborted but HTTP connection is alive — DefaultChatTransport handles this correctly; custom transports often miss it
Server doesn’t read request.signalServer keeps executing — the most common dropped hop, check the route handler
streamText doesn’t accept abortSignalLLM call continues — billing keeps running
tool.execute ignores its abortSignalLong tools keep running (shell commands, long fetches)

Correct template (Hono)

import { convertToModelMessages, streamText } from "ai";
import { Hono } from "hono";

app.post("/api/chat", async (c) => {
  const { messages } = await c.req.json();

  const result = streamText({
    model,
    messages: await convertToModelMessages(messages),
    tools,
    abortSignal: c.req.raw.signal, // ← Key: Hono's raw request carries the signal
    // tool execute signatures MUST accept { abortSignal } and forward to downstream fetch / subprocess
  });

  return result.toUIMessageStreamResponse();
});

Correct template (Next.js Route Handler)

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model,
    messages: await convertToModelMessages(messages),
    tools,
    abortSignal: req.signal, // ← Key
  });

  return result.toUIMessageStreamResponse();
}

Tool-internal abort convention

const searchTool = tool({
  description: "...",
  inputSchema: z.object({ query: z.string() }),
  execute: async ({ query }, { abortSignal }) => {
    const res = await fetch(`https://api.example.com/search?q=${query}`, {
      signal: abortSignal, // ← This line, so long fetches can be aborted
    });
    return res.json();
  },
});

End-to-end resume implementation

The useChat page covered the one-line client side (resume: true); the server side is where the real work lives. This section gives the full two-sided pattern.

Core constraints

  1. Chunks must be replayable: on reconnect, all previously emitted chunks must be re-sent, then new chunks continue. This requires the server to buffer as it generates.
  2. Chat id must be stable: the client’s useChat({ id }) on re-mount must pass the same id, or the server won’t find the buffer.
  3. Storage must be cross-process visible: a single-process in-memory buffer handles only single-worker reconnect; multi-worker deployments or pod restarts lose state — production uses Redis stream / Pub-Sub.

Client behavior

useChat({
  id: chatId, // stable, derived from URL / props
  resume: true, // on mount auto-calls transport.reconnectToStream({ chatId })
});

DefaultChatTransport.reconnectToStream POSTs to ${api}?chatId={id}. If it returns null or an empty stream, client enters ready, no replay; if it returns a non-empty stream, client consumes it like a normal stream.

Server implementation (Redis Stream approach)

Pseudocode, core idea:

// POST /api/chat — new conversation
app.post("/api/chat", async (c) => {
  const body = await c.req.json();
  const chatId = body.id;

  const result = streamText({...});
  const uiStream = result.toUIMessageStream();

  // Key: tee one copy into Redis, return the other to the client
  const [forClient, forBuffer] = uiStream.tee();

  // Asynchronously write each chunk of forBuffer into Redis stream (keyed by chatId)
  ctx.waitUntil(writeStreamToRedis(chatId, forBuffer));

  return new Response(forClient, { headers: sseHeaders });
});

// POST /api/chat?chatId=xxx — reconnect
app.post("/api/chat", async (c) => {
  const reconnectId = c.req.query("chatId");
  if (reconnectId) {
    // Query Redis; if the stream exists, return (replay existing + subscribe to new)
    const bufferedStream = await readStreamFromRedis(reconnectId);
    if (bufferedStream) {
      return new Response(bufferedStream, { headers: sseHeaders });
    }
    // Stream completed or doesn't exist → return empty, client returns to ready
    return new Response(null, { status: 204 });
  }
  // ... normal new-conversation path
});

Idempotency

  • Each chunk on the server can carry an auto-incrementing seq number (Redis XADD’s native id field works)
  • On reconnect, client passes the last received seq (?chatId=xxx&lastSeq=42)
  • Server replays only seq > lastSeq
  • This avoids double-applying text-delta on reconnect (text-delta is incremental — replaying twice doubles the content)

DefaultChatTransport’s default implementation does not auto-dedupe by seq — exact dedup requires custom prepareReconnectToStreamRequest coordinated with the server route. For most cases: agent step loops produce full text-start → text-delta → text-end blocks per step; persist after text-end, and the client’s message.parts rebuild naturally dedupes by part id.

Common pitfalls

ErrorReality
Using an in-memory Map for bufferSingle pod restart loses everything
No TTLRedis fills up indefinitely (recommend 24-48h TTL)
Randomized chatIdPage refresh loses the association; resume always fails
On reconnect, call streamText againBypasses buffer, LLM runs twice — billing doubles
Forget tee()Can’t both write buffer and send to client

Four error paths

Server-side errors reach the client’s onError via four different paths, corresponding to four different network behaviors:

PathWhenHTTP layerStream layerHow client receives
A. Pre-flight (4xx)Auth failure / validation / rate limitReturns 4xx with bodyStream not startedfetch returns non-ok response → transport throws → onError(new Error("HTTP 401 ..."))
B. Pre-flight (5xx)Server boot failure / dependency init failureReturns 5xx with bodyStream not startedSame as above, but message is 5xx content
C. Mid-stream (agent internal)streamText throws / tool throwsHTTP 200, stream already startedEmits error chunkChunk parsed → onError(new Error(errorText)) + onFinish({ isError: true })
D. Connection severedTCP drop / proxy timeout / client network diedHTTP 200, stream started then brokeNo error / finish chunkfetch rejects → onFinish({ isDisconnect: true })

Client-side signals per path

useChat({
  onError: (error) => {
    // Fires only for paths A / B / C
    // Path D does NOT go here; it goes to onFinish's isDisconnect
  },
  onFinish: ({ isAbort, isDisconnect, isError, finishReason }) => {
    // Four states are mutually exclusive:
    // - Normal completion: all three flags false, finishReason = 'stop' / 'length' / 'tool-calls' / ...
    // - User stop: isAbort = true
    // - Network disconnect: isDisconnect = true (path D)
    // - Mid-stream error: isError = true (path C)
    // - Paths A / B: onFinish does NOT fire (stream never started)
  },
});

Correct server-side layering

Paths A / B (pre-flight): just throw / return HTTP error response:

app.post("/api/chat", async (c) => {
  if (!c.req.header("Authorization")) {
    return c.json({ error: "Unauthorized" }, 401); // ← Path A
  }
  // ...
});

Path C (mid-stream): go through the onError serializer:

const result = streamText({...});

return result.toUIMessageStreamResponse({
  onError: (error) => {
    // Key: this return value becomes the error chunk's errorText
    // Don't return error.message directly — may leak internal stack info
    log.error("stream.failed", error);
    return "Internal error, please retry.";
  },
});

If onError returns a string, the stream emits a { type: "error", errorText: "..." } chunk before ending; the client’s onError and onFinish({ isError: true }) both fire.

Path D (connection severed): server often can’t detect this (or only indirectly via request.signal). The client detects it from the fetch rejection.

Four client-side recovery strategies

CaseStrategyImplementation
isAbortDo nothing, user chose thisEmpty handler in onFinish
isDisconnectExpose reconnectShow “Reconnect?” button, call resumeStream()
isErrorShow error, optionally roll backonError + setMessages removes the last user msg
Paths A / BForm-error UIIn onError, branch on error.message (or return structured JSON from the server)

SSE environment-layer traps

Streaming SSE puts requirements on every layer of the network path. In production, these are the layers that commonly break:

Reverse-proxy buffering

nginx by default buffers the response and holds it until enough bytes accumulate. SSE stream buffered = client sees “a big burst all at once” instead of “small chunks as they arrive”.

location /api/chat {
  proxy_pass http://app;
  proxy_buffering off;         # ← Key
  proxy_cache off;
  proxy_set_header Connection "";
  proxy_http_version 1.1;
  chunked_transfer_encoding on;
}

Cloudflare

Default Auto Minify and some compression features can break SSE. In Cloudflare dashboard, disable Auto Minify / Rocket Loader / Speed features for the /api/chat path. Or add a response header:

return result.toUIMessageStreamResponse({
  headers: {
    "X-Accel-Buffering": "no", // Generic bypass (nginx / some CDNs recognize)
    "Cache-Control": "no-cache",
  },
});

Service Worker

If your app registers a PWA service worker, it may cache or replay POST requests. Explicitly exclude /api/chat:

self.addEventListener("fetch", (event) => {
  if (event.request.url.includes("/api/chat")) {
    return; // Let the browser handle natively, don't go through SW
  }
  // ...
});

Browser connection limits

HTTP/1.1 limits 6 concurrent connections per origin. An SSE stream takes one, and additional XHRs on the same page can easily hit the cap. Deploy over HTTP/2 or HTTP/3 (Vercel / Cloudflare default) to avoid.

Load-balancer idle timeout

AWS ALB / nginx / Cloudflare’s default idle timeout is typically 60s–5min. An agent running a long task (10+ minutes) may be dropped. Either:

  • Bump the LB idle timeout
  • Or have the server proactively flush a no-op event every 15–30s (e.g. a transient data-heartbeat) to keep the connection alive

Further reading

Other chapters in this section

SDK source anchors

  • ai@6.0.134dist/index.d.ts:2158-2273 (UIMessageChunk full union definition)
  • ai@6.0.134dist/index.d.ts:2150-2156 (DataUIMessageChunk + transient)
  • ai@6.0.134dist/index.js:5101-5198 (createUIMessageStreamResponse SSE wrapping)
  • @ai-sdk/react@3.0.136dist/index.js (AbstractChat stream consumption logic)

External references

Was this page helpful?