AISeptember 22, 2026 · 8 min

Real-time AI streaming with Server-Sent Events and TypeScript

The single biggest UX win for an AI feature is streaming. Waiting 20 seconds for a full response feels broken; watching it type feels alive. Server-Sent Events are the right tool for one-way token streaming — simpler than WebSockets and enough for the job. Here's the end-to-end TypeScript implementation, including the parts tutorials skip: cancellation and errors.

Why SSE, not WebSockets

Streaming a generation is one-way: server to client. SSE is built exactly for that — plain HTTP, automatic reconnection, no handshake protocol to manage. WebSockets are for genuine bidirectional real-time (collaboration, multiplayer). For AI token streaming, SSE is less code and fewer failure modes.

The server: emit typed frames

// Route Handler: stream typed SSE frames
export async function POST(req: Request) {
  const { prompt } = await req.json();
  const stream = new ReadableStream({
    async start(controller) {
      const enc = new TextEncoder();
      for await (const tok of llm.stream(prompt)) {
        controller.enqueue(enc.encode(`data: ${JSON.stringify({ t: tok })}\n\n`));
      }
      controller.enqueue(enc.encode('data: [DONE]\n\n'));
      controller.close();
    },
  });
  return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } });
}

The client: read, render, and cancel

The part most examples omit: users abandon generations. If you don't cancel the request, you keep paying for tokens no one will read. An AbortController wired to the fetch stops the stream — and the server should notice the disconnect and stop generating too.

const ac = new AbortController();
const res = await fetch('/api/chat', { method: 'POST', body, signal: ac.signal });
const reader = res.body!.getReader();
const dec = new TextDecoder();
try {
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    for (const f of dec.decode(value).split('\n\n')) {
      if (f.startsWith('data: ')) {
        const p = f.slice(6);
        if (p === '[DONE]') return;
        append(JSON.parse(p).t as string);
      }
    }
  }
} catch (e) { /* aborted or network error — show a retry */ }
// ac.abort() on unmount / stop button

Cancellation isn't a nicety — it's cost control. An abandoned generation you don't cancel is tokens you pay for and throw away. Wire abort to the stop button and to component unmount.

Errors and backpressure

Mid-stream failures happen (rate limits, model errors). Emit a typed error frame so the client can show a retry instead of a half-message that just stops. And respect backpressure: the ReadableStream naturally pauses when the consumer is slow, so you don't buffer an unbounded queue of tokens in memory.

Key takeaways

  • SSE is the right tool for one-way AI token streaming — simpler than WebSockets.
  • Send typed frames (JSON per event) and a clear [DONE] sentinel.
  • Wire an AbortController — cancellation is real cost control, not polish.
  • Emit typed error frames so failures become retries, not frozen messages.

FAQ

Can I stream AI responses without WebSockets?

Yes — Server-Sent Events over plain HTTP are purpose-built for one-way server-to-client streaming, which is exactly what token streaming is. They're simpler to run and auto-reconnect. Use WebSockets only when you need true bidirectional real-time.

How do I stop paying for a response the user abandoned?

Wire an AbortController to the fetch and abort it on the stop button and on component unmount. The server should detect the client disconnect and stop generating. Without cancellation, abandoned generations keep spending tokens you'll never show.

AA
Ali Asghar

Senior software engineer & technical lead — 6+ years shipping production multi-tenant SaaS, payments and AI integration in Next.js, Node & TypeScript.

Keep reading
Architecting a production-grade AI SaaS with Next.js and FastAPI →How to add AI to your SaaS without runaway costs →Building an AI recruiter: matching that runs toward the candidate, not a search box on top of listings →