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 Stop
setMessageError(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.body readers on abort — holding them keeps the connection open.
Was this page helpful?