Long-running operations create a UX problem. A request that takes 10-30 seconds leaves users staring at a spinner with no indication of whether anything is happening. Streaming lets you send partial results as they become available — the user sees progress rather than nothing.
Next.js App Router has solid streaming support through two approaches: React Suspense for component-level streaming, and ReadableStream/Server-Sent Events for API-level streaming. Here's how both work in production, including the pattern I use for long-running generation tasks.
When to Use Each Approach
React Suspense streaming: Best for streaming HTML from Server Components. The page shell renders immediately, then sections fill in as data becomes available. Built into App Router, minimal additional code.
ReadableStream / SSE: Best for streaming data from API routes to client components — progress updates, chunked text generation, status updates for background jobs. More control, more code.
React Suspense Streaming — The Simpler Case
// app/dashboard/page.js
import { Suspense } from 'react';
import { UserStats } from '@/components/UserStats';
import { RecentActivity } from '@/components/RecentActivity';
import { StatsSkeleton, ActivitySkeleton } from '@/components/Skeletons';
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-6 p-6">
{/* Renders immediately */}
<h1 className="col-span-2 text-2xl font-semibold">Dashboard</h1>
{/* Streams in when data is ready */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
{/* Streams in independently */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
// Server Component — awaits its own data
async function UserStats() {
const stats = await fetchUserStats(); // Can take 1-3 seconds
return <StatsDisplay stats={stats} />;
}
Each Suspense boundary streams independently. The page shell (heading) renders immediately. Stats and activity sections each stream in as their data becomes available, without waiting for each other.
ReadableStream API Routes — Streaming Data
For streaming progress updates from an API route:
// app/api/generate/stream/route.ts
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const jobId = searchParams.get('jobId');
if (!jobId) {
return new Response('Missing jobId', { status: 400 });
}
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const sendUpdate = (data: object) => {
const line = `data: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(line));
};
try {
// Poll until complete or timeout
const maxAttempts = 60;
let attempts = 0;
while (attempts < maxAttempts) {
const status = await checkJobStatus(jobId);
sendUpdate({
status: status.state,
progress: status.progress,
message: status.message,
});
if (status.state === 'complete') {
sendUpdate({ status: 'complete', url: status.outputUrl });
break;
}
if (status.state === 'error') {
sendUpdate({ status: 'error', error: status.error });
break;
}
await sleep(2000); // Poll every 2 seconds
attempts++;
}
if (attempts >= maxAttempts) {
sendUpdate({ status: 'error', error: 'Timeout' });
}
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Client-Side SSE Consumer
// hooks/useJobStream.ts
'use client';
import { useState, useEffect, useRef } from 'react';
type JobStatus = 'idle' | 'pending' | 'complete' | 'error';
interface JobState {
status: JobStatus;
progress: number;
message: string;
outputUrl: string | null;
error: string | null;
}
export function useJobStream(jobId: string | null) {
const [state, setState] = useState<JobState>({
status: 'idle',
progress: 0,
message: '',
outputUrl: null,
error: null,
});
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!jobId) return;
// Close any existing connection
eventSourceRef.current?.close();
const eventSource = new EventSource(
`/api/generate/stream?jobId=${jobId}`
);
eventSourceRef.current = eventSource;
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
setState(prev => ({
...prev,
status: data.status ?? prev.status,
progress: data.progress ?? prev.progress,
message: data.message ?? prev.message,
outputUrl: data.url ?? prev.outputUrl,
error: data.error ?? prev.error,
}));
if (data.status === 'complete' || data.status === 'error') {
eventSource.close();
}
};
eventSource.onerror = () => {
setState(prev => ({
...prev,
status: 'error',
error: 'Connection failed',
}));
eventSource.close();
};
return () => {
eventSource.close();
};
}, [jobId]);
return state;
}
Usage in a component:
'use client';
import { useJobStream } from '@/hooks/useJobStream';
function GenerationProgress({ jobId }) {
const { status, progress, message, outputUrl, error } = useJobStream(jobId);
if (status === 'complete' && outputUrl) {
return <img src={outputUrl} alt="Generated" className="rounded-xl" />;
}
if (status === 'error') {
return <p className="text-red-500">{error}</p>;
}
return (
<div className="flex flex-col gap-3 p-6">
<div className="h-2 bg-neutral-200 rounded-full overflow-hidden">
<div
className="h-full bg-orange-500 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-sm text-muted">{message || 'Processing...'}</p>
</div>
);
}
Streaming Text Generation
For streaming text responses (like AI-generated text):
// app/api/describe/route.ts
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const { prompt } = await request.json();
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Simulate streaming text chunks
// In production, this would come from an AI API that supports streaming
const words = generateDescription(prompt).split(' ');
for (const word of words) {
controller.enqueue(encoder.encode(word + ' '));
await sleep(50); // Simulate streaming delay
}
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
Connection Management Edge Cases
Handle browser tab visibility. EventSource reconnects automatically on network interruption, but connections left open in background tabs waste server resources.
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
eventSourceRef.current?.close();
} else if (jobId && state.status === 'pending') {
// Reconnect when tab becomes visible again
reconnect();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [jobId, state.status]);
Set server-side timeouts. Long-running streams should have a maximum duration to prevent resource leaks.
Handle the Vercel timeout. Vercel's function timeout is 60 seconds by default (10 seconds on Hobby). For longer operations, use edge functions (which support streaming better) or ensure your polling approach completes within the timeout window.
Testing Streaming Endpoints
Standard fetch-based tests don't work well for streaming. A few approaches:
// Integration test approach
test('stream sends progress updates then complete', async () => {
const jobId = await createTestJob();
const updates: any[] = [];
await new Promise<void>((resolve, reject) => {
const es = new EventSource(`/api/generate/stream?jobId=${jobId}`);
es.onmessage = (event) => {
const data = JSON.parse(event.data);
updates.push(data);
if (data.status === 'complete' || data.status === 'error') {
es.close();
resolve();
}
};
es.onerror = () => {
es.close();
reject(new Error('Stream error'));
};
// Timeout
setTimeout(() => {
es.close();
reject(new Error('Stream timeout'));
}, 30000);
});
// Verify updates sequence
const statuses = updates.map(u => u.status);
expect(statuses).toContain('pending');
expect(statuses[statuses.length - 1]).toBe('complete');
});
Manual testing: Browser DevTools → Network tab → filter by "EventStream" type → click a stream request → see the "EventStream" sub-tab showing each message as it arrives. The most useful debugging tool for SSE in development.
Summary
React Suspense streaming handles the common case of streaming HTML from Server Components with minimal code — wrap async components in Suspense boundaries and Next.js handles the rest.
ReadableStream + SSE handles the more complex case of streaming progress updates or text from API routes to client components. The pattern: create a ReadableStream in the route handler, write SSE-formatted events, return with the appropriate headers. Consume with EventSource on the client, update state as events arrive.
The connection management details — cleanup on unmount, tab visibility handling, server-side timeouts — are what separate a production-ready implementation from a demo.
Top comments (0)