top of page

Stream Responses to a UI — Building with LLMs, Part 3

S. West
Jul 8
3 min read

Updated: Oct 4

Building with LLMs · Part 3

A 5-second wait for a response feels broken. Streaming token-by-token feels alive. This tutorial walks through implementing it.


What You'll Build


A streaming response from LLM through your backend to your frontend, displayed token-by-token.


Step 1: Stream from the LLM (10 min)


def stream_llm_response(prompt: str):
    with client.messages.stream(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}]
    ) as stream:
        for text in stream.text_stream:
            yield text

Generator yields tokens as they arrive.


Step 2: Server-Sent Events Endpoint (20 min)


from flask import Response, request, stream_with_context

@app.route('/api/chat/stream', methods=['POST'])
def chat_stream():
    prompt = request.json['prompt']
    
    def generate():
        for token in stream_llm_response(prompt):
            # SSE format
            yield f"data: {json.dumps({'token': token})}\n\n"
        yield "data: [DONE]\n\n"
    
    return Response(
        stream_with_context(generate()),
        mimetype='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'X-Accel-Buffering': 'no',  # nginx
        }
    )

SSE is simpler than WebSockets for one-way streaming.


Step 3: Frontend Reader (20 min)


async function streamChat(prompt: string, onToken: (token: string) => void) {
  const response = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt }),
  });
  
  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    
    const chunk = decoder.decode(value);
    const lines = chunk.split('\n');
    
    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const data = line.slice(6);
        if (data === '[DONE]') return;
        const { token } = JSON.parse(data);
        onToken(token);
      }
    }
  }
}

Reads the stream; calls onToken per token.


Step 4: React UI (15 min)


function ChatBox() {
  const [response, setResponse] = useState('');
  const [streaming, setStreaming] = useState(false);
  
  const sendPrompt = async (prompt: string) => {
    setStreaming(true);
    setResponse('');
    
    await streamChat(prompt, (token) => {
      setResponse(prev => prev + token);
    });
    
    setStreaming(false);
  };
  
  return (
    <div>
      <pre>{response}</pre>
      {streaming && <Spinner />}
    </div>
  );
}

State updates per token. UI re-renders smoothly.


Step 5: Handle Cancellation (10 min)


User clicks "stop":


const abortController = new AbortController();

const response = await fetch('/api/chat/stream', {
  signal: abortController.signal,
  // ...
});

// On stop button click
abortController.abort();

Stops the stream immediately. Saves tokens (you don't pay for what's not generated, but you stop early).


Step 6: Handle Errors Mid-Stream (10 min)


Streams can fail partway:


def generate():
    try:
        for token in stream_llm_response(prompt):
            yield f"data: {json.dumps({'token': token})}\n\n"
        yield "data: [DONE]\n\n"
    except Exception as e:
        yield f"data: {json.dumps({'error': str(e)})}\n\n"

Frontend handles error events:


if (data.error) {
  setError(data.error);
  return;
}

Step 7: Backpressure (15 min)


If the client is slow, server can buffer too much. Use HTTP/2 or careful flushing.


Most setups handle this OK for typical LLM rates. For high-volume, consider WebSockets with proper backpressure.


Step 8: Reconnection (varies)


If the connection drops, the stream is lost. For high-value cases:


  • Cache the full response server-side as it streams

  • On reconnection, send what's accumulated

  • Resume the stream from where it stopped (if possible)


Complex. For most apps, just retry the whole thing.


Step 9: UX Polish (15 min)


Small things that matter:


  • Cursor indicator while streaming (blinking caret)

  • "Stop" button during streaming

  • Auto-scroll as content grows

  • Markdown rendering at end of stream (or smartly during)

  • Code syntax highlighting when relevant


Step 10: Production Concerns (varies)


For production:


  • Proxy buffering: disable in nginx/load balancer (X-Accel-Buffering)

  • Timeout: server timeout must exceed expected stream duration

  • Rate limiting: still apply, but care about stream durations

  • Cost capping: monitor; some users may abuse


What You Just Did


You built end-to-end streaming. Responses feel responsive; users engaged; UX matches modern AI products.


Common Failure Modes


Buffering proxy. SSE doesn't flush; user waits then sees all at once.


No cancellation. User waits for unwanted content.


Sync UI updates. State updates per token; React re-renders too aggressively. Batch.


No error handling mid-stream. Failure halfway leaves UI confused.


Stream from cache. "Stream" the same response over and over from cache — defeats UX.



Continue the Building with LLMs path



Part of the Building with LLMs learning path.

bottom of page