iToverDose/Software· 5 JUNI 2026 · 12:03

Echtzeit-Textstreaming im Browser: So funktioniert die SSE-Produktionslösung

Ein Ladekreisel verrät nicht, was wirklich passiert. Doch mit Server-Sent Events lässt sich Text tokenweise an den Browser streamen – wie bei ChatGPT. So gelingt die Integration in Next.js ohne zusätzliche Bibliotheken.

DEV Community5 min0 Kommentare

Eine sich drehende Ladeanzeige ist kein Fortschritt – sie gaukelt nur Aktivität vor, ohne Klarheit zu schaffen. Genau das passiert, wenn ein Sprachmodell wie bei spectr-ai einen Sicherheitsbericht generiert: Nach 15 bis 40 Sekunden liegt das Ergebnis plötzlich vor, doch der Nutzer starrt in der Zwischenzeit in eine leere Seite. Der Unterschied liegt im Streaming: Wird jeder Token sofort an den Browser gesendet, entsteht der Eindruck eines fließenden Textes, als würde jemand live tippen. Dieselbe Wartezeit, aber ein völlig anderes Erlebnis.

Warum Server-Sent Events (SSE) die bessere Wahl sind

Die naheliegende Lösung wäre der Einsatz von EventSource – die Browser-API für Server-Sent Events ist einfach zu nutzen und übernimmt sogar die automatische Wiederverbindung bei Abbrüchen. Doch hier liegt der Haken: EventSource unterstützt ausschließlich GET-Anfragen. Da spectr-ai jedoch eine POST-Anfrage mit Vertragsdaten und Modellauswahl im Body benötigt, scheidet diese Option aus. Stattdessen setzt die Lösung auf fetch mit manueller Stream-Verarbeitung über response.body.getReader().

Der entscheidende Vorteil: Diese Methode ermöglicht den Einsatz von AbortController, um Anfragen gezielt abzubrechen – eine Funktion, die EventSource nicht direkt anbietet. Für einmalige LLM-Anfragen ist eine automatische Wiederverbindung ohnehin kontraproduktiv: Ein erneuter Verbindungsaufbau würde die Generierung neu starten und doppelte Kosten verursachen.

| Anforderung | EventSource | fetch + Reader | |-------------|------------|----------------| | GET-Anfrage | Ja | Nein | | POST-Anfrage mit Body | Nein | Ja | | Benutzerdefinierte Header (z. B. Auth) | Nein | Ja | | Manuelle Abbruchsteuerung | Umständlich | Einfach (AbortController) | | Automatische Wiederverbindung | Ja | Muss selbst implementiert werden |

Der Kern: Sprachmodell-Stream in Textfragmente umwandeln

Sprachmodelle wie Ollama oder Claude unterstützen Streaming – doch ihre Ausgabeformate unterscheiden sich. Ollama bietet ein OpenAI-kompatibles Endpunkt unter /v1/chat/completions, das mit stream: true Server-Sent Events im SSE-Format zurückgibt: Jede Zeile beginnt mit data: {json}\n\n, und das Ende wird mit data: [DONE] markiert. Die Serverkomponente übernimmt hier eine Doppelrolle: Sie agiert sowohl als SSE-Client (liest die Modellausgabe) als auch als SSE-Server (sendet die Tokens an den Browser).

Die folgende Funktion wandelt den HTTP-Stream des Modells in ein asynchrones Iterator-Objekt um, das einzelne Textfragmente liefert:

// lib/stream-model.ts
interface ChatChunk {
  choices: { delta: { content?: string } }[];
}

export async function* streamModel(
  prompt: string,
  contract: string,
  signal: AbortSignal,
): AsyncGenerator<string> {
  const response = await fetch(
    "
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      signal,
      body: JSON.stringify({
        model: "qwen2.5-coder:7b",
        stream: true,
        messages: [
          { role: "system", content: prompt },
          { role: "user", content: contract },
        ],
      }),
    },
  );

  if (!response.ok || !response.body) {
    throw new Error(`Modellanfrage fehlgeschlagen: ${response.status}`);
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? "";

    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed.startsWith("data: ")) continue;

      const payload = trimmed.slice(6);
      if (payload === "[DONE]") return;

      const chunk = JSON.parse(payload) as ChatChunk;
      const token = chunk.choices[0]?.delta?.content;
      if (token) yield token;
    }
  }
}

Der entscheidende Trick liegt in der Pufferverwaltung: Netzwerkpakete kommen nicht immer als vollständige Zeilen an. Ein einzelner read()-Aufruf könnte eine unvollständige data:-Zeile zurückgeben. Durch Aufteilung nach Zeilenumbrüchen und das Behalten des letzten Fragments bis zum nächsten Lesevorgang wird verhindert, dass versucht wird, ein abgeschnittenes JSON-Objekt zu parsen.

Für Claude gestaltet sich die Integration ähnlich, allerdings nutzt die Anthropic-SDK ein eigenes Typensystem mit content_block_delta-Events. Aus Sicht des Servers bleibt die Schnittstelle jedoch identisch: ein asynchroner Generator, der Textfragmente liefert. Ein Austausch des Backend-Codes für Claude würde den Rest der Implementierung unverändert lassen.

Der Server: Route-Handler für dynamische Token-Streaming

Im nächsten Schritt wird der Generator in einen ReadableStream eingebettet und über einen Next.js-Route-Handler bereitgestellt. Jeder Token wird als separates SSE-Event formatiert, sodass der Client ihn sofort rendern kann.

// app/api/report/route.ts
import { NextRequest } from "next/server";
import { streamModel } from "@/lib/stream-model";

export const runtime = "nodejs";
export const maxDuration = 60;

interface TokenEvent {
  type: "token";
  text: string;
}

interface DoneEvent {
  type: "done";
}

interface ErrorEvent {
  type: "error";
  message: string;
}

type ReportEvent = TokenEvent | DoneEvent | ErrorEvent;

export async function POST(request: NextRequest) {
  const { contract } = (await request.json()) as { contract: string };

  if (!contract?.trim()) {
    return Response.json({ error: "Kein Vertrag angegeben" }, { status: 400 });
  }

  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      function send(event: ReportEvent) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify(event)}\n\n`),
        );
      }

      try {
        for await (const token of streamModel(
          SYSTEM_PROMPT,
          contract,
          request.signal,
        )) {
          send({ type: "token", text: token });
        }
        send({ type: "done" });
      } catch (err) {
        if (request.signal.aborted) return; // Nutzer hat die Seite verlassen
        const message = err instanceof Error ? err.message : "Generierung fehlgeschlagen";
        send({ type: "error", message });
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    },
  });
}

Drei Aspekte dieser Implementierung sind besonders wertvoll:

  • Durchgängige Signal-Weiterleitung: Das Abbruch-Signal des Browsers wird über request.signal an Next.js übergeben, fließt weiter in streamModel und schließlich zur Modell-Anfrage. So lässt sich die Generierung zu jedem Zeitpunkt unterbrechen – ohne manuelle Nacharbeit.
  • Robuste Fehlerbehandlung: Tritt ein Fehler auf, wird ein error-Event gesendet. Der Client kann darauf reagieren, etwa durch eine Benachrichtigung oder einen Neustart.
  • Effiziente Pufferung: Die Header-Einstellungen Cache-Control, Connection und X-Accel-Buffering stellen sicher, dass der Stream ohne Verzögerungen oder Zwischenspeicherung übertragen wird.

Fazit: Streaming als Standard für Echtzeit-Erlebnisse

Die Integration von Server-Sent Events in eine Next.js-Anwendung mag auf den ersten Blick komplex wirken, doch die Vorteile überwiegen deutlich. Nutzer erhalten sofortiges Feedback, während die Generierung läuft – ein Erlebnis, das an Tools wie ChatGPT erinnert. Die hier vorgestellte Architektur ist modular: Der Kern-Code für das Streaming bleibt unverändert, selbst wenn das Backend-Modell gewechselt wird. Für Entwickler, die Echtzeit-Textgenerierung in Webanwendungen integrieren möchten, bietet diese Lösung eine solide Grundlage – skalierbar, wartbar und ohne externe Abhängigkeiten.

Die Zukunft gehört Anwendungen, die Nutzer nicht im Ungewissen lassen. Mit SSE wird Wartezeit nicht nur verkürzt, sondern in ein interaktives Erlebnis verwandelt.

KI-Zusammenfassung

Learn how to stream LLM tokens to browsers using Next.js 15 and SSE. Avoid blank screens with real-time AI report generation and efficient error handling.

Kommentare

00
KOMMENTAR SCHREIBEN
ID #BBB5UK

0 / 1200 ZEICHEN

Menschen-Check

2 + 2 = ?

Erscheint nach redaktioneller Prüfung

Moderation · Spam-Schutz aktiv

Noch keine Kommentare. Sei der erste.