Server-Sent Events in Next.js: Simpler Than WebSockets

Photo by dpstyles™ on flickr
Server-Sent Events, or SSE, are a browser standard for streaming a long-lived response from server to client over ordinary HTTP using the EventSource API. Unlike WebSockets, SSE is one-way only and does not upgrade the connection, so it is simpler to run. WebSockets add a full two-way channel that is worth the complexity only when the client also needs to push data continuously.
Write a route handler in the App Router that returns a Response wrapping a ReadableStream. Enqueue UTF-8 text frames using a TextEncoder and set the content type to text/event-stream with no-cache headers. Use the Node.js runtime and force the route to be dynamic so the connection stays open and is never cached.
Choose SSE whenever the data flows mostly one way, from server to client. Live dashboards, job progress bars, notifications, and streaming AI answers all fit this shape. Pick WebSockets only when both sides need to send data constantly, such as multiplayer games or collaborative editors.
Yes. The browser waits the retry interval and reopens the connection on its own without any client code. If your frames include an id field, the browser resends it in the Last-Event-ID header so the server can resume from the last event the client received. To stop reconnection, emit a done event and call close on the EventSource.
Yes, and it is the most popular use today. Iterate over the streamed chunks from a model provider such as Groq or OpenAI and enqueue each token as a data frame in the ReadableStream. The client listens with EventSource and appends tokens for a typewriter effect. Disable proxy and gzip buffering so tokens flush immediately rather than arriving all at once.

Photo by dpstyles™ on flickr
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.
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:
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.
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:
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.
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.
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.
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.
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.
SSE is simple, but a few sharp edges catch teams the first time. Keep these in mind:
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.