Streaming fundamentals
Slipstream components consume a stream-agnostic contract: a growing content string and an optional streamingMessageId. Whether the bytes come from SSE, WebSockets, or LangGraph, the UI side is identical.
The one contract
tsx
// While streaming, exactly one message updates on every chunk:setMessages((prev) =>prev.map((m) => (m.id === replyId ? { ...m, content } : m))));setStreamingId(replyId); // drives the typing cursor + aria-busy// When done:setStreamingId(null); // cursor stops, completion is announced
Reading a fetch stream
tsx
async function* streamText(url: string, body: unknown, signal: AbortSignal) {const res = await fetch(url, { method: 'POST', body: JSON.stringify(body), signal });if (!res.ok || !res.body) throw new Error('Stream failed: ' + res.status);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 });// SSE lines: "data: {...}" — split on newlines, keep the tail.const lines = buffer.split('\n');buffer = lines.pop() ?? '';for (const line of lines) {const payload = line.replace(/^data:\s*/, '');if (payload && payload !== '[DONE]') yield JSON.parse(payload).delta as string;}}}
Abort and stop-generation
Keep the AbortController in a ref — PromptInput's stop button just calls it. Abort is not an error: stop writing and leave the partial text in place (users expect their stop to keep what arrived).
tsx
const abortRef = useRef<AbortController | null>(null);async function send(text: string) {const controller = new AbortController();abortRef.current = controller;try {for await (const delta of streamText('/api/chat', { text }, controller.signal)) {appendDelta(delta);}} catch (err) {if ((err as Error).name === 'AbortError') return; // user pressed StopsetMessageError(replyId, (err as Error).message); // render retry card} finally {setStreamingId(null);}}<PromptInput onSubmit={send} isLoading={streamingId !== null}onStop={() => abortRef.current?.abort()} />
Partial markdown renders cleanly
Streams cut mid-token: **half, unclosed code fences, dangling tables. Response repairs those on every render (parseIncompleteMarkdown, on by default) — never show raw asterisks while streaming.
Scroll anchoring rules
- Conversation auto-scrolls only while you are near the bottom — reading history is never yanked mid-stream.
- During streaming scrolls are instant (per-token smooth animations jank); user-triggered jumps are smooth.
- The scroll-to-latest pill scrolls the container only — never the page.
- History prepend (loading older messages) keeps your anchor position stable.
Performance notes
- Batch state updates (requestAnimationFrame or a 30–60ms timer) when your transport emits sub-frame chunks — one setState per token will melt large transcripts.
- Keep deltas as strings; parse markdown lazily per message, not the whole transcript on every chunk.
- Drop
response.bodyreaders on abort — holding them keeps the connection open.
Was this page helpful?