Streaming SSE from Next.js Route Handlers Permalink to this section

Part of Node.js Streaming Architecture Basics, under Backend Stream Generation & Connection Management.

Next.js App Router route handlers are Web-standard: they receive a Request and return a Response. That makes Server-Sent Events straightforward in principle — return a Response whose body is a ReadableStream of encoded frames. In practice several Next.js behaviours get in the way: static optimisation that caches the response at build time, a development server that buffers, abort signals that are easy to ignore, and hosting platforms that end functions after a fixed duration. This guide covers each.

Symptom & Developer Intent Permalink to this section

  • The endpoint returns the same few events to every client, forever — it was rendered once at build time.
  • In development, events arrive; in production behind the platform’s edge, they arrive all at once when the function ends.
  • The server keeps producing events for clients that closed the tab.
  • Streams die at a fixed time — 10, 15, 60 or 300 seconds — depending on the host.
  • EventSource receives nothing and the Network panel shows a completed response of type text/plain.

The intent is a route handler that streams per request, stops work when the client leaves, and reconnects cleanly across platform time limits.

Root Cause Analysis Permalink to this section

Route handlers for GET can be statically optimised when they do not read dynamic request data. A handler that ignores the request and returns a stream may be evaluated at build time, freezing its output. Marking the route dynamic prevents it.

Choosing where the route handler runs Decision tree for a Next.js SSE route handler, choosing between a self-hosted Node.js server, a serverless function with reconnect handover, and moving the stream to a dedicated service. Choosing where the route handler runs Self-hosting next start on a server? Node runtime, long streams yes no Streams shorter than the platform limit? Serverless, reconnect at limit yes no Need hours-long or high-fan-out streams? Dedicated streaming service yes no Reconnect-driven design
The code is the same in each case. What changes is how long a single response may live, and therefore how often the client reconnects.

Duration limits come from the hosting model, not from Next.js. A self-hosted next start process is an ordinary long-lived Node.js server; a serverless deployment runs each request in a function with a maximum duration. When the limit is reached the platform ends the response, EventSource reconnects, and — if the stream is resumable — nothing is lost.

Work continuing after the client leaves happens because the stream’s producer does not observe request.signal, the AbortSignal that fires when the client disconnects.

Step-by-Step Resolution Permalink to this section

Step 1 — Return a streaming Response from a dynamic route Permalink to this section

// app/api/events/route.ts
export const dynamic = 'force-dynamic';   // never prerender or cache this route
export const runtime = 'nodejs';          // long-lived connections, full Node APIs

const encoder = new TextEncoder();

export async function GET(request: Request) {
  const lastId = request.headers.get('last-event-id');

  const stream = new ReadableStream({
    async start(controller) {
      const send = (s: string) => controller.enqueue(encoder.encode(s));
      send('retry: 3000\n\n');

      for (const e of await replayAfter(lastId)) send(frame(e));

      const unsubscribe = bus.subscribe((e) => send(frame(e)));
      const hb = setInterval(() => send(': hb\n\n'), 15_000);

      request.signal.addEventListener('abort', () => {   // client left
        clearInterval(hb);
        unsubscribe();
        try { controller.close(); } catch {}
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream; charset=utf-8',
      'Cache-Control': 'no-cache, no-transform',
      'X-Accel-Buffering': 'no',
    },
  });
}

function frame(e: { id: string; type: string; data: unknown }) {
  return `id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`;
}

Reading request.headers already makes the route dynamic, but force-dynamic states the intent explicitly and survives refactoring.

Step 2 — Hand over cleanly at the platform’s duration limit Permalink to this section

On serverless hosts, end the stream a little before the limit with a short retry hint, so the reconnect happens on your terms rather than as an abrupt cut:

export const maxDuration = 300;             // seconds, where the platform allows configuring it

// inside start():
const deadline = setTimeout(() => {
  send('retry: 250\n: handing over\n\n');   // reconnect quickly, with Last-Event-ID
  cleanup();
  controller.close();
}, (maxDuration - 5) * 1000);

With ids on every event and replay on connect, the handover is invisible to users. The pattern is covered generally in handling execution timeouts in serverless SSE, and host-specific limits in SSE on Vercel and Netlify functions.

A long session made of bounded responses Timeline of fifteen minutes in which a serverless SSE route ends each response just before a five-minute limit and the client reconnects immediately with Last-Event-ID. A long session made of bounded responses Response 1 Response 2 Response 3 stream stream stream 0 3 6 9 12 15 minutes handover handover
Each response is bounded by the platform; the session is not. The gaps are a quarter of a second, and replay covers anything published during them.

Step 3 — Use a shared bus, not per-request producers Permalink to this section

In a serverless deployment each request may run in a different instance, so an in-memory EventEmitter only sees events published in the same instance. Subscribe to Redis, a managed pub/sub or a provider’s realtime channel inside the handler, and unsubscribe on abort. On a self-hosted Node.js server, a module-level bus works for a single process; use a broker when running several.

Step 4 — Consume it from a client component Permalink to this section

'use client';
import { useEffect, useState } from 'react';

export function LiveFeed() {
  const [items, setItems] = useState<Item[]>([]);
  useEffect(() => {
    const es = new EventSource('/api/events');
    es.addEventListener('item', (e) => setItems((xs) => [JSON.parse((e as MessageEvent).data), ...xs].slice(0, 100)));
    return () => es.close();
  }, []);
  return <ul>{items.map((i) => <li key={i.id}>{i.text}</li>)}</ul>;
}

EventSource must live in a client component; server components render once and cannot hold a connection. SSE in Next.js App Router client components covers the client side, including sharing one stream across components.

Validation & Monitoring Permalink to this section

# Production build: events must arrive one at a time, not at the end.
next build && next start &
curl -sN http://localhost:3000/api/events | while IFS= read -r l; do echo "$(date +%T) $l"; done

# Abort handling: open and kill streams, then check the bus has no subscribers left.
for i in $(seq 1 50); do timeout 2 curl -sN http://localhost:3000/api/events > /dev/null & done; wait
curl -s http://localhost:3000/api/debug/subscribers    # expect 0
What changes between development, self-hosting and serverless Matrix comparing Next.js development server, self-hosted next start and a serverless deployment on buffering, stream duration and fan-out scope. What changes between development, self-hosting and serverless Aspect next dev next start Serverless host Buffering varies none check edge Max stream length unbounded unbounded platform limit In-memory bus reach one process one process one instance
Test streaming in a production build on the target host. The development server is the least representative of the three.

Monitor the rate of reconnects per session. On serverless hosts it should match the handover interval; a higher rate means streams are ending earlier than planned, usually from an idle timeout that heartbeats should cover.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Can I stream SSE from the Edge runtime?

Yes; the code is the same Web-standard Response. Edge runtimes have their own limits on duration and available APIs, and many cannot hold connections to brokers such as Redis over raw TCP, so check what your event source needs.

Why does my stream work in dev but arrive all at once in production?

Something between the function and the browser buffers the response, or the route was statically rendered. Force dynamic rendering, send the no-transform cache header, and test the deployed URL with curl -N.

Can server actions push events?

No. Server actions are request/response calls from the client. They can publish to the bus that the SSE route subscribes to, which is a good way to trigger events from mutations.

Should I use the Pages Router API routes instead?

They can stream too, via res.write on the Node.js response, but App Router route handlers use Web-standard streams that also run on edge runtimes, which makes them the more portable choice.