AI responses take 5 to 30 seconds to generate. Without streaming, users stare at a spinner the entire time. Streaming UI sends tokens as they arrive so the first word appears in 200 to 500 milliseconds.
How Streaming Works Under the Hood
Most LLM providers stream via Server-Sent Events (SSE) over a single HTTP connection. Each event carries a small chunk:
data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":" there"}}]}
data: {"choices":[{"delta":{"content":"! How"}}]}
data: [DONE]
The frontend reads the response body as a readable stream, decodes chunks, and appends them to the accumulated text:
async function streamResponse(prompt) {
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulated = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
accumulated += parseChunk(chunk);
setContent(accumulated);
}
}
The hard part isn't receiving data. It's rendering partial Markdown, code blocks, and tables without flicker or layout shift.
Rendering Partial Markdown Safely
A naive renderer reparses on every token. That breaks bold tags, leaves code fences unclosed, and invalidates tables. Buffer incomplete structures and only render when you hit a safe boundary.
function StreamingMarkdown({ chunks, isStreaming }) {
const [rendered, setRendered] = useState('');
const bufferRef = useRef('');
useEffect(() => {
bufferRef.current += chunks;
const safeContent = sanitizePartialMarkdown(bufferRef.current);
setRendered(markdownToHtml(safeContent));
}, [chunks]);
return (
<div
className="prose"
dangerouslySetInnerHTML={{ __html: rendered }}
aria-live="polite"
aria-busy={isStreaming}
/>
);
}
function sanitizePartialMarkdown(text) {
// Close any open bold/italic tags
// Close any open code fences
// Remove incomplete table rows
// Buffer incomplete list items
return cleanedText;
}
For code blocks, defer syntax highlighting until the closing fence arrives. Re-highlighting on every chunk is expensive for long blocks.
Streaming Citations
Citations often reference content that appears later in the stream. Render inline markers immediately, then populate the reference panel when full citation objects arrive:
function StreamingWithCitations({ content, isStreaming }) {
const { text, citations } = parseCitations(content);
return (
<div>
<div className="prose" aria-live="polite" aria-busy={isStreaming}>
{text}
</div>
{citations.length > 0 && (
<div className="citations-panel" aria-label="Sources">
{citations.map((citation, i) => (
<a key={i} href={citation.url} className="citation-card">
<span className="citation-number">[{i + 1}]</span>
<span className="citation-title">{citation.title}</span>
<span className="citation-domain">{citation.domain}</span>
</a>
))}
</div>
)}
</div>
);
}
Stream Controls Users Expect
Stop Button
Visible during streaming. Aborts generation and keeps what arrived.
function StreamControls({ isStreaming, onStop, onRetry }) {
return (
<div className="flex gap-2">
{isStreaming ? (
<button onClick={onStop} aria-label="Stop generating">
<StopIcon aria-hidden="true" />
Stop
</button>
) : (
<button onClick={onRetry} aria-label="Regenerate response">
<RefreshIcon aria-hidden="true" />
Retry
</button>
)}
</div>
);
}
Copy and Format Toggle
Show copy after streaming completes. Let users switch between rendered Markdown, raw Markdown, and plain text without regenerating.
Accessibility Requirements
Streaming content breaks screen readers if handled poorly. Use these patterns for WCAG AA:
<div aria-live="polite" aria-atomic="false" aria-busy={isStreaming}>
{streamedContent}
</div>
-
aria-live="polite"announces changes without interrupting -
aria-atomic="false"reads only new content, not the entire region -
aria-busy="true"holds announcements until streaming completes
Don't steal focus from the prompt input when a response starts. Debounce announcements to 2 to 3 second intervals. Stop, retry, and copy buttons must be keyboard accessible with logical tab order.
Using the Vercel AI SDK
The Vercel AI SDK handles the streaming connection and state. You provide the UI layer:
'use client';
import { useChat } from 'ai/react';
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, stop } = useChat();
return (
<div>
{messages.map((message) => (
<div key={message.id} role={message.role === 'assistant' ? 'status' : undefined}>
<strong>{message.role === 'user' ? 'You' : 'Assistant'}</strong>
<p>{message.content}</p>
</div>
))}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Ask something..."
aria-label="Chat prompt"
/>
{isLoading ? (
<button type="button" onClick={stop}>Stop</button>
) : (
<button type="submit">Send</button>
)}
</form>
</div>
);
}
Anti-Patterns to Avoid
Layout thrash: Each token resizes the container and pushes content down. Fix with a minimum height on the response container and scroll the container, not the page.
No loading state before first token: TTFT can be seconds. Show a typing indicator or skeleton immediately on submit.
Flickering Markdown: Re-parsing every token toggles formatting on and off. Buffer and re-render only on whitespace or sentence boundaries.
No stop button: Users can't abort irrelevant long responses. Keep a prominent stop button visible during streaming.
Losing partial responses on error: If the stream fails mid-response, don't discard what arrived. Show the partial content with an error message and retry option.
This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.
Top comments (0)