Streaming to a Simple Frontend

Ma Mahalakshmi V Updated 16 Sep 2026
6 min read ·Lesson 63 of 72

Why Streaming Changes the User's Experience

Lesson 2's /chat endpoint waits for the model's entire response to finish generating before returning anything at all — for a short answer this is barely noticeable, but for a longer, more detailed answer grounded in retrieved document context, a user can be left staring at nothing for several seconds before any text appears. Streaming changes this by sending back each piece of the response as it's generated, so a user sees text appearing progressively rather than waiting for the complete answer — the total time to finish generating is the same either way, but the perceived responsiveness is considerably better, since the user has something to read within a fraction of a second rather than waiting for the entire answer to be ready.

Streaming a Response From the Model

The Responses API supports streaming directly, returning a sequence of events as the response is generated rather than a single completed object.

async def stream_model_response(async_client, messages: list):
    async with async_client.responses.stream(
        model="gpt-5.6-terra",
        input=messages,
    ) as stream:
        async for event in stream:
            if event.type == "response.output_text.delta":
                yield event.delta

Note: The exact streaming interface, the specific event type names, and which event carries incremental text can vary by SDK version. Confirm the current streaming API and event structure against your installed SDK version's documentation before relying on a specific event type name in production code.

Iterating over stream yields a sequence of events describing what's happening as the response is generated — checking specifically for response.output_text.delta events and yielding just the incremental text (event.delta) is what turns the full event stream, which includes other event types not relevant to display, into exactly the piece-by-piece text a frontend needs to show progressively.

Exposing a Streaming Endpoint With FastAPI

FastAPI's StreamingResponse takes an async generator and streams its output to the client as it's produced, which is exactly the shape stream_model_response() above already provides.

from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

async def sse_formatted_stream(async_client, messages: list):
    async for text_chunk in stream_model_response(async_client, messages):
        yield f"data: {text_chunk}\n\n"
    yield "data: [DONE]\n\n"

@app.post("/chat/stream")
async def chat_stream(request: ChatRequest):
    history = conversation_store.setdefault(request.conversation_id, [])
    relevant_chunks = await retrieve_relevant_chunks(request.message)
    context_block = "\n\n".join(relevant_chunks) if relevant_chunks else "No relevant document content found."

    messages = [
        {"role": "system", "content": f"Answer using only this context:\n{context_block}"},
        *history,
        {"role": "user", "content": request.message},
    ]

    return StreamingResponse(
        sse_formatted_stream(async_client, messages),
        media_type="text/event-stream",
    )

sse_formatted_stream() wraps each text chunk in the Server-Sent Events (SSE) format — a data: prefix followed by two newlines — which is a simple, widely supported convention for streaming text to a browser over a regular HTTP connection, with a final [DONE] marker signaling that no more chunks are coming. Reusing Lesson 2's retrieve_relevant_chunks() and conversation-history logic unchanged here reflects that streaming only changes how the final response is delivered, not how retrieval or conversation state work — everything from Lesson 2 up through generating the messages list carries over exactly as written.

A Simple Frontend Consuming the Stream

A minimal browser-side implementation reads the streamed response incrementally and appends each chunk to the page as it arrives, rather than waiting for the entire response body.

async function sendMessage(conversationId, message) {
  const response = await fetch("/chat/stream", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ conversation_id: conversationId, message: message }),
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  const replyElement = document.getElementById("reply");
  replyElement.textContent = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const chunkText = decoder.decode(value);
    for (const line of chunkText.split("\n\n")) {
      if (!line.startsWith("data: ")) continue;
      const data = line.slice(6);
      if (data === "[DONE]") return;
      replyElement.textContent += data;
    }
  }
}

response.body.getReader() gives direct access to the HTTP response body as it arrives, rather than waiting for fetch() to resolve with a complete body — each call to reader.read() returns whatever has arrived since the last call, which is decoded and parsed for data: lines matching the SSE format the backend produces. Appending each piece of text directly to replyElement.textContent as it's decoded is what produces the progressive, word-by-word (or chunk-by-chunk) appearance a streaming interface is meant to provide.

Handling Tool Calls Within a Streamed Response

Lesson 1's design includes a tool alongside retrieval, and a streamed response needs to account for a tool call occurring partway through generation rather than assuming every response is pure incremental text from start to finish.

async def stream_with_tool_awareness(async_client, messages: list, tools: list):
    async with async_client.responses.stream(
        model="gpt-5.6-terra",
        input=messages,
        tools=tools,
    ) as stream:
        async for event in stream:
            if event.type == "response.output_text.delta":
                yield {"type": "text", "content": event.delta}
            elif event.type == "response.function_call_arguments.delta":
                yield {"type": "tool_call_in_progress", "content": None}

Note: The exact event types emitted during a streamed response that includes a tool call, and how a completed tool call's result re-enters the stream, can vary by SDK version. Confirm the current event sequence for tool-calling streams against your installed SDK version's documentation before building production logic around a specific event type.

Distinguishing a response.function_call_arguments.delta event from a text delta matters for the frontend's experience: rather than showing nothing (or a raw, confusing partial function call) while a tool is being invoked, the frontend can show an indicator — "checking loaded documents..." — that something is happening, closing the same kind of perceived-latency gap streaming addresses for text generation, applied to the tool-calling step instead.

Common Mistakes

Buffering the entire response server-side before sending anything to the client, defeating the purpose of streaming even though the endpoint is nominally using a streaming API.

Assuming every streamed response consists only of text delta events, missing tool-call-related events entirely and producing a confusing or broken experience when the model happens to call the tool mid-response.

Not handling a dropped or interrupted connection on the frontend, leaving a user's interface stuck mid-response with no indication that the stream ended unexpectedly rather than completing normally.

Parsing the SSE stream incorrectly — such as assuming each read() call returns exactly one complete data: line — when a single chunk from the underlying connection can contain a partial line, multiple lines, or split awkwardly across chunk boundaries.

Best Practices

Stream text to the frontend incrementally as it's generated, rather than waiting for the full response, to substantially improve perceived responsiveness for longer answers.

Reuse the same retrieval and conversation-state logic between streaming and non-streaming endpoints, changing only how the final response is delivered.

Give the frontend a visible indicator for non-text events, such as an in-progress tool call, rather than leaving the interface silent while something is happening behind the scenes.

Handle stream parsing defensively, accounting for a chunk boundary that doesn't align cleanly with a complete SSE data: line.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.out Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Mem Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources
Ask about this post
AI Ask about this post

Ask questions about Streaming to a Simple Frontend and get answers drawn from it.

Signed-in readers only.