Streaming and loading states
Design streaming responses and loading states for AI features, from when to stream to skeletons, progress, stop buttons and handling a stream that ends early.
On this page
Why this matters
Waiting is where users decide whether to trust an AI feature. A silent spinner for eight seconds feels broken; the same eight seconds with text appearing after the first second feels fast. The model hasn’t changed, only what the interface shows while it works.
Streaming and loading states are also where many AI features quietly mishandle failure: a stream that stops early, a request the user wants to cancel, an answer cut off at the token limit. This article covers when to stream, how to design the waiting experience, and a complete Next.js implementation that handles every way a streamed response can end.
To stream or not to stream
Streaming means showing output as the model generates it, token by token. It’s the right default for some features and the wrong one for others.
| Stream when | Don’t stream when |
|---|---|
| Users read the output as prose: chat, drafts, explanations | Your code needs the complete output: JSON, classifications, structured data |
| Responses take more than about a second | Responses are fast enough that streaming adds flicker |
| Partial output is useful on its own | Partial output could mislead (a half-finished number or decision) |
| Users might stop early once they have what they need | The result is applied automatically, so nobody watches it arrive |
For structured output, you can still stream progress: “Reading the document… Extracting fields… Checking totals…” while the full result is assembled, then show it all at once.
Loading states that inform
Before the first token arrives, and for features that don’t stream, the waiting state should tell users something real:
- Show that something started, immediately. Disable the button, show the working state within 100 milliseconds of the click, so users don’t click twice.
- Use skeletons shaped like the result. A skeleton of a three-paragraph reply sets expectations better than a generic spinner.
- Show meaningful steps for longer tasks. “Searching 4 sources… Drafting…” is more reassuring than a spinner, and tells users the system isn’t stuck. Only show steps that are really happening.
- Avoid fake precision. A progress bar that crawls to 90% and waits is worse than honest step labels. Don’t promise times you can’t keep.
- Keep the rest of the interface usable. Users should be able to keep reading, scroll or start something else while the AI works.
Stop and cancel
A visible Stop button turns streaming from a show into a tool. Users stop when they already have what they need, when the answer is going the wrong way, or when they realize they asked the wrong question.
Design decisions to make:
- What happens to the partial output? Usually keep it, clearly marked as stopped, so users can use what’s there or regenerate.
- Does stopping cancel the request upstream? It should. Aborting the request to the model stops generation, so you don’t keep paying for tokens nobody will read.
- What can users do next? Regenerate, edit their request, or continue from the partial answer.
The states of a streamed response
Every streamed request moves through a small number of states. Designing each one explicitly is what separates a solid feature from a demo:
Show text descriptionHide text description
A sequence of 4 steps.
- Idle : Ready for a request
- Waiting : Request sent, nothing shown yet: show progress
- Streaming : Text arriving: show it, offer Stop
- Final state : Done, truncated, declined, stopped or error
The final states map directly to how the request ended:
| Final state | Cause | What to show |
|---|---|---|
| Done | stop_reason is end_turn |
The answer, with actions such as copy, insert or edit |
| Truncated | max_tokens or the context window filled |
The partial answer, labelled, with “Continue” or “Shorter answer” |
| Declined | refusal |
A short explanation and an alternative path |
| Stopped | The user pressed Stop | The partial answer, marked as stopped, with “Regenerate” |
| Error | Network or API failure | What happened, and “Try again” if it can help |
Error, refusal and empty states covers the copy for each.
Streaming in Next.js and React
Here’s a complete implementation for a “draft a reply” feature. The server route streams events as newline-delimited JSON: one object per line for text, then one final object that says how the response ended. Keeping the model call on the server keeps your API key out of the browser.
import Anthropic from '@anthropic-ai/sdk';
// Runs on the server only. The API key never reaches the browser.
const client = new Anthropic();
/** One JSON object per line, so the client can act on each event as it arrives. */
type StreamEvent =
| { type: 'text'; text: string }
| { type: 'done'; stopReason: Anthropic.Message['stop_reason'] }
| { type: 'error'; retryable: boolean };
export async function POST(request: Request): Promise<Response> {
const { prompt } = (await request.json()) as { prompt?: unknown };
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.length > 8000) {
return Response.json({ error: 'Prompt must be 1 to 8,000 characters.' }, { status: 400 });
}
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: StreamEvent) => controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
try {
// Passing request.signal cancels the model call when the user presses Stop or leaves the page.
const stream = client.messages.stream(
{
model: 'claude-opus-5-5',
max_tokens: 4096,
output_config: { effort: 'low' },
system: 'Draft a concise, friendly reply to the customer message. Plain text, no greeting line.',
messages: [{ role: 'user', content: prompt }],
},
{ signal: request.signal },
);
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
send({ type: 'text', text: event.delta.text });
}
}
const message = await stream.finalMessage();
send({ type: 'done', stopReason: message.stop_reason });
} catch (error) {
// If the user cancelled, the browser has already gone: there's nobody to tell.
if (!request.signal.aborted) {
const retryable =
error instanceof Anthropic.RateLimitError ||
error instanceof Anthropic.InternalServerError ||
error instanceof Anthropic.APIConnectionError;
send({ type: 'error', retryable });
}
} finally {
try {
controller.close();
} catch {
// Already closed because the client disconnected.
}
}
},
});
return new Response(body, {
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8', 'Cache-Control': 'no-store' },
});
} The client component reads the stream line by line, shows text as it arrives, and maps every way the response can end to a state the interface designs for:
'use client';
import { useRef, useState } from 'react';
// Same shape as the events the route sends.
type StreamEvent =
| { type: 'text'; text: string }
| { type: 'done'; stopReason: string | null }
| { type: 'error'; retryable: boolean };
type Status = 'idle' | 'waiting' | 'streaming' | 'done' | 'truncated' | 'declined' | 'stopped' | 'error';
const finalStatus = (stopReason: string | null): Status => {
switch (stopReason) {
case 'end_turn':
return 'done';
case 'max_tokens':
case 'model_context_window_exceeded':
return 'truncated';
case 'refusal':
return 'declined';
default:
// Anything else (no tools are defined here, so tool_use or pause_turn would be a bug) is shown as an error.
return 'error';
}
};
const messages: Partial<Record<Status, string>> = {
truncated: 'This draft was cut off. Try asking for a shorter reply.',
declined: "This request can't be drafted automatically. Please write this reply yourself.",
stopped: 'Stopped. You can keep this partial draft or try again.',
error: 'Something went wrong while drafting.',
};
export function DraftReply({ customerMessage }: { customerMessage: string }) {
const [status, setStatus] = useState<Status>('idle');
const [text, setText] = useState('');
const [retryable, setRetryable] = useState(false);
const controller = useRef<AbortController | null>(null);
async function draft() {
controller.current = new AbortController();
setText('');
setStatus('waiting');
try {
const response = await fetch('/api/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: customerMessage }),
signal: controller.current.signal,
});
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
let ended = false;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines.filter(Boolean)) {
const event = JSON.parse(line) as StreamEvent;
if (event.type === 'text') {
setStatus('streaming');
setText((t) => t + event.text);
} else if (event.type === 'done') {
ended = true;
setStatus(finalStatus(event.stopReason));
} else {
ended = true;
setRetryable(event.retryable);
setStatus('error');
}
}
}
// The connection closed without a final event: treat it as an error, not as a finished draft.
if (!ended) {
setRetryable(true);
setStatus('error');
}
} catch {
if (controller.current.signal.aborted) {
setStatus('stopped');
} else {
setRetryable(true);
setStatus('error');
}
}
}
const busy = status === 'waiting' || status === 'streaming';
const note = messages[status];
return (
<section aria-labelledby="draft-heading">
<h2 id="draft-heading">Suggested reply</h2>
{/* aria-busy stops screen readers announcing every token; the status line announces the outcome once. */}
<div aria-busy={busy}>{status === 'waiting' ? <p>Drafting…</p> : <p style={{ whiteSpace: 'pre-wrap' }}>{text}</p>}</div>
<p role="status">{note ?? (status === 'done' ? 'Draft ready.' : '')}</p>
{busy ? (
<button type="button" onClick={() => controller.current?.abort()}>
Stop
</button>
) : (
<button type="button" onClick={draft} disabled={status === 'error' && !retryable}>
{status === 'idle' ? 'Draft a reply' : 'Try again'}
</button>
)}
</section>
);
} A few details in this code are easy to get wrong:
- A stream can close without a final event if the connection drops. The client treats that as an error instead of silently presenting a partial draft as finished.
- Stop really stops. Aborting the browser request aborts the server’s request to the model through
request.signal, so generation and billing stop too. - Screen readers aren’t flooded. Text arriving token by token in a live region would be read out piece by piece. Instead, the output area is marked busy while streaming, and a separate status line announces the outcome once.
- The input is validated on the server before any model call. A real app also needs authentication and rate limits.
A day-one demo in Next.js builds a full project around this pattern.
Common mistakes
- A spinner with no information. Show progress that means something: skeletons shaped like the result, or the real steps being taken.
- No Stop button. Users can’t cancel a wrong answer, and you keep paying for tokens nobody reads. Make Stop abort the upstream request.
- Treating a closed stream as success. Check for an explicit final event and its stop reason; a dropped connection isn’t a finished answer.
- Streaming structured output. Half a JSON object is useless to users and to your code. Stream progress instead, then show the complete result.
- Announcing every token to screen readers. Mark the output busy while streaming and announce the result once.
Try it
Exercise
About 60 min
Add streaming and a Stop button to a feature
- Take an AI feature that currently waits for the full response, or build the draft example above in a Next.js app.
- Stream the output with a route like the one above, and render it as it arrives.
- Add a Stop button that aborts the request, and confirm in your server logs that the model call is cancelled.
- Force each final state: lower
max_tokensto trigger truncation, press Stop mid-stream, and stop your server to trigger an error. Check that each looks deliberate.
Show a hint
To test truncation quickly, temporarily set max_tokens very low. To test a dropped connection, kill the dev server while a response is streaming.
Show a worked solution
A finished implementation passes these checks:
- The first text appears well before the full response would have.
- Pressing Stop keeps the partial text, marks it as stopped, and the server log shows the request was aborted.
- With a very low
max_tokens, the draft is labelled as cut off, not shown as complete. - Killing the server mid-stream shows an error, not a silently truncated draft.
- With a screen reader, you hear “Drafting…” and then a single announcement of the outcome.
Check your understanding
Key takeaways
- Stream prose users read; wait for complete results your code consumes, and stream progress instead.
- Loading states should inform: immediate feedback, skeletons shaped like the result, real steps.
- A Stop button should keep the partial output and cancel the upstream request.
- Every stream ends in one final state: done, truncated, declined, stopped or error. Detect each explicitly and design for it.
Progress is saved in this browser and lights up the roadmap.
Related articles
- Latency budgets and perceived speed Set latency budgets for AI features, measure time to first token and total time, and use streaming and UX to make responses feel fast. Level: Intermediate
- Error, refusal and empty states Design the unhappy paths of AI features, from API errors and refusals to truncated answers, empty results and timeouts, with copy and recovery for each. Level: Intermediate
- A day-one demo in Next.js Build a working AI feature demo in one day with Next.js and the Anthropic SDK, with a server route, streaming UI and handling for every stop reason. Level: Intermediate