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",
},
)
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 };
}
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)