DEV Community

Cover image for Notes from a 2 AM On-Call: LangChain Streaming Breaks Under FastAPI Load
Leo Martinez
Leo Martinez

Posted on

Notes from a 2 AM On-Call: LangChain Streaming Breaks Under FastAPI Load

The Failure Symptom

Production alert: SSE endpoint serving LangChain chat completions starts timing out at 500 concurrent connections. No exceptions logged. CPU idle at 12%. Memory normal. Clients just hang on EventSource connection, then 504 after 60s.

# Nginx access log
"POST /v1/chat/stream HTTP/1.1" 504 0 60.001
"POST /v1/chat/stream HTTP/1.1" 504 0 60.004
Enter fullscreen mode Exit fullscreen mode

Strace shows worker processes stuck in futex() wait. All 8 uvicorn workers frozen.

The Red Herring

LangChain docs show this standard async streaming pattern:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

app = FastAPI()

@app.post("/v1/chat/stream")
async def stream_chat(prompt: str):
    llm = ChatOpenAI(
        base_url="https://b-lost.com/v1",
        streaming=True
    )

    async def generate():
        async for chunk in llm.astream([HumanMessage(content=prompt)]):
            yield f"data: {chunk.content}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")
Enter fullscreen mode Exit fullscreen mode

Looks correct. Runs fine in dev at 10 req/s. Tutorial says "just use astream() for async".

Under load testing with 500 parallel connections, requests start blocking after ~200 complete. Adding more uvicorn workers makes it worse.

The Root Cause

LangChain's ChatOpenAI client instantiates a new httpx.AsyncClient on every request by default, but doesn't explicitly close it when streaming completes. Under high concurrency:

  1. Each SSE connection holds an unclosed httpx.AsyncClient
  2. Default httpx connection pool limit is 100 connections
  3. Request 101+ blocks waiting for a free connection slot in the pool
  4. Because clients never close, pool never releases slots
  5. Deadlock: all workers waiting on connection pool sempahore

The fix: explicit client lifecycle management with connection pooling:

from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
import httpx

# Shared httpx client with explicit limits
http_client = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global http_client
    http_client = httpx.AsyncClient(
        limits=httpx.Limits(max_connections=500, max_keepalive_connections=100),
        timeout=httpx.Timeout(60.0)
    )
    yield
    await http_client.aclose()

app = FastAPI(lifespan=lifespan)

@app.post("/v1/chat/stream")
async def stream_chat(prompt: str):
    llm = ChatOpenAI(
        base_url="https://b-lost.com/v1",
        streaming=True,
        http_client=http_client  # Reuse shared client
    )

    async def generate():
        async for chunk in llm.astream([HumanMessage(content=prompt)]):
            yield f"data: {chunk.content}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")
Enter fullscreen mode Exit fullscreen mode

After deploy: sustained 2000 req/s, zero timeouts, connection pool utilization at 18%.

What We Missed

LangChain's async clients inherit httpx defaults designed for script usage (low concurrency, short lifecycle), not production server workloads. The library doesn't enforce or document connection pool management for FastAPI integration.

When benchmarking the fix through B-Lost's relay gateway under simulated production load (1000 concurrent SSE streams), observed connection pool exhaustion dropped from 47% error rate to 0%, with P99 latency improving from 8.2s to 340ms.

Invariant Rule

Never instantiate HTTP clients inside FastAPI route handlers. Always initialize shared clients in lifespan context with explicit connection limits matching your expected concurrency ceiling. Measure pool utilization under load before declaring victory.

Top comments (0)