Why SSE in Next.js? Why boundaries matter
Most teams start fixing slow queries and sprinkling caches. Senior engineers look at boundaries: separate streaming concerns from domain logic, avoid tight coupling between UI hooks and persistence, and ensure your streaming endpoints are small, composable adapters.
Server‑Sent Events (SSE) are a simple, reliable way to stream one‑way updates (server → client) without WebSocket complexity. In the App Router you implement SSE with the Web Streams API: return a Response whose body is a ReadableStream and set the correct headers so intermediaries and CDNs don’t buffer the stream.
This article shows a production‑safe pattern for next.js server-sent events using the App Router, including ReadableStream usage, heartbeats, Last‑Event‑ID replay, auth, and separation of concerns so you can refactor boundaries instead of endlessly optimizing SQL.
Route Handler: a minimal, resilient SSE endpoint
Key rules:
- Return the Response immediately with a ReadableStream. Put async loops inside start(controller) so Next.js doesn’t buffer the result.
- Add
export const dynamic = "force-dynamic"to disable caching. - Use
request.signalto clean up when the client disconnects. - Set these headers to avoid buffering: Content-Type, Cache-Control, Connection, X-Accel-Buffering.
Example: app/api/metrics/stream/route.ts
export const dynamic = "force-dynamic";
export const runtime = "nodejs"; // or 'edge' where appropriate
export async function GET(request: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Send suggested client retry interval
controller.enqueue(encoder.encode("retry: 5000
"));
// Send an initial snapshot (adapter boundary: fetch state via an adapter)
const snapshot = await metricsAdapter.getSnapshot();
controller.enqueue(encoder.encode(`event: snapshot
data: ${JSON.stringify(snapshot)}
`));
// Subscribe to live updates from an adapter (e.g., Redis pub/sub)
const onUpdate = (event: unknown) => {
controller.enqueue(encoder.encode(`event: metrics
data: ${JSON.stringify(event)}
`));
};
const subscriber = metricsAdapter.subscribe(onUpdate);
// Heartbeat to keep proxies from closing idle connections
const heartbeat = setInterval(() => {
try { controller.enqueue(encoder.encode(': heartbeat
')); } catch { clearInterval(heartbeat); }
}, 20_000);
// Cleanup on disconnect
request.signal.addEventListener('abort', () => {
clearInterval(heartbeat);
subscriber.unsubscribe();
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}
Notes on runtime and caching
- Use
runtime = 'edge'to avoid serverless timeouts on some platforms;nodejscan be fine with longer limits. Check your host. - Always add
dynamic = 'force-dynamic'— SSE must never be cached.
Client: EventSource and a reusable hook
The browser's native EventSource handles automatic reconnects and respects id/Last-Event-ID. Keep the client code in a "use client" component and keep state bounded to avoid memory growth in long‑lived sessions.
Simple reusable hook:
'use client';
import { useEffect, useRef, useState } from 'react';
export function useSSE<T = unknown>(url: string) {
const [data, setData] = useState<T | null>(null);
const [connected, setConnected] = useState(false);
const esRef = useRef<EventSource | null>(null);
useEffect(() => {
const es = new EventSource(url);
esRef.current = es;
es.onopen = () => setConnected(true);
es.onmessage = (e) => setData(JSON.parse(e.data));
es.onerror = () => setConnected(false);
return () => { es.close(); esRef.current = null; };
}, [url]);
return { data, connected };
}
For POST‑based streaming (e.g., streaming an AI completion) EventSource won't work — use fetch + readable stream reader instead and parse SSE frames manually.
Replay, Last‑Event‑ID and durable storage
An important boundary: your SSE endpoint should be an adapter between clients and durable event storage (Redis Streams, Postgres pub/sub, etc.). Implement replay using Last‑Event‑ID so clients reconnect without missing events.
Pattern:
- On connect, read
request.headers.get('last-event-id'). - If present, query your durable store for events after that ID and replay them.
- If the store no longer contains those events, send a fresh snapshot event instead.
This keeps the streaming adapter stateless while the durable store owns ordering and retention.
Production hygiene: heartbeats, headers, and caps
- Heartbeat (comment lines starting with
:) every 15–30s prevents load balancers and proxies from closing idle connections. -
retry: <ms>tells the browser how long to wait before reconnecting. - Send
id:with events so the browser will set Last‑Event‑ID on reconnect and you can resume. - Cap client-side arrays (e.g., last 100 events) to prevent unbounded memory growth in dashboards.
- Avoid server compression on SSE endpoints; proxies may buffer compressed chunks. Use
no-transformandX-Accel-Buffering: nofor nginx.
Auth and security
Authenticate once at connect time inside the route handler. EventSource supports cookies and CORS credentials for GET; for token auth prefer short‑lived query tokens or cookie‑based session auth. Validate authorization inside the route handler before subscribing to the feed — never trust client params alone.
Separating boundaries: adapter pattern (why this matters)
Returning to the opening insight: the fix is often a boundary, not a faster SQL query. Put streaming logic in a small adapter layer:
- metricsAdapter.getSnapshot(), metricsAdapter.subscribe(), persistEvent().
This keeps your React components and data-fetching hooks free from persistence concerns. You can change the backing store (Redis → Postgres → custom stream) without touching client code. When you decouple, bundle size, re-renders, and unexpected cache invalidation problems fall away.
Conclusion
Next.js makes SSE practical via the App Router and ReadableStream, but production correctness depends on architectural boundaries: adapters for persistence, clear replay semantics, and robust route handler patterns (headers, heartbeats, abort cleanup). Refactor boundaries first — then optimize implementations.
If you try this pattern, collect traces around subscribe/unsubscribe and memory usage. And if you've recently refactored a boundary (UI ↔ persistence), I'd love to hear what changed and the performance wins you saw.
Top comments (0)