n4nAI

Stream LLM responses in NestJS using server-sent events

Learn how to implement nestjs server-sent events llm streaming end to end, from controller setup to OpenAI-compatible client and verification.

n4n Team3 min read591 words

Audio narration

Coming soon — every post will get a voice note here.

Streaming tokens from an LLM into a browser should not require WebSocket scaffolding or custom polling. With nestjs server-sent events llm streaming, you get a single HTTP connection that pushes incremental completions using the native EventSource API, and NestJS has first-class support for it.

Step 1: Scaffold the SSE controller and module

NestJS ships the @Sse() decorator in @nestjs/common. A method decorated with @Sse() must return an Observable<MessageEvent>. The framework sets Content-Type: text/event-stream, disables proxy buffering hints, and keeps the response open until the observable completes.

Create the controller:

import { Controller, Sse, Query } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { MessageEvent } from '@nestjs/common';
import { upstreamStream } from './upstream';

@Controller('llm')
export class LlmController {
  @Sse('stream')
  stream(@Query('prompt') prompt: string): Observable<MessageEvent> {
    if (!prompt) {
      throw new Error('prompt query param required');
    }
    return upstreamStream(prompt).pipe(
      map((token) => ({ data: token } as MessageEvent)),
    );
  }
}

The MessageEvent type is the same shape the browser expects: { data: any }. NestJS serializes it as data: <json>\n\n.

Register it in a module:

import { Module } from '@nestjs/common';
import { LlmController } from './llm.controller';

@Module({ controllers: [LlmController] })
export class LlmModule {}

And import LlmModule into AppModule. No HttpModule is needed because we will use global fetch.

Step 2: Call an OpenAI-compatible streaming endpoint

Most LLM gateways expose an OpenAI-compatible /v1/chat/completions route with stream: true. The wire format is server-sent events: each line starts with data: followed by a JSON delta. A plain fetch in Node 18+ is enough to consume it without extra SDK weight.

import { Observable } from 'rxjs';

const LLM_BASE_URL = process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1';
const LLM_API_KEY = process.env.LLM_API_KEY!;

export function upstreamStream(prompt: string): Observable<string> {
  return new Observable<string>((subscriber) => {
    const abort = new AbortController();
    (async () => {
      const res = await fetch(`${LLM_BASE_URL}/chat/completions`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${LLM_API_KEY}`,
        },
        body: JSON.stringify({
          model: 'gpt-4o-mini',
          messages: [{ role: 'user', content: prompt }],
          stream: true,
        }),
        signal: abort.signal,
      });

      if (!res.ok || !res.body) {
        subscriber.error(new Error(`Upstream failed: ${res.status}`));
        return;
      }

      const reader = res.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(5).trim();
          if (payload === '[DONE]') {
            subscriber.complete();
            return;
          }
          try {
            const json = JSON.parse(payload);
            const token = json.choices?.[0]?.delta?.content;
            if (token) subscriber.next(token);
          } catch {
            // ignore keep-alive comments or partial JSON
          }
        }
      }
      subscriber.complete();
    })().catch((err) => {
      if (err.name !== 'AbortError') subscriber.error(err);
    });

    return () => abort.abort();
  });
}

The AbortController ensures that when the browser closes the tab, the server-side fetch is cancelled. This prevents orphaned upstream connections.

If you want a single OpenAI-compatible endpoint that addresses 240+ models and automatically falls back when a provider is rate-limited, point LLM_BASE_URL at n4n.ai and use the exact same code.

Step 3: Bridge upstream tokens to NestJS SSE

The controller in Step 1 already maps the string observable to MessageEvent. One detail worth adding is explicit error propagation so the client gets a structured event instead of a silent disconnect:

import { catchError, map, of } from 'rxjs';

@Sse('stream')
stream(@Query('prompt') prompt: string): Observable<MessageEvent> {
  if (!prompt) {
    throw new Error('prompt query param required');
  }
  return upstreamStream(prompt).pipe(
    map((token) => ({ data: token } as MessageEvent)),
    catchError((err) =>
      of({ data: { error: err.message } } as MessageEvent),
    ),
  );
}

NestJS flushes each emission immediately. You do not need to touch Response or set Transfer-Encoding.

Step 4: Consume the stream in the browser

EventSource is built into every modern browser. It only supports GET, which is fine for a prompt passed as a query parameter. For authenticated streams, issue a signed cookie or a short-lived ticket.

<div id="output"></div>
<script>
  const prompt = 'Explain the Rust borrow checker in one paragraph';
  const es = new EventSource(`/llm/stream?prompt=${encodeURIComponent(prompt)}`);
  const out = document.getElementById('output');

  es.onmessage = (e) => {
    try {
      const parsed = JSON.parse(e.data);
      if (parsed.error) {
        out.textContent += `\n[error] ${parsed.error}`;
        es.close();
        return;
      }
    } catch {}
    out.textContent += e.data;
  };

  es.onerror = (e) => {
    console.error('stream error', e);
    es.close();
  };
</script>

Because NestJS serializes data as JSON, e.data for a raw string token arrives quoted (e.g. "Hello"). The browser concatenates the quoted string, which is acceptable for plain text. If you want unquoted output, send an object { token: 'Hello' } and handle it in onmessage.

Step 5: Verify end to end

Run the app (npm run start) and test the raw stream with curl:

curl -N "http://localhost:3000/llm/stream?prompt=Hello"

The -N flag disables curl’s output buffering. You should see frames like:

data: "Hello"
data: " there"
data: ","
data: " world"

Check headers separately:

curl -s -D - -o /dev/null "http://localhost:3000/llm/stream?prompt=Hi"

Expect Content-Type: text/event-stream and Cache-Control: no-cache. In a browser, open the HTML from Step 4 and watch the div fill token by token. Chrome DevTools shows the request as text/event-stream with frames arriving incrementally.

Common failure modes

  • Proxy buffering: Nginx or a cloud LB may buffer SSE. Add proxy_buffering off; for the route.
  • CORS: Cross-origin browser calls need @nestjs/cors enabled and Access-Control-Allow-Origin set.
  • Timeouts: Node’s default keepAliveTimeout can cut long generations. Raise it in main.ts: app.getHttpServer().keepAliveTimeout = 120000;

Step 6: Send structured events for production UIs

Token strings are fine for a demo, but real chat interfaces need finish reasons, usage, or role metadata. Extend the event contract:

type LlmEvent =
  | { type: 'token'; value: string }
  | { type: 'done'; reason: string }
  | { type: 'error'; message: string };

// controller map:
map((e: LlmEvent) => ({ data: e } as MessageEvent))

Browser side:

es.onmessage = (e) => {
  const evt = JSON.parse(e.data);
  if (evt.type === 'token') out.textContent += evt.value;
  if (evt.type === 'done') es.close();
  if (evt.type === 'error') console.error(evt.message);
};

This keeps nestjs server-sent events llm streaming compatible with typed frontends and lets you forward provider metadata (cache hits, token counts) when your gateway exposes it.

Why SSE beats WebSockets here

WebSockets give bidirectional channels you do not need for LLM output. SSE rides on HTTP/1.1 or HTTP/2, auto-reconnects via the browser, and passes through most corporate proxies without extra configuration. The server remains stateless per request, which simplifies load balancing and observability.

The pattern above is the minimal viable implementation. It avoids WebSocket state, works through standard proxies, and degrades gracefully when the tab closes. For most LLM chat UIs, nestjs server-sent events llm streaming is the right default.

Tagsnestjsssestreamingnodejs

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All nestjs llm api integration posts →