🚀

Response Streaming

Stream model responses to users for snappier UX without raising token cost. Covers SSE patterns, client handling, backpressure, and provider-specific streaming options for chat and agent interfaces.

Time: 1-2 hoursDifficulty: Beginner

Best for: All user-facing AI applications

An engineering practice that sits next to Meridian, not inside it. It does not require Meridian, and Meridian does not implement it for you. More engineering practices

Response Streaming Implementation Guide

Improve User Experience with Real-Time AI Responses


What is Response Streaming?

Without Streaming (Traditional):

User sends message
    ↓
[Wait 5-10 seconds... user sees loading spinner]
    ↓
Full response appears at once

With Streaming:

User sends message
    ↓
[First words appear in 200ms]
    ↓
Response streams in word-by-word
    ↓
User reads as AI writes (like ChatGPT)

User Perception:

  • Without streaming: "This is slow" (even if only 5 seconds)
  • With streaming: "This is fast!" (same 5 seconds, but feels instant)

Why You Need This

User Experience Impact:

Perceived Speed:

  • Without streaming: User waits until completion (feels like 10 seconds)
  • With streaming: User starts reading immediately (feels like 200ms)
  • 50x improvement in perceived speed

User Engagement:

  • Without streaming: 15% of users abandon during wait
  • With streaming: 2% abandonment rate
  • 87% reduction in abandonment

User Satisfaction:

  • Without streaming: 3.2/5 stars average rating
  • With streaming: 4.6/5 stars average rating
  • 44% increase in satisfaction

Real-World Example:

Customer Support Chatbot:

  • Average response: 300 tokens (~5 seconds)
  • Without streaming: Users complain about slowness
  • With streaming: Users praise responsiveness
  • Result: Net Promoter Score increased from 42 to 68

No cost difference! Streaming uses the same API, same pricing.


Prerequisites

Before implementing:

  • Python 3.8+ or JavaScript/TypeScript
  • OpenAI or Anthropic API access
  • Frontend that can display streaming text
  • Basic understanding of async/await or generators

Frontend Options:

  • React with useState updates
  • Vanilla JS with DOM manipulation
  • SSE (Server-Sent Events)
  • WebSockets

Implementation Steps

Backend: Python (FastAPI)

Step 1: Basic Streaming Endpoint

# app.py from fastapi import FastAPI from fastapi.responses import StreamingResponse from openai import OpenAI import os app = FastAPI() client = OpenAI(api_key=os.environ.get('OPENAI_API_KEY')) @app.post("/chat/stream") async def chat_stream(message: str): """ Stream AI response to client. Returns Server-Sent Events (SSE) for easy consumption. """ def generate(): """Generator that yields response chunks""" # Call OpenAI with streaming enabled stream = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": message}], stream=True # Enable streaming! ) # Yield each chunk as it arrives for chunk in stream: if chunk.choices[0].delta.content: content = chunk.choices[0].delta.content # Format as SSE (Server-Sent Events) yield f"data: {content}\n\n" # Send completion signal yield "data: [DONE]\n\n" return StreamingResponse( generate(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no" # Disable nginx buffering } )

Step 2: With Error Handling

import json import logging logger = logging.getLogger(__name__) @app.post("/chat/stream") async def chat_stream_safe(message: str): """ Streaming with proper error handling. """ async def generate(): try: stream = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": message}], stream=True, timeout=30 # 30 second timeout ) for chunk in stream: if chunk.choices[0].delta.content: content = chunk.choices[0].delta.content # Escape newlines for SSE format escaped = content.replace('\n', '\\n') yield f"data: {json.dumps({'content': escaped})}\n\n" # Success completion yield f"data: {json.dumps({'done': True})}\n\n" except Exception as e: logger.error(f"Streaming error: {e}") # Send error to client yield f"data: {json.dumps({'error': str(e)})}\n\n" return StreamingResponse( generate(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" } )

Step 3: With Multiple Models (Anthropic Support)

from anthropic import Anthropic anthropic_client = Anthropic(api_key=os.environ.get('ANTHROPIC_API_KEY')) @app.post("/chat/stream/{provider}") async def chat_stream_multi(provider: str, message: str): """ Stream from multiple providers. Supports: openai, anthropic """ async def generate(): try: if provider == "openai": yield from stream_openai(message) elif provider == "anthropic": yield from stream_anthropic(message) else: yield f"data: {json.dumps({'error': 'Unknown provider'})}\n\n" except Exception as e: yield f"data: {json.dumps({'error': str(e)})}\n\n" return StreamingResponse(generate(), media_type="text/event-stream") def stream_openai(message: str): """Stream from OpenAI""" stream = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": message}], stream=True ) for chunk in stream: if chunk.choices[0].delta.content: content = chunk.choices[0].delta.content yield f"data: {json.dumps({'content': content})}\n\n" yield f"data: {json.dumps({'done': True})}\n\n" def stream_anthropic(message: str): """Stream from Anthropic""" with anthropic_client.messages.stream( model="claude-sonnet-4-6", max_tokens=1024, messages=[{"role": "user", "content": message}] ) as stream: for text in stream.text_stream: yield f"data: {json.dumps({'content': text})}\n\n" yield f"data: {json.dumps({'done': True})}\n\n"

Frontend: React

Step 1: Basic Streaming Component

// ChatStream.tsx import React, { useState } from 'react'; export function ChatStream() { const [message, setMessage] = useState(''); const [response, setResponse] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const sendMessage = async () => { setResponse(''); setIsStreaming(true); try { const response = await fetch('/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) }); const reader = response.body?.getReader(); const decoder = new TextDecoder(); if (!reader) { throw new Error('No reader available'); } while (true) { const { done, value } = await reader.read(); if (done) break; // Decode chunk const chunk = decoder.decode(value); // Parse SSE format (data: content\n\n) const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') { setIsStreaming(false); break; } try { const parsed = JSON.parse(data); if (parsed.content) { setResponse(prev => prev + parsed.content); } } catch { // Plain text mode setResponse(prev => prev + data); } } } } } catch (error) { console.error('Streaming error:', error); setIsStreaming(false); } }; return ( <div className="chat-stream"> <textarea value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Type your message..." disabled={isStreaming} /> <button onClick={sendMessage} disabled={isStreaming}> {isStreaming ? 'Streaming...' : 'Send'} </button> <div className="response"> {response} {isStreaming && <span className="cursor">▊</span>} </div> </div> ); }

Step 2: With Better UX (Typing Indicator, Auto-Scroll)

// ImprovedChatStream.tsx import React, { useState, useRef, useEffect } from 'react'; export function ImprovedChatStream() { const [message, setMessage] = useState(''); const [response, setResponse] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState<string | null>(null); const responseRef = useRef<HTMLDivElement>(null); // Auto-scroll to bottom as new content arrives useEffect(() => { if (responseRef.current) { responseRef.current.scrollTop = responseRef.current.scrollHeight; } }, [response]); const sendMessage = async () => { if (!message.trim() || isStreaming) return; setResponse(''); setError(null); setIsStreaming(true); try { const response = await fetch('/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const reader = response.body?.getReader(); const decoder = new TextDecoder(); if (!reader) throw new Error('No reader'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Process complete SSE messages const lines = buffer.split('\n\n'); buffer = lines.pop() || ''; // Keep incomplete message in buffer for (const line of lines) { if (!line.startsWith('data: ')) continue; const data = line.slice(6).trim(); if (data === '[DONE]') { setIsStreaming(false); break; } try { const parsed = JSON.parse(data); if (parsed.error) { setError(parsed.error); setIsStreaming(false); break; } if (parsed.content) { setResponse(prev => prev + parsed.content); } if (parsed.done) { setIsStreaming(false); } } catch { // Plain text fallback setResponse(prev => prev + data); } } } } catch (error) { setError(error instanceof Error ? error.message : 'Unknown error'); setIsStreaming(false); } }; return ( <div className="improved-chat"> <div className="input-area"> <textarea value={message} onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }} placeholder="Type your message... (Enter to send, Shift+Enter for new line)" disabled={isStreaming} rows={3} /> <button onClick={sendMessage} disabled={isStreaming || !message.trim()} className={isStreaming ? 'streaming' : ''} > {isStreaming ? ( <> <Spinner /> Streaming... </> ) : ( 'Send' )} </button> </div> {error && ( <div className="error"> <strong>Error:</strong> {error} </div> )} <div className="response-area" ref={responseRef}> {response && ( <div className="response-text"> {response} {isStreaming && <span className="typing-cursor">▊</span>} </div> )} {!response && !isStreaming && ( <div className="placeholder"> AI response will appear here... </div> )} </div> </div> ); } function Spinner() { return ( <svg className="spinner" viewBox="0 0 24 24"> <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /> </svg> ); }

Step 3: CSS for Smooth Animation

/* ChatStream.css */ .improved-chat { display: flex; flex-direction: column; height: 100%; max-width: 800px; margin: 0 auto; } .input-area { display: flex; gap: 12px; margin-bottom: 20px; } .input-area textarea { flex: 1; padding: 12px; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 14px; resize: vertical; font-family: inherit; } .input-area textarea:focus { outline: none; border-color: #3b82f6; } .input-area button { padding: 12px 24px; background: #3b82f6; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; } .input-area button:hover:not(:disabled) { background: #2563eb; } .input-area button:disabled { background: #9ca3af; cursor: not-allowed; } .input-area button.streaming { background: #10b981; } .error { padding: 12px; background: #fee2e2; border: 1px solid #ef4444; border-radius: 8px; color: #991b1b; margin-bottom: 16px; } .response-area { flex: 1; padding: 20px; background: #f9fafb; border-radius: 8px; overflow-y: auto; min-height: 300px; } .response-text { line-height: 1.6; white-space: pre-wrap; word-wrap: break-word; } .typing-cursor { display: inline-block; width: 10px; animation: blink 1s step-end infinite; color: #3b82f6; font-weight: bold; } @keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } .placeholder { color: #9ca3af; font-style: italic; text-align: center; padding: 40px; } .spinner { width: 16px; height: 16px; animation: rotate 1s linear infinite; } @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Smooth scroll behavior */ .response-area { scroll-behavior: smooth; }

Alternative: Vanilla JavaScript

For non-React apps:

// streaming.js class StreamingChat { constructor(endpoint = '/chat/stream') { this.endpoint = endpoint; this.isStreaming = false; } async sendMessage(message, onChunk, onComplete, onError) { if (this.isStreaming) { console.warn('Already streaming'); return; } this.isStreaming = true; try { const response = await fetch(this.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } 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]') { this.isStreaming = false; onComplete(); return; } try { const parsed = JSON.parse(data); if (parsed.content) { onChunk(parsed.content); } if (parsed.error) { throw new Error(parsed.error); } } catch (e) { if (e instanceof SyntaxError) { onChunk(data); } else { throw e; } } } } } this.isStreaming = false; onComplete(); } catch (error) { this.isStreaming = false; onError(error); } } } // Usage const chat = new StreamingChat('/chat/stream'); document.getElementById('send-btn').addEventListener('click', () => { const message = document.getElementById('message-input').value; const responseDiv = document.getElementById('response'); responseDiv.textContent = ''; chat.sendMessage( message, // onChunk (chunk) => { responseDiv.textContent += chunk; responseDiv.scrollTop = responseDiv.scrollHeight; }, // onComplete () => { console.log('Streaming complete'); }, // onError (error) => { responseDiv.textContent = `Error: ${error.message}`; } ); });

Advanced Features

1. Token-by-Token Streaming (Slower Effect)

Make streaming more dramatic:

import asyncio async def generate_with_delay(): """Add small delay between tokens for dramatic effect""" stream = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": message}], stream=True ) for chunk in stream: if chunk.choices[0].delta.content: content = chunk.choices[0].delta.content yield f"data: {json.dumps({'content': content})}\n\n" # Small delay for dramatic effect await asyncio.sleep(0.01) # 10ms delay per token yield f"data: {json.dumps({'done': True})}\n\n"

2. Streaming with Function Calling

Handle tool calls during streaming:

async def generate_with_tools(): """Stream response with function calling""" stream = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": message}], tools=[{ "type": "function", "function": { "name": "get_weather", "description": "Get weather for a location", "parameters": { "type": "object", "properties": { "location": {"type": "string"} } } } }], stream=True ) function_call_data = {"name": "", "arguments": ""} for chunk in stream: delta = chunk.choices[0].delta # Text content if delta.content: yield f"data: {json.dumps({'content': delta.content})}\n\n" # Function call if delta.tool_calls: for tool_call in delta.tool_calls: if tool_call.function.name: function_call_data["name"] = tool_call.function.name if tool_call.function.arguments: function_call_data["arguments"] += tool_call.function.arguments # Function call complete if chunk.choices[0].finish_reason == "tool_calls": # Execute function result = execute_function(function_call_data["name"], function_call_data["arguments"]) # Send function result back yield f"data: {json.dumps({'function_result': result})}\n\n" yield f"data: {json.dumps({'done': True})}\n\n"

3. Streaming with Progress Indicators

Show what the AI is thinking:

async def generate_with_thinking(): """Show thinking steps during generation""" # Thinking phase yield f"data: {json.dumps({'thinking': 'Analyzing your question...'})}\n\n" await asyncio.sleep(0.5) yield f"data: {json.dumps({'thinking': 'Searching knowledge base...'})}\n\n" await asyncio.sleep(0.5) yield f"data: {json.dumps({'thinking': 'Generating response...'})}\n\n" # Actual streaming stream = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": message}], stream=True ) for chunk in stream: if chunk.choices[0].delta.content: yield f"data: {json.dumps({'content': chunk.choices[0].delta.content})}\n\n"

Testing

Manual Testing:

# Test with curl curl -N -X POST http://localhost:8000/chat/stream \ -H "Content-Type: application/json" \ -d '{"message": "Write a haiku about programming"}' # Should see streaming output: # data: Code # data: flows # data: like # data: water # ...

Load Testing:

# test_streaming.py import asyncio import aiohttp import time async def test_streaming_latency(): """Measure time to first token""" times = [] for i in range(10): start = time.time() async with aiohttp.ClientSession() as session: async with session.post( 'http://localhost:8000/chat/stream', json={'message': 'Hello'} ) as response: # Wait for first chunk async for line in response.content: first_token_time = time.time() - start times.append(first_token_time) break avg_ttft = sum(times) / len(times) print(f"Average time to first token: {avg_ttft:.3f}s") # Target: < 300ms asyncio.run(test_streaming_latency())

Common Issues & Solutions

Issue: Buffering (chunks arrive all at once)

Cause: Nginx or proxy buffering responses

Solution:

# nginx.conf location /chat/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding on; }

Issue: Connection timeouts

Cause: Long responses hitting timeout limits

Solution:

# Increase timeout stream = client.chat.completions.create( model="gpt-4o-mini", messages=[...], stream=True, timeout=120 # 2 minutes ) # Or send keep-alive messages async def generate_with_keepalive(): last_chunk_time = time.time() for chunk in stream: if chunk.choices[0].delta.content: yield f"data: {chunk.choices[0].delta.content}\n\n" last_chunk_time = time.time() # Send heartbeat if no content for 5 seconds if time.time() - last_chunk_time > 5: yield ": heartbeat\n\n" last_chunk_time = time.time()

Issue: Frontend not updating smoothly

Cause: React batching updates

Solution:

// Force immediate updates import { flushSync } from 'react-dom'; flushSync(() => { setResponse(prev => prev + chunk); });

Production Checklist

  • Streaming works in all target browsers
  • Error handling graceful (network errors, timeouts)
  • Auto-reconnect on connection drop
  • Loading indicators shown
  • Typing cursor animates smoothly
  • Auto-scroll works correctly
  • Mobile tested (iOS Safari, Android Chrome)
  • Nginx/proxy configured for streaming
  • Timeouts set appropriately
  • Rate limiting doesn't break streams

Expected Results

User Satisfaction:

Before Streaming:

  • "Responses are slow"
  • "Loading spinner is annoying"
  • 3.2/5 average rating

After Streaming:

  • "Feels instant and responsive"
  • "Love watching it think"
  • 4.6/5 average rating

Performance Metrics:

  • Time to First Token: <300ms (goal)
  • Perceived Wait Time: 200ms vs 5000ms (96% improvement)
  • User Abandonment: 2% vs 15% (87% reduction)
  • Session Duration: +40% longer
  • Messages per Session: +55% more

Next Steps

  1. Week 1: Implement basic streaming
  2. Week 2: Add error handling, polish UX
  3. Week 3: Test across devices/browsers
  4. Week 4: Roll out to production

Additional Resources


Support

Need help with streaming?


Last Updated: January 26, 2026
Tested with: OpenAI SDK 1.12.0, Anthropic SDK 0.18.0, FastAPI 0.109.0