Server-Sent Events for One-Way Real-Time Streaming in Next.js
Why SSE beats WebSockets for one-way data, implementation in Next.js API routes, client EventSource handling, and reconnection.
Server-Sent Events (SSE) is the underrated sibling of WebSockets. For one-way server-to-client streaming — progress updates, download status, notifications — SSE is simpler to implement, works over plain HTTP/2, and handles reconnection automatically. Here's how NodWatch uses it for download progress.
Why SSE Over WebSockets
WebSockets are bidirectional. SSE is unidirectional. For most "show me what's happening" use cases, SSE is the right choice:
- Works over HTTP/2 — no upgrade handshake, multiplexed with other requests
- Auto-reconnect built into the browser —
EventSourcereconnects on disconnect withretrycontrol - Simpler server — a plain
Responsewith the right headers, no socket management - Firewalls and proxies — HTTP connections rarely get blocked; WebSocket upgrades sometimes do
- Load balancers — no sticky sessions needed for read-only streams
The tradeoff: SSE is one-way (server → client only). If you need the client to also send data through the same connection, use WebSockets.
Server Implementation in Next.js
// app/api/downloads/events/route.ts
import { db } from "@/lib/db";
import { getSession } from "@/lib/session";
import { downloadJobs } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function GET(req: Request) {
const session = await getSession();
if (!session) return new Response("Unauthorized", { status: 401 });
const userId = session.user.id;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
function send(event: string, data: unknown) {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
);
}
// Initial state
const jobs = await db.select().from(downloadJobs).where(eq(downloadJobs.userId, userId));
send("init", jobs);
// Poll for updates every 2 seconds
const interval = setInterval(async () => {
try {
const updated = await db
.select()
.from(downloadJobs)
.where(eq(downloadJobs.userId, userId));
send("update", updated);
} catch {
clearInterval(interval);
controller.close();
}
}, 2000);
// Clean up when client disconnects
req.signal.addEventListener("abort", () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // disable nginx buffering
},
});
}SSE Message Format
The protocol is plain text:
event: update
data: {"jobId":"abc","progress":45,"status":"downloading"}
event: complete
data: {"jobId":"abc","filePath":"/downloads/movie.mp4"}
Each message ends with a blank line (\n\n). The event: field is optional — omitting it defaults to message.
You can also send a retry: field to control reconnect interval:
retry: 5000
data: {"status":"reconnecting"}
Client Implementation
"use client";
import { useEffect, useRef, useState } from "react";
type DownloadJob = { jobId: string; progress: number; status: string };
export function DownloadTray() {
const [jobs, setJobs] = useState<DownloadJob[]>([]);
const esRef = useRef<EventSource | null>(null);
useEffect(() => {
const es = new EventSource("/api/downloads/events");
esRef.current = es;
es.addEventListener("init", (e) => {
setJobs(JSON.parse(e.data));
});
es.addEventListener("update", (e) => {
setJobs(JSON.parse(e.data));
});
es.addEventListener("complete", (e) => {
const completed: DownloadJob = JSON.parse(e.data);
setJobs((prev) =>
prev.map((j) => (j.jobId === completed.jobId ? { ...j, status: "done" } : j))
);
});
es.onerror = () => {
// EventSource reconnects automatically after a short delay
// onerror fires on each reconnect attempt — you can use it for UI
};
return () => es.close();
}, []);
return (
<div>
{jobs.map((job) => (
<div key={job.jobId}>
<span>{job.status}</span>
<div style={{ width: `${job.progress}%` }} className="h-1 bg-white" />
</div>
))}
</div>
);
}Handling Reconnection
EventSource reconnects automatically after the connection drops. The browser waits for the retry interval (default ~3 seconds) then opens a new connection. The server sends the Last-Event-ID header on reconnect if you set id: fields in your stream:
id: 42
event: update
data: {...}
On reconnect, the browser sends Last-Event-Id: 42. Use this to resume from where you left off rather than replaying the full state.
Cloudflare Caveat
On Cloudflare Workers, long-lived connections are subject to the 100-second CPU limit. Use a heartbeat to keep the connection alive and poll frequently enough to not time out:
// Heartbeat every 20 seconds
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(": heartbeat\n\n"));
}, 20000);Comment lines (starting with :) are valid SSE but are not dispatched as events — they keep the connection alive without triggering client handlers.