taskId,
type: 'error',
payload: { message: err instanceof Error ? err.message : 'Unknown failure' }
});
} finally {
controller.close();
}
},
cancel: () => {
this.abortController.abort();
}
});
}
private async monitorTask(taskId: string, controller: ReadableStreamDefaultController<Uint8Array>) {
const maxRetries = 45;
let attempts = 0;
while (attempts < maxRetries && !this.abortController.signal.aborted) {
const status = await this.fetchTaskStatus(taskId);
this.sendEvent(controller, {
id: taskId,
type: status.state === 'finished' ? 'complete' : 'progress',
payload: {
progress: status.percent,
stage: status.currentStep,
result: status.state === 'finished' ? status.output : null
}
});
if (status.state === 'finished' || status.state === 'failed') break;
await this.delay(1500);
attempts++;
}
if (attempts >= maxRetries) {
this.sendEvent(controller, {
id: taskId,
type: 'error',
payload: { message: 'Operation exceeded maximum duration' }
});
}
}
private sendEvent(controller: ReadableStreamDefaultController<Uint8Array>, event: StreamPayload) {
const formatted = id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event.payload)}\n\n;
controller.enqueue(this.encoder.encode(formatted));
}
private delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
private async fetchTaskStatus(id: string) {
// Replace with actual queue/database lookup
return {
state: 'processing',
percent: Math.floor(Math.random() * 100),
currentStep: 'rendering',
output: null
};
}
}
### Step 2: API Route Configuration
The route handler must attach streaming headers and instantiate the controller. Missing headers cause browsers to buffer the response until completion, defeating the purpose of streaming.
```typescript
// app/api/tasks/stream/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { TaskStreamController } from '@/lib/stream-controller';
export async function GET(request: NextRequest) {
const taskId = request.nextUrl.searchParams.get('taskId');
if (!taskId) {
return NextResponse.json({ error: 'taskId required' }, { status: 400 });
}
const controller = new TaskStreamController();
const stream = controller.createStream(taskId);
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disables Nginx buffering
'CDN-Cache-Control': 'no-cache'
}
});
}
Step 3: Client-Side Consumption with State Machine
Raw EventSource usage leads to fragile state updates. We'll implement a reducer-based hook that handles connection lifecycle, event parsing, and automatic cleanup.
// hooks/useTaskStream.ts
'use client';
import { useState, useEffect, useRef, useReducer, useCallback } from 'react';
type StreamStatus = 'idle' | 'connecting' | 'active' | 'completed' | 'failed';
interface StreamState {
status: StreamStatus;
progress: number;
currentStage: string;
result: unknown;
error: string | null;
}
type StreamAction =
| { type: 'CONNECT' }
| { type: 'UPDATE'; payload: Partial<StreamState> }
| { type: 'COMPLETE'; payload: Partial<StreamState> }
| { type: 'FAIL'; error: string }
| { type: 'DISCONNECT' };
function streamReducer(state: StreamState, action: StreamAction): StreamState {
switch (action.type) {
case 'CONNECT':
return { ...state, status: 'connecting', error: null };
case 'UPDATE':
return { ...state, status: 'active', ...action.payload };
case 'COMPLETE':
return { ...state, status: 'completed', ...action.payload };
case 'FAIL':
return { ...state, status: 'failed', error: action.error };
case 'DISCONNECT':
return { ...state, status: 'idle' };
default:
return state;
}
}
export function useTaskStream(taskId: string | null) {
const [state, dispatch] = useReducer(streamReducer, {
status: 'idle',
progress: 0,
currentStage: '',
result: null,
error: null
});
const sourceRef = useRef<EventSource | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const connect = useCallback(() => {
if (!taskId) return;
dispatch({ type: 'CONNECT' });
sourceRef.current?.close();
const source = new EventSource(`/api/tasks/stream?taskId=${taskId}`);
sourceRef.current = source;
source.onopen = () => dispatch({ type: 'CONNECT' });
source.addEventListener('progress', (e) => {
const data = JSON.parse((e as MessageEvent).data);
dispatch({ type: 'UPDATE', payload: { progress: data.progress, currentStage: data.stage } });
});
source.addEventListener('complete', (e) => {
const data = JSON.parse((e as MessageEvent).data);
dispatch({ type: 'COMPLETE', payload: { progress: 100, result: data.result } });
source.close();
});
source.addEventListener('error', (e) => {
const data = JSON.parse((e as MessageEvent).data);
dispatch({ type: 'FAIL', error: data.message || 'Stream terminated unexpectedly' });
source.close();
});
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) {
dispatch({ type: 'FAIL', error: 'Connection lost' });
}
};
}, [taskId]);
useEffect(() => {
if (taskId) connect();
return () => {
sourceRef.current?.close();
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
};
}, [taskId, connect]);
return { ...state, reconnect: connect };
}
Architecture Decisions & Rationale
- SSE Event Typing: Instead of relying on generic
onmessage, we explicitly listen for progress, complete, and error events. This prevents accidental state corruption when the server sends metadata or heartbeat signals.
- Reducer Pattern:
useReducer centralizes state transitions, making it trivial to add logging, analytics, or undo functionality later. It also prevents stale closure bugs common with useState in async loops.
- AbortController Integration: The server stream respects client disconnection. When the browser closes the tab or navigates away, the
cancel callback triggers, freeing database connections and stopping unnecessary polling.
- Header Hardening:
X-Accel-Buffering: no and CDN-Cache-Control: no-cache prevent reverse proxies and edge networks from buffering the stream. Without these, many deployments experience 5-10 second delays before the first chunk arrives.
Pitfall Guide
1. Missing Cache-Control: no-cache
Explanation: Browsers and CDNs aggressively cache responses. Without explicit cache directives, the entire stream buffers until completion, then delivers everything at once.
Fix: Always include Cache-Control: no-cache, no-transform and Connection: keep-alive in the response headers.
2. Blocking the Event Loop Inside Stream Callbacks
Explanation: Synchronous operations (heavy JSON parsing, synchronous file I/O, or CPU-bound calculations) inside start() or enqueue() block Node's event loop, causing backpressure and dropped connections.
Fix: Offload heavy processing to worker threads or queue systems. Keep stream callbacks strictly I/O-bound and asynchronous.
3. Ignoring Browser Connection Limits
Explanation: Browsers cap concurrent EventSource connections per origin (typically 6). Opening multiple streams simultaneously causes queuing or silent failures.
Fix: Implement a connection pool or multiplex multiple tasks over a single stream using event IDs. Close unused streams immediately when components unmount.
4. Tab Visibility Resource Leaks
Explanation: Background tabs keep EventSource connections alive, consuming server memory and database connections. Modern browsers throttle timers in hidden tabs, causing polling drift.
Fix: Listen to document.visibilitychange. Close the stream when hidden, and implement a lightweight heartbeat or re-fetch strategy when the tab regains focus.
Explanation: Streaming does not extend function execution limits. Vercel's 60-second (Pro) or 10-second (Hobby) timeout still applies. Long-running tasks will be killed mid-stream.
Fix: Offload work to background queues (Redis, BullMQ, AWS SQS). Use streaming only for status polling, not for executing the actual computation.
Explanation: The SSE specification requires strict formatting: event: name\ndata: payload\n\n. Missing double newlines or incorrect prefixes cause the browser to ignore events or throw parsing errors.
Fix: Use a formatter utility that enforces the spec. Always validate with browser DevTools Network tab → EventStream sub-tab before deploying.
7. No Retry or Backoff Strategy
Explanation: Network interruptions are inevitable. EventSource auto-reconnects, but without exponential backoff, it can trigger connection storms during outages.
Fix: Implement a custom reconnect loop with jittered delays. Track connection attempts and switch to a fallback polling mechanism after 3 consecutive failures.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Dashboard with mixed static/async sections | React Suspense | Renders shell immediately, defers heavy data fetching | Low (server CPU only) |
| AI text generation or real-time transcription | ReadableStream + SSE | Delivers incremental tokens, enables typewriter UI | Medium (persistent connections) |
| Background job monitoring (reports, exports) | ReadableStream + Polling | Decouples execution from delivery, survives timeouts | Low-Medium (queue infrastructure) |
| Multi-tenant SaaS with high concurrency | WebSockets or Server-Sent Events + Connection Pooling | SSE connection limits cause bottlenecks at scale | High (infrastructure + monitoring) |
| Static site with occasional async data | SWR/React Query + Suspense | Caching reduces redundant requests, simpler mental model | Low |
Configuration Template
// lib/sse-helpers.ts
export const SSE_HEADERS = {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
'CDN-Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*', // Adjust for your CORS policy
} as const;
export function formatSSEEvent(event: string, data: unknown, id?: string): string {
const lines: string[] = [];
if (id) lines.push(`id: ${id}`);
lines.push(`event: ${event}`);
lines.push(`data: ${JSON.stringify(data)}`);
lines.push(''); // Double newline required by spec
return lines.join('\n');
}
export function createSSEStream(generator: (controller: ReadableStreamDefaultController<Uint8Array>) => Promise<void>): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream({
async start(controller) {
try {
await generator(controller);
} catch (err) {
controller.enqueue(encoder.encode(formatSSEEvent('error', { message: err instanceof Error ? err.message : 'Stream failure' })));
} finally {
controller.close();
}
}
});
}
Quick Start Guide
- Create the API route: Copy the
SSE_HEADERS and createSSEStream utilities into your project. Set up a new route under app/api/stream/route.ts that returns a NextResponse with the stream and headers.
- Implement the generator: Inside
createSSEStream, write your async loop. Use controller.enqueue(encoder.encode(formatSSEEvent('progress', data))) to push updates. Add a delay or queue listener between iterations.
- Build the client hook: Use the
useTaskStream pattern with useReducer. Pass your taskId to the hook. Render progress bars, status text, or typewriter effects based on the reducer state.
- Test in DevTools: Open Network tab → filter by
eventsource. Verify each chunk arrives independently. Check that Cache-Control headers are present. Simulate network throttling to validate reconnection logic.
- Deploy with monitoring: Add stream lifecycle logging. Set up alerts for connection drops or timeout spikes. Verify your hosting platform's timeout configuration matches your expected stream duration.