Dev notes
Building streaming AI agent UIs from scratch — SSE wire format parsing, state machines for run lifecycle, streaming markdown rendering, tool call displays, human-in-the-loop approval gates, and the performance tricks that keep it smooth at 50+ tokens per second.
Three options for getting data from a streaming AI backend to the browser. Polling is the simplest — hit an endpoint every N seconds — but it wastes bandwidth and adds latency equal to half your polling interval on average. WebSockets give you bidirectional communication with low overhead, but they require a persistent connection, custom reconnection logic, and they don't work through some corporate proxies.
SSE (Server-Sent Events) is the sweet spot for most AI agent UIs. It's unidirectional (server to client), works over standard HTTP, and the browser's EventSource API handles reconnection automatically. The only catch is EventSource only supports GET requests with no custom headers — if you need POST (to send a prompt) or auth headers, you use fetch with a ReadableStream and parse the SSE format yourself.
SSE is a text protocol. Each event is a block of field lines separated by a blank line. The fields are event:, data:, id:, and retry:. Lines starting with a colon are comments. A blank line terminates the event and triggers dispatch.
The tricky part is that network chunks don't respect event boundaries. A single reader.read() call might return half an event, or three events and part of a fourth. The parser needs an internal buffer to carry incomplete data across chunk boundaries. Multi-line data fields (multiple data: lines in one event) are joined with newlines in the final value.
event: text_delta
data: {"content":"Hello "}
event: text_delta
data: {"content":"world"}
event: done
data: {}When you need POST or custom headers, you can't use EventSource. Instead, fetch the endpoint and read the response body as a stream. The pattern is: response.body.getReader() returns a ReadableStreamDefaultReader. Loop on reader.read(), decode each Uint8Array chunk with TextDecoder (using { stream: true } so multi-byte characters that split across chunks decode correctly), feed the text into your SSE parser, and process the events.
Cancellation is clean: call reader.cancel() or abort the fetch via AbortController. The stream terminates, the loop exits, and cleanup runs. No dangling connections.
An AI agent run has a lifecycle: idle, running, awaiting approval, completed, errored, or cancelled. Modeling this with boolean flags like isLoading, isError, isWaiting creates impossible states — what does isLoading && isError mean? A discriminated union on status makes impossible states unrepresentable. Each variant carries only the data relevant to that state.
The reducer pattern fits naturally here: useReducer with a pure agentReducer function. Every state transition is explicit, testable without React, and invalid transitions return the state unchanged rather than corrupting it. The reducer is 140 lines and handles 10 action types — START, APPEND_TEXT, APPEND_THINKING, ADD_TOOL_CALL, COMPLETE_TOOL_CALL, REQUEST_APPROVAL, RESOLVE_APPROVAL, COMPLETE, ERROR, CANCEL.
The AI is streaming markdown tokens one word at a time. You need to render it as HTML in real time. The main challenge is unclosed syntax: the model sends ```js and starts streaming code, but the closing ``` hasn't arrived yet. If you render the raw text, the user sees backticks instead of a code block.
The fix: count open code fences. If the count is odd and isStreaming is true, auto-close the fence for display purposes without mutating the source. Once streaming completes, the real closing fence arrives and the auto-close is no longer needed. Wrap the component in React.memo so completed messages don't re-render when new tokens arrive for the current message.
The critical rule: don't force-scroll when the user has scrolled up. If they're reviewing earlier content, yanking them back to the bottom on every new token is hostile UX. Track whether the scroll position is within a threshold (100px works well) of the bottom. If yes, auto-scroll. If no, let them read.
Use a passive scroll event listener to avoid blocking the main thread. Scroll with scrollTo({ behavior: 'smooth' }) for a polished feel. Show a "scroll to bottom" affordance when the user is scrolled up so they can jump back when they're ready. This is the same pattern every chat app uses, but getting the threshold right matters — too small and it flickers when content size changes, too large and it grabs the user too aggressively.
Not all errors are equal. Network errors and timeouts are retryable. Rate limit errors need exponential backoff. Auth errors need re-authentication. Stream interruptions (connection reset mid-response) might be recoverable — the backend can resume from where it left off. The key insight: preserve partial content on error. The user has already read the first half of the response; destroying it and showing a generic error page is the worst possible UX.
The state machine handles this naturally: the ERROR action transitions to error status while preserving the existing steps array. The UI renders the error as an inline banner in the timeline, not a full-page replacement. If the stream recovers (some backends do), the recovery text appears after the error banner and the user sees exactly what happened.
A fast model produces 50-100 tokens per second. Each token is a separate SSE event. If you call setState on every token, that's 50-100 React renders per second. The browser can handle it, but barely — you'll see jank, especially on mobile.
The fix is batching. Accumulate tokens in a useRef buffer and flush to state via requestAnimationFrame. This collapses multiple tokens into a single render that aligns with the browser's paint cycle. You go from 50 renders/sec to ~16 (matching 60fps), and each render has more content to show. For long conversations, consider virtualizing older messages so only visible content is in the DOM.
Common mistakes in agent UIs, collected from production codebases:
dangerouslySetInnerHTML for markdown — XSS vector. Parse and render safely, or use a sanitizer.Streaming is inherently async and timing-dependent, which makes testing tricky. The solution is vi.useFakeTimers() with controllable ReadableStream instances. Create a stream with a controller, enqueue SSE-formatted chunks on demand, and advance timers with vi.advanceTimersByTimeAsync(). Tests become fully deterministic.
For mock scenarios, build scripted streams that produce realistic event sequences on timers. This gives you repeatable demos and tests without a backend. For E2E, Playwright's route.fulfill() can intercept API calls and respond with chunked SSE data, letting you test the full streaming pipeline including network decoding.
// controllable mock stream for unit tests
function createTestStream() {
let controller;
const stream = new ReadableStream({
start(c) { controller = c; },
});
return {
stream,
sendEvent(event, data) {
controller.enqueue(
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
);
},
close() { controller.close(); },
};
}