DEV Community

Cover image for Unbuffered SSE Streaming in FastAPI + React 19: Full-Stack Implementation
Ken
Ken

Posted on

Unbuffered SSE Streaming in FastAPI + React 19: Full-Stack Implementation

Wiring up real-time streaming LLM responses into full-stack application scaffolds frequently uncovers friction between standard Python ASGI streaming responses and React client-side token decoders. When testing fastapi/full-stack-fastapi-template for conversational workflows, replacing standard JSON request-response roundtrips with unbuffered Server-Sent Events (SSE) requires precise coordination between FastAPI's StreamingResponse, proxy buffering, and @ai-sdk/react.

Here is a breakdown of how to wire an unbuffered, abort-safe streaming typewriter UI into the fastapi/full-stack-fastapi-template stack using React 19 and @ai-sdk/react.


The Architecture: Python ASGI to Client SSE

In fastapi/full-stack-fastapi-template, backend endpoints default to standard JSON payload returns. To stream tokens from an upstream model relay without chunk starvation, the FastAPI endpoint must disable downstream buffering by setting X-Accel-Buffering: no (critical when running behind Traefik or Nginx in Docker Compose) and return an async generator:

# backend/app/api/routes/chat.py
import asyncio
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
import httpx

router = APIRouter()

async def token_generator(prompt: str):
    # Upstream OpenAI-compatible relay client
    url = "https://api.b-lost.com/v1/chat/completions"
    headers = {"Authorization": "Bearer YOUR_RELAY_KEY"}
    payload = {
        "model": "gpt-4o",
        "messages": [{"role": "user", "content": prompt}],
        "stream": True,
    }

    async with httpx.AsyncClient(timeout=30.0) as client:
        async with client.stream("POST", url, headers=headers, json=payload) as response:
            async for chunk in response.aiter_lines():
                if chunk.startswith("data: "):
                    yield f"{chunk}\n\n"

@router.post("/chat/stream")
async def chat_stream(prompt: str):
    return StreamingResponse(
        token_generator(prompt),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        },
    )
Enter fullscreen mode Exit fullscreen mode

React 19 Frontend: Resilient SSE Consumer Hook

On the frontend container of fastapi/full-stack-fastapi-template (Vite + React 19), consuming raw SSE streams requires strict cancellation lifecycle handling via AbortSignal to prevent orphan requests and state race conditions when users abort generation mid-burst.

// frontend/src/hooks/use-stream-chat.ts
import { useState, useRef, useCallback } from "react";

export function useStreamChat() {
  const [messages, setMessages] = useState<{ role: string; content: string }[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const abortControllerRef = useRef<AbortController | null>(null);

  const sendMessage = useCallback(async (prompt: string) => {
    abortControllerRef.current?.abort();
    const controller = new AbortController();
    abortControllerRef.current = controller;

    setMessages((prev) => [...prev, { role: "user", content: prompt }, { role: "assistant", content: "" }]);
    setIsStreaming(true);

    try {
      const response = await fetch(`/api/v1/chat/stream?prompt=${encodeURIComponent(prompt)}`, {
        method: "POST",
        signal: controller.signal,
      });

      if (!response.body) throw new Error("ReadableStream not supported");
      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";

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

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split("\n\n");
        buffer = lines.pop() ?? "";

        for (const line of lines) {
          const trimmed = line.trim();
          if (trimmed.startsWith("data: ") && trimmed !== "data: [DONE]") {
            try {
              const parsed = JSON.parse(trimmed.slice(6));
              const token = parsed.choices?.[0]?.delta?.content || "";
              setMessages((prev) => {
                const next = [...prev];
                const last = next[next.length - 1];
                if (last && last.role === "assistant") {
                  last.content += token;
                }
                return next;
              });
            } catch {
              // Partial chunk edge; keep in buffer
            }
          }
        }
      }
    } catch (err: unknown) {
      if (err instanceof Error && err.name !== "AbortError") {
        console.error("Stream reader dropped:", err);
      }
    } finally {
      setIsStreaming(false);
    }
  }, []);

  const stop = useCallback(() => {
    abortControllerRef.current?.abort();
    setIsStreaming(false);
  }, []);

  return { messages, isStreaming, sendMessage, stop };
}
Enter fullscreen mode Exit fullscreen mode

This pattern demonstrates connecting Vercel AI SDK chat interfaces directly to B-Lost's unbuffered SSE relay, eliminating reverse-proxy buffering delays and stabilizing token ingestion in containerized full-stack stacks.

Top comments (0)