Streaming von LLM-Antworten in Next.js: SSE-Muster, die unter Last stabil bleiben

#Streaming von LLM-Antworten Next.js
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Token-für-Token-Streaming ist das, was KI-Features schnell wirken lässt. Das erste Zeichen erscheint fast sofort, Fortschritt ist sichtbar, und Nutzer können mitlesen, bevor das Modell fertig generiert hat. Wenn du Streaming von LLM-Antworten in Next.js allerdings auf dem "Hello World"-Stand ausgeliefert und für erledigt erklärt hast, bist du nur einen Traffic-Peak von einer Wand aus Verbindungsfehlern entfernt.

In diesem Beitrag geht es um die Lücke zwischen einer Streaming-Demo und einem Streaming-Feature, das in Produktion trägt.

Warum SSE für Streaming von LLM-Antworten in Next.js

WebSockets sind der naheliegende Kandidat, wenn man an "Echtzeit" denkt. Beim LLM-Streaming ist der Verkehr aber fast vollständig einseitig: Der Server schiebt Tokens, der Client liest sie. Server-Sent Events (SSE) passen hier besser. SSE nutzt reines HTTP, funktioniert durch die meisten Proxies und CDN-Edge-Knoten ohne Sonderkonfiguration und liefert automatischen Reconnect auf Browser-Ebene gratis mit.

Das Standardmuster in einem Next.js Route Handler sieht ungefähr so aus:

// app/api/chat/route.ts
export async function POST(req: Request) {
  const { prompt } = await req.json();

  const stream = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages: [{ role: 'user', content: prompt }],
    stream: true,
  });

  const encoder = new TextEncoder();
  const readable = new ReadableStream({
    async start(controller) {
      for await (const chunk of stream) {
        const token = chunk.choices[0]?.delta?.content ?? '';
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ token })}\n\n`));
      }
      controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      controller.close();
    },
  });

  return new Response(readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

In der Entwicklung funktioniert das. Unter Last fängt es an zu scheitern.

Die drei Probleme naiver SSE-Implementierungen

1. Backpressure wird ignoriert

Die for await-Schleife oben zieht Tokens aus dem OpenAI-Stream, so schnell sie eintreffen, und schiebt sie in den ReadableStream-Controller, ohne zu prüfen, ob der Client hinterherkommt. Sitzt der Client auf einer langsamen Verbindung, füllt sich irgendwann das TCP-Empfangsfenster des Browsers, aber der serverseitige Controller reiht weiter Chunks ein. Node.js puffert sie im Speicher. Bei genügend gleichzeitigen Requests frisst das RAM, bis der Prozess beendet wird oder der OOM-Killer eingreift.

Die Lösung ist, das Pull-Modell von ReadableStream zu respektieren. Statt start nutzt du die Methode pull, die nur aufgerufen wird, wenn der Konsument bereit für weitere Daten ist:

const readable = new ReadableStream({
  async pull(controller) {
    const { value, done } = await streamIterator.next();
    if (done) {
      controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      controller.close();
    } else {
      const token = value.choices[0]?.delta?.content ?? '';
      if (token) {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ token })}\n\n`));
      }
    }
  },
});

Damit bekommst du Backpressure geschenkt: Der Stream pausiert, sobald der Browser aufhört zu konsumieren.

2. Cancellation wird nicht weitergereicht

Wenn ein Nutzer einen Tab schließt oder wegnavigiert, schließt der Browser die SSE-Verbindung. Der Route Handler erhält ein Abort-Signal auf req.signal. Die naive Implementierung ignoriert es, also läuft der OpenAI-Request weiter, verbraucht Tokens und API-Budget für eine Antwort, die niemand mehr lesen wird.

Reiche das Abort-Signal durch:

export async function POST(req: Request) {
  const { prompt } = await req.json();
  const abortController = new AbortController();

  req.signal.addEventListener('abort', () => {
    abortController.abort();
  });

  const stream = await openai.chat.completions.create(
    { model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], stream: true },
    { signal: abortController.signal }
  );

  // ... Rest des Handlers
}

Trennt sich der Client jetzt, wird der Upstream-API-Call innerhalb von Millisekunden abgebrochen. Bei jedem nennenswerten Traffic-Niveau macht das einen messbaren Unterschied auf deiner OpenAI-Rechnung.

3. Reconnect sendet doppelte Tokens

Die EventSource des Browsers verbindet sich nach einem Abbruch automatisch neu. Standardmäßig spielt sie den Stream von vorn ab, weil der Server nicht festhält, wo der Client aufgehört hat. Entweder zeigt der Client doppelte Tokens an, oder deine Deduplizierungslogik im Frontend erzeugt Komplexität, die sich schlecht testen lässt.

Die SSE-Spezifikation sieht id:-Felder genau dafür vor. Vergib jedem Event eine hochzählende ID und behandle Last-Event-ID auf dem Server:

// Server: Event-IDs vergeben
let eventId = 0;
controller.enqueue(
  encoder.encode(`id: ${eventId++}\ndata: ${JSON.stringify({ token })}\n\n`)
);

// Server: Last-Event-ID beim Reconnect auslesen
const lastId = req.headers.get('last-event-id');
const startFrom = lastId ? parseInt(lastId, 10) + 1 : 0;

Für kurzlebige One-Shot-Completions geht es auch einfacher: Vergib pro Request eine eindeutige Session-ID, puffere abgeschlossene Tokens serverseitig in einem kurzlebigen Store (Redis eignet sich gut, ein In-Memory-LRU-Cache reicht für Single-Instance-Deployments) und spiele beim Reconnect nur das verpasste Segment nach.

Nebenläufigkeit am Edge beherrschen

Next.js Route Handler laufen standardmäßig auf der Node.js-Runtime, aber das obige Streaming-Muster funktioniert auch auf der Edge-Runtime (export const runtime = 'edge'). Die Edge-Runtime hat engere Speichergrenzen, dafür geringere Cold-Start-Latenz, was zu kurzen Streaming-Antworten passt.

Worauf du achten musst: Die Edge-Runtime hat keinen persistenten Speicher, In-Memory-Puffer für Reconnects überleben also keine Invocation-Grenzen. Wenn du Replay beim Reconnect brauchst und auf die Edge deployst, muss der Puffer extern liegen: Redis, Upstash oder ein vergleichbarer KV-Store mit niedriger Latenz.

Für die meisten Teams, die ihr erstes KI-Feature ausliefern, ist die Node.js-Runtime mit einem kurzlebigen In-Process-Puffer ein vernünftiger Startpunkt. Der Wechsel zu Edge plus externem Store lohnt sich, wenn du globale Verteilung oder Time-to-First-Token unter 100 ms für geografisch verteilte Nutzer brauchst.

Fehlerbehandlung und Graceful Degradation

SSE hat keinen eingebauten Fehlerkanal. Ein häufiger Fehler ist, den Stream im Fehlerfall still zu schließen, sodass der Client auf Tokens wartet, die nie kommen.

Sende ein explizites Error-Event, bevor du schließt:

try {
  for await (const chunk of stream) {
    // ... Tokens einreihen
  }
} catch (err) {
  const message = err instanceof Error ? err.message : 'Upstream error';
  controller.enqueue(
    encoder.encode(`event: error\ndata: ${JSON.stringify({ message })}\n\n`)
  );
  controller.close();
}

Auf dem Client hörst du explizit auf den Event-Typ error:

source.addEventListener('error', (e) => {
  const { message } = JSON.parse(e.data);
  setError(message);
  source.close();
});

Damit kann die UI etwas Handlungsrelevantes anzeigen statt eines endlosen Lade-Spinners, den Nutzer zu Recht als kaputte Erfahrung deuten.

Streaming-Endpunkte unter Last testen

Ein paar Werkzeuge, die in deinen Workflow gehören, bevor du einen Streaming-Endpunkt produktionsreif nennst:

k6 eignet sich gut für SSE-Lasttests. Du kannst einen virtuellen Nutzer skripten, der eine SSE-Verbindung öffnet, alle Events liest und prüft, ob das [DONE]-Sentinel innerhalb eines Timeouts ankommt. 50 bis 100 gleichzeitige virtuelle Nutzer vor dem Launch bringen die meisten der oben beschriebenen Backpressure- und Speicherprobleme ans Licht.

Wiremock kann langsame Upstream-Antworten simulieren, was beim Testen der Backpressure-Behandlung hilft, ohne echte API-Kosten zu verursachen.

Für Unit-Tests des Route Handlers selbst erlaubt das Paket @edge-runtime/jest-environment, Edge-kompatible Tests ohne vollständigen Browser laufen zu lassen. Die Pull-Semantik von ReadableStream lässt sich isoliert verifizieren, was Sicherheit gibt, dass die Streaming-Logik korrekt arbeitet, bevor sie in einen Integrationstest eingebettet wird.

Wann du über einen Next.js Route Handler hinausgehen solltest

Für ein kleines Team mit einem einzelnen KI-Feature ist ein Next.js Route Handler der richtige Startpunkt. Er ist schnell ausgeliefert, teilt sich die Authentifizierungs-Middleware und hält die Infrastruktur einfach.

Wenn du an Grenzen stößt, etwa mehrere gleichzeitige Modell-Calls pro Nutzer, Echtzeit-Kollaboration oder Streams, die an mehrere Konsumenten verteilt werden müssen, lohnt sich ein eigener Service. Technologien wie nats.io oder Redis Streams beherrschen Fan-out von Haus aus und puffern für sich neu verbindende Clients, ohne dass du diese Logik selbst verwalten musst.

Ab diesem Punkt wird die Next.js-Schicht zum dünnen Proxy: Sie authentifiziert den Request, öffnet eine Verbindung zum Streaming-Service und leitet Events an den Browser weiter. Die Geschäftslogik liegt woanders und skaliert unabhängig vom Frontend.

Fazit

Streaming von LLM-Antworten in Next.js ist auf Demo-Ebene nicht kompliziert. Sie unter echtem Traffic zuverlässig zu machen, verlangt drei Dinge, die das naive Muster überspringt: Backpressure respektieren, Cancellation weiterreichen und Reconnect ohne doppelte Tokens behandeln.

Nichts davon ist besonders raffiniert. Es ist genau die Art von Detail, die ein Feature, auf das sich deine Nutzer verlassen können, von einem trennt, das in Demos funktioniert und in Produktion still degradiert.

Wenn du KI-Funktionen in ein bestehendes Produkt einbaust und vor der Festlegung auf ein Muster eine zweite Meinung zur Architektur willst, melde dich unter hello@wolf-tech.io. Der Ansatz, der in der Entwicklung funktioniert, und der, der unter Last trägt, sind oft verschieden, und die Lücke früh zu finden ist günstiger, als sie nach dem Launch zu reparieren.

Erfahre mehr darüber, wie Wolf-Tech Custom Software Development und Web Application Development mit Zuverlässigkeit als erstklassigem Anliegen angeht.