Back to Blog
Jan 19, 20264 min readOnuzulike Anthony

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.

Full-StackSSENext.jsReal-Time

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 browserEventSource reconnects on disconnect with retry control
  • Simpler server — a plain Response with 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

ts
// 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

tsx
"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:

ts
// 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.