5. Streaming Chat UX and Conversation State
Build responsive streaming interfaces, manage cancellation and history, and handle errors and concurrent turns.
Why Stream Model Output?
Streaming delivers partial output before the full response is ready, improving perceived responsiveness for long generations. A stream may include text deltas, tool-call events, usage metadata, and terminal status depending on the provider. Do not assume every event is a user-visible string; parse event types explicitly.
Streaming complicates error handling: a request can fail after some text has already appeared. The UI should indicate incomplete responses, allow retry, and avoid presenting partial output as a completed answer. Define cancellation behavior and clean up readers or connections when the user navigates away.
const response = await fetch("/api/ai/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt })
});
if (!response.ok || !response.body) {
throw new Error("Streaming request failed");
}
const reader = response.body.getReader();
// Decode and process chunks according to your server's wire protocol.- Streaming improves perceived latency but needs explicit protocol handling.
- Partial output must be distinguished from a completed answer.
- Support cancellation, retries, and terminal error states.
Why must a streaming client parse event types rather than append every event blindly?
Chunk Parsing, AbortController, and Backpressure
Network chunks do not necessarily align with logical events or UTF-8 characters. A robust reader decodes incrementally and buffers incomplete protocol frames until a delimiter is found. If using Server-Sent Events, parse event boundaries according to the SSE format rather than assuming one fetch chunk equals one event.
Use AbortController to cancel a request when the user stops generation or the component unmounts. Propagate cancellation to the server and provider where supported, and ensure background work does not continue consuming resources unnecessarily. Backpressure matters when a consumer cannot process data as fast as it arrives.
const controller = new AbortController();
try {
const response = await fetch("/api/ai/stream", {
method: "POST",
body: JSON.stringify({ prompt }),
headers: { "Content-Type": "application/json" },
signal: controller.signal
});
// Read the stream...
} catch (error) {
if (error.name === "AbortError") {
console.log("Generation cancelled");
} else {
throw error;
}
}
// Call controller.abort() from a Stop button.- Protocol frames can span multiple network chunks.
- Use AbortController for cancellation.
- Propagate disconnects and release resources on the server.
What is a common reason to use AbortController for AI streaming?
Conversation State, Persistence, and Turn Ordering
Conversation state may include messages, tool calls and results, attachments, summaries, and safety metadata. Persist only what is needed and define tenant/user ownership on every read and write. Concurrent turns can race: two requests may read the same history and append responses in the wrong order. Use per-conversation locking, optimistic versions, or a turn sequence number when ordering matters.
A client-provided conversation ID is not proof of access. Load the conversation through an authorization-scoped query. For long conversations, maintain a compact summary plus recent turns and retrieve older facts only when needed.
async function appendTurn({ conversationId, userId, expectedVersion, message }) {
return db.conversations.updateOne(
{ id: conversationId, userId, version: expectedVersion },
{
$push: { messages: message },
$inc: { version: 1 }
}
);
}- Persist conversations with ownership checks.
- Handle concurrent turn ordering.
- Use summaries and retrieval to manage long history.
Why is a conversation ID not sufficient authorization by itself?