Skip to content
AI Product Engineer Wiki

    ↑ ↓ to move between results, Enter to open, Esc to close

    Menu

    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.

    Level: Intermediate 14 min read Updated
    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:

    States of a streamed response. Every request ends in exactly one final state, and each needs a design.
    Show text descriptionHide text description

    A sequence of 4 steps.

    1. Idle : Ready for a request
    2. Waiting : Request sent, nothing shown yet: show progress
    3. Streaming : Text arriving: show it, offer Stop
    4. 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.

    app/api/draft/route.ts
    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:

    components/draft-reply.tsx
    '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

    1. Take an AI feature that currently waits for the full response, or build the draft example above in a Next.js app.
    2. Stream the output with a route like the one above, and render it as it arrives.
    3. Add a Stop button that aborts the request, and confirm in your server logs that the model call is cancelled.
    4. Force each final state: lower max_tokens to 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

    Question 1 of 4: Which feature should usually not stream its output to users?

    Show answer

    C. A classification your code applies to tickets automatically

    Your code needs the complete result, and nobody reads it as it arrives. Stream progress if anything, not partial output.

    Question 2 of 4: Why pass request.signal to the model call in the route handler?

    Show answer

    B. So that when the user stops or leaves, the model call is cancelled and stops generating and billing

    Without it, the server keeps generating, and paying for, tokens that nobody will read.

    Question 3 of 4: The stream closes, but no final "done" event arrived. What should the interface show?

    Show answer

    B. An error state, because the response did not end normally

    A dropped connection is not a finished answer. Without an explicit final event, assume something went wrong.

    Question 4 of 4: Why is the streaming output area marked aria-busy rather than being a live region?

    Show answer

    B. A live region would announce the text piece by piece; busy plus a single status announcement is far more usable

    Screen reader users get one clear announcement of the outcome instead of a stream of fragments.

    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.