Skip to the solution
HardFrontend11 min read

How do you stream responses and Server-Sent Events from a Route Handler?

121 views

The problem

Question presented to candidate: "We want a live order-status feed and a chat endpoint that shows an AI answer word by word. How would you stream from a Next.js Route Handler, and what would you check before it goes to production?"

What a strong answer should cover:

  • Return a Web Response whose body is a ReadableStream; enqueue encoded chunks as they are produced and close the stream when done.
  • Server-Sent Events use Content-Type: text/event-stream and frames like id:, event:, data: ending in a blank line; the browser EventSource parses them and reconnects automatically (honouring retry:).
  • Clean up on disconnect with request.signal and the stream's cancel(); stop timers and upstream work.
  • Use pull() for backpressure when wrapping a producer (an LLM, a file, a database cursor).
  • Production checks: proxies and CDNs that buffer (X-Accel-Buffering: no, Cache-Control: no-cache, no-transform), the Node.js runtime and platform time limits, and sending a first byte early because headers go out with the first chunk.

Clarifying questions expected:

  • "Is the traffic one-way server to client?" β€” SSE fits; two-way needs WebSockets or a separate service.
  • "Where is it deployed, and what sits in front?" β€” serverless limits and buffering proxies decide feasibility.

Code / implementation expected: Yes β€” an SSE Route Handler with cleanup, and an EventSource client.

  • nextjs
  • streaming
  • route-handlers
  • sse

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read Β· 8 sections

Read the code

app/api/events/route.ts: a Server-Sent Events endpoint with cleanup on disconnect
// app/api/events/route.ts
export async function GET(request: Request) {
  const encoder = new TextEncoder();
  let timer: ReturnType<typeof setInterval> | undefined;
  let id = 0;

  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      const send = (event: string, data: unknown) => {
        id++;
        controller.enqueue(encoder.encode(`id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
      };
      controller.enqueue(encoder.encode(`retry: 3000\n: connected\n\n`));
      timer = setInterval(() => {
        send("tick", { n: id + 1, at: new Date().toISOString() });
        if (id === 5) { send("done", { ok: true }); clearInterval(timer); controller.close(); }
      }, 500);

      request.signal.addEventListener("abort", () => {
        clearInterval(timer);
        console.log(`[sse] client disconnected after ${id} events`);
        try { controller.close(); } catch {}
      });
    },
    cancel() {
      clearInterval(timer);
      console.log("[sse] stream cancelled");
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    },
  });
}
app/api/text/route.ts: streaming generated text from a POST with pull()
// app/api/text/route.ts
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const WORDS = ["Streaming ", "lets ", "the ", "user ", "read ", "early."];

export async function POST(request: Request) {
  const { prompt } = await request.json();
  const encoder = new TextEncoder();
  let i = 0;

  const stream = new ReadableStream<Uint8Array>({
    // pull() is called only when the consumer wants more: natural backpressure
    async pull(controller) {
      if (i === WORDS.length) return controller.close();
      await sleep(300); // stand-in for an LLM producing the next token
      controller.enqueue(encoder.encode(WORDS[i++]));
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8", "X-Prompt-Length": String(prompt.length) },
  });
}
A Client Component consuming the SSE stream with EventSource
// app/live/Feed.tsx
"use client";
import { useEffect, useState } from "react";
export default function Feed() {
  const [lines, setLines] = useState<string[]>([]);
  useEffect(() => {
    const es = new EventSource("/api/events");
    es.addEventListener("tick", (e) => setLines((l) => [...l, (e as MessageEvent).data]));
    es.addEventListener("done", () => es.close());
    return () => es.close();
  }, []);
  return <ul id="feed">{lines.map((l, i) => <li key={i}>{l}</li>)}</ul>;
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 124 of 129 in Next.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Next.js questions
Up nextHard

How do you internationalize (i18n) a Next.js App Router app?

Continue