Every real-time feature request seems to arrive with the same assumption baked in: we will need WebSockets. Live notifications, a progress bar for a long job, an AI assistant that types its answer word by word — the reflex is to reach for a bidirectional socket, a dedicated server, and a reconnection library. But most of these features only push data in one direction, from the server to the browser. For that job, Server-Sent Events are dramatically simpler, and in Next.js they fit neatly into a route handler you already know how to write.
Server-Sent Events, or SSE, are a browser standard for streaming a long-lived response over ordinary HTTP. The client opens a connection with the built-in EventSource API, the server keeps the response open and writes text frames as things happen, and the browser reconnects on its own if the link drops. There is no protocol upgrade, no separate port, and no extra dependency. This article walks through when SSE beats WebSockets, how the wire format works, how to build a streaming route handler in the Next.js App Router, and the patterns that matter for reconnection and LLM token streaming.
When Server-Sent Events beat WebSockets
WebSockets earn their complexity when both sides talk constantly — multiplayer games, collaborative editors, chat where typing indicators fly in both directions. SSE is the better tool the moment the traffic is mostly one-way, from server to client. That covers a surprising share of what teams actually build:
- Live dashboards and metrics that refresh as new numbers land on the server.
- Progress updates for long-running jobs such as report generation, imports, or deployments.
- Notifications, activity feeds, and status changes pushed to a logged-in user.
- Streaming AI responses, where a language model emits tokens and the UI renders them as they arrive.
In all of these the browser rarely needs to say anything back mid-stream; a normal POST or a fresh request covers the occasional command. Choosing SSE here removes an entire class of infrastructure — you keep plain HTTP, your existing auth cookies, and your existing load balancer, and you skip the sticky-session and upgrade-header headaches that WebSockets introduce.
How the event stream format works
SSE is almost embarrassingly simple on the wire. The server responds with the content type text/event-stream and then writes UTF-8 text in small frames separated by a blank line. Each frame is built from a few optional fields, one per line:
- data — the payload. Multiple data lines in one frame are joined with a newline, which is handy for multi-line messages.
- event — an optional name so the client can listen for specific event types instead of the generic message event.
- id — an identifier the browser remembers. On reconnect it sends the value back in a Last-Event-ID header so the server can resume where it left off.
- retry — an integer in milliseconds that tells the browser how long to wait before reconnecting after a drop.
A line beginning with a colon is a comment and is ignored; a periodic comment makes a cheap keep-alive that stops idle proxies from closing the connection. Because the whole thing is just text over HTTP, you can watch a stream in your terminal with curl and read exactly what the browser sees.
Building an SSE route handler in Next.js
In the App Router an SSE endpoint is just a route handler that returns a Response wrapping a ReadableStream. You enqueue encoded text frames as events happen and close the controller when the stream is finished. Two segment-config lines matter: set the Node.js runtime so the connection can stay open, and force the route to be dynamic so Next.js never tries to cache or statically render it.
// app/api/stream/route.ts — an SSE endpoint in the App Router
export const runtime = "nodejs" // keep the connection open
export const dynamic = "force-dynamic" // never cache the stream
export async function GET(req: Request) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) => {
// one SSE frame: event line + data line + blank line
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
)
}
// suggest a client reconnect delay, then push updates
controller.enqueue(encoder.encode("retry: 3000\n\n"))
for (let i = 1; i <= 5; i++) {
send("tick", { n: i, at: Date.now() })
await new Promise((r) => setTimeout(r, 1000))
}
// stop the browser from auto-reconnecting when we are done
send("done", { ok: true })
controller.close()
},
})
// abort the loop if the client disconnects
req.signal.addEventListener("abort", () => {})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // disable proxy buffering (nginx)
},
})
}The response headers do the real work. The content type text/event-stream tells the browser to treat this as an event stream; no-cache and no-transform stop intermediaries from buffering; and X-Accel-Buffering set to no disables nginx response buffering so frames flush immediately instead of arriving in one lump at the end. Reading the request abort signal lets you detect when the client disconnects and stop any expensive work — important, because otherwise a closed browser tab can leave a loop running on the server.
SSE versus WebSockets at a glance
The two technologies are not really competitors so much as tools for different shapes of problem. This table lines up the trade-offs that usually decide the call:
| Aspect | Server-Sent Events | WebSockets |
|---|---|---|
| Direction | One-way, server to client | Full duplex, both directions |
| Transport | Plain HTTP, no upgrade | TCP upgrade to the ws protocol |
| Reconnection | Automatic, built into the browser | Manual, you write it yourself |
| Payload | UTF-8 text only | Text and binary frames |
| Setup cost | A route handler and EventSource | A socket server and client library |
One historical caveat: over HTTP/1.1 a browser allows only about six concurrent connections per domain, and each open EventSource counts against that budget across all tabs. HTTP/2 multiplexes many streams over one connection and effectively removes the limit, so serving your app over HTTP/2 — which most modern hosts do by default — makes this a non-issue.
Reconnection and resuming a stream
The feature that makes SSE feel robust for free is automatic reconnection. If the connection drops, the browser waits the retry interval and opens a new request on its own. If your frames included an id field, the browser sends the last one it saw in the Last-Event-ID request header, and a well-written server reads that header to replay only what the client missed. On the client you rarely touch any of this; you attach listeners and let EventSource manage the lifecycle.
// Client: EventSource reconnects automatically on drop
const es = new EventSource("/api/stream")
es.addEventListener("tick", (e) => {
const payload = JSON.parse(e.data)
console.log("tick", payload.n)
})
es.addEventListener("done", () => es.close()) // no reconnect wanted
es.onerror = () => {
// readyState === 0 (CONNECTING) means it is retrying;
// the browser resends the Last-Event-ID header so the
// server can resume from the last id it emitted.
if (es.readyState === EventSource.CLOSED) console.log("closed for good")
}The one thing you must handle deliberately is stopping. EventSource treats a closed connection as a signal to retry, so if you simply end the response the browser will reconnect in a loop. Emit an explicit done event and call close on the client, or keep the stream open until the client navigates away. Inspecting readyState in the error handler tells you whether the browser is retrying, which shows as the CONNECTING state, or has genuinely given up, which shows as CLOSED.
Send a comment line such as a single colon every 15 to 30 seconds as a heartbeat. Idle connections are often killed by load balancers and reverse proxies after a fixed timeout, and a tiny periodic comment keeps the stream alive without polluting your data or triggering any client event.
Streaming LLM tokens over SSE
The pattern that has made SSE popular again is AI chat. When a language model generates a reply, it produces tokens one at a time, and users expect to watch the answer appear rather than staring at a spinner. The Next.js documentation calls out streaming as the common companion to LLM APIs, and SSE is the natural transport: iterate over the model streamed chunks and enqueue each token as a data frame. The provider SDK — Groq, OpenAI, or the Vercel AI SDK — hands you an async iterable, and you forward each delta straight into the ReadableStream.
// app/api/chat/route.ts — stream LLM tokens as SSE frames
export async function POST(req: Request) {
const { prompt } = await req.json()
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const completion = await groq.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages: [{ role: "user", content: prompt }],
stream: true,
})
for await (const chunk of completion) {
const token = chunk.choices[0]?.delta?.content ?? ""
if (token) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(token)}\n\n`))
}
}
controller.enqueue(encoder.encode("event: done\ndata: end\n\n"))
controller.close()
},
})
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
})
}On the client the same EventSource listener appends each token to the visible message, giving that familiar typewriter effect with almost no code. Because it is ordinary HTTP, this works through your existing auth and rate-limiting middleware, and if the user reloads mid-answer the browser reconnects automatically. For chat specifically, remember to close the stream on a done event so a finished answer does not trigger an endless reconnect.
Buffering is the number-one reason an SSE stream appears to hang and then dump everything at once. Compression and proxy buffering are the usual culprits: gzip and nginx will happily hold your frames until the buffer fills. Send Cache-Control no-transform, set X-Accel-Buffering to no for nginx, and avoid wrapping the response in compression middleware so each frame flushes the instant you enqueue it.
Gotchas worth knowing before you ship
SSE is simple, but a few sharp edges catch teams the first time. Keep these in mind:
- EventSource only issues GET requests and cannot send a request body or custom headers. If you need to POST a payload to start the stream, use the fetch API with a readable body reader instead, or pass parameters in the query string.
- The transport is text only. Encode binary data as base64 or send JSON strings; there are no binary frames like the ones WebSockets provide.
- On serverless platforms with short execution limits a long-lived stream can be cut off by the function timeout. Prefer a Node.js runtime or a long-running host for streams that stay open for minutes.
- Always disable caching and buffering. A cached event-stream response is one of the more confusing bugs to diagnose because the endpoint looks correct in isolation.
- Clean up on disconnect. Listen to the request abort signal and stop timers, database cursors, or model streams so a closed tab does not leak server resources.
The takeaway
Before you stand up a WebSocket server, ask which direction the data actually flows. If the answer is mostly server to client — dashboards, progress bars, notifications, streaming AI answers — Server-Sent Events give you the same live experience over plain HTTP, with automatic reconnection handed to you by the browser and an endpoint that is just another Next.js route handler. Reach for WebSockets when you genuinely need a two-way channel; reach for SSE, which is the simpler choice far more often than the default reflex suggests, for everything else.

