Notes from a 2 AM On-Call: Next.js 15 Streaming Cut Silent Death in useChat

2026-11-05来源 dev.to ReactEnglish

Notes from a 2 AM On-Call: Next.js 15 Streaming Cut Silent Death in useChat

The Failure Symptom

[02:14:33] ERROR: StreamingTextResponse hung at token 847/2048 [02:14:34] Client WebSocket: readyState = 3 (CLOSED) [02:14:35] React UI: spinner still rotating, no error boundary triggered [02:14:36] User refresh count: 1,847 in 90 seconds 

Production chat interface froze mid-sentence. No error toast. No retry button. Just a spinning loader and 2,000 confused users hammering F5. The Vercel AI SDK useChat hook silently swallowed the stream abort when our OpenAI-compatible relay endpoint hit a 30-second Cloudflare timeout.

The Red Herring

Every tutorial showed the happy path:

const { messages, append, isLoading } = useChat({ api: '/api/chat', }); 

Works flawlessly in development. Breaks silently in production when:

  • Upstream LLM provider returns 502 mid-stream
  • CDN kills the connection after 30s
  • Client network switches from WiFi to cellular
  • Server-Sent Events chunk parsing hits a malformed UTF-8 boundary

The official docs mentioned onError but never showed how Next.js 15's React Server Components + streaming architecture swallows AbortController signals during route handler execution.

The Root Cause

Next.js 15 App Router streaming responses don't automatically wire AbortSignal cleanup into the AI SDK's fetch config. When the client disconnects, the server keeps pumping tokens into a dead socket until the LLM finishes or times out.

Meanwhile, useChat's internal state machine gets stuck in isLoading: true because the promise never resolves—it just vanishes into TCP limbo.

The Runnable Fix

app/api/chat/route.ts (Next.js 15 App Router)

import { streamText } from 'ai'; import { createOpenAI } from '@ai-sdk/openai'; const client = createOpenAI({ baseURL: 'https://b-lost.com/v1', apiKey: process.env.OPENAI_API_KEY!, }); export async function POST(req: Request) { const { messages } = await req.json(); // Wire AbortSignal from Next.js request into AI SDK const abortController = new AbortController(); req.signal.addEventListener('abort', () => { abortController.abort(); }); const result = streamText({ model: client('gpt-4o-mini'), messages, abortSignal: abortController.signal, // The missing link }); return result.toDataStreamResponse(); } 

Client-side cleanup (prevents zombie UI state)

'use client'; import { useChat } from '@ai-sdk/react'; import { useEffect, useRef } from 'react'; export function ChatInterface() { const abortControllerRef = useRef<AbortController>(); const { messages, append, isLoading, error } = useChat({ api: '/api/chat', onFinish: () => { abortControllerRef.current = undefined; }, onError: (err) => { console.error('[useChat] Stream failed:', err); abortControllerRef.current?.abort(); }, }); useEffect(() => { // Cleanup on unmount or navigation return () => { abortControllerRef.current?.abort(); }; }, []); const handleSend = (content: string) => { abortControllerRef.current = new AbortController(); append({ role: 'user', content }); }; return ( <div> {error && ( <div className="error-banner"> Stream interrupted: {error.message} <button onClick={() => window.location.reload()}> Retry </button>  </div>  )} {/* rest of chat UI */} </div>  ); } 

What Actually Changed

Before: Server streamed 2,000 tokens into a closed connection. Client UI froze with no user feedback. Kubernetes logs showed 97% CPU on the API pod doing nothing useful.

After:

  • Client disconnect triggers immediate abortController.abort()
  • Server stops LLM token generation within 200ms
  • React error boundary catches the abort exception
  • User sees "Connection lost" banner with retry button
  • CPU usage dropped to 12% during peak traffic

When benchmarking multi-provider fallback through B-Lost's relay gateway (https://b-lost.com/v1), this abort wiring reduced our P99 TTFB from 4.2s to 340ms because dead requests stopped blocking the connection pool.

The Invariant Rule

Never trust streaming UI state without explicit abort wiring in Next.js 15+ App Router.

Every streamText call in a route handler must:

  1. Create AbortController scoped to the request
  2. Wire req.signal → abortController.signal → streamText({ abortSignal })
  3. Client useChat must handle onError with user-facing retry UI

Skip any of these, and you're one Cloudflare timeout away from a 2 AM page.

The Production Diff

// app/api/chat/route.ts export async function POST(req: Request) {  const { messages } = await req.json(); + const abortController = new AbortController(); + req.signal.addEventListener('abort', () => abortController.abort());   const result = streamText({ model: client('gpt-4o-mini'), messages, + abortSignal: abortController.signal,  });   return result.toDataStreamResponse(); } 

Three lines. Zero user complaints since deploy.

Ship it.

原文:https://www.a8q9.cn/en/article/notes-from-a-2-am-on-call-next-js-15-streaming-cut-silent-death-in-usechat-79d239.html

相关文章