n4nAI

Turn an SSE stream into a JavaScript async iterator

Learn how to wrap a Server-Sent Events feed in a JavaScript async iterator to consume LLM streams cleanly with for-await-of loops step by step.

n4n Team4 min read828 words

Audio narration

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

Server-Sent Events remain the lowest-friction streaming transport for LLM APIs, but the browser’s EventSource can’t send POST bodies or custom auth headers. To consume a POST-based stream cleanly, you need to turn the raw fetch response into an sse async iterator javascript that yields parsed events. The pattern below is dependency-free, runs on Node 18+ and evergreen browsers, and drops straight into a for await...of loop.

Step 1: Recognize what the SSE wire format actually gives you

SSE is a simple line-oriented protocol. Each event is a sequence of fields prefixed by data:, event:, id:, or retry:, terminated by a blank line. A single event may span multiple data: lines; the receiver concatenates them with newlines. Lines starting with a colon are comments and must be ignored.

LLM providers almost always send only data: lines containing JSON, ending with a data: [DONE] sentinel. Knowing this lets you write a minimal parser instead of a full RFC 8625 implementation.

Step 2: Open the stream with fetch and assert invariants

EventSource is GET-only. LLM endpoints require a POST with a bearer token and a stream: true flag, so use fetch. Check the status and content type before touching the body.

async function openStream(messages) {
  const res = await fetch("https://api.example.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Accept": "text/event-stream",
      "Authorization": `Bearer ${process.env.API_KEY}`,
    },
    body: JSON.stringify({
      model: "gpt-4o-mini",
      messages,
      stream: true,
    }),
  });
  if (!res.ok) throw new Error(`Upstream HTTP ${res.status}`);
  if (!res.body) throw new Error("Response has no body");
  return res;
}

If the server returns JSON on error instead of an SSE stream, this catches it early.

Step 3: Decode bytes into lines with a buffer

HTTP chunks do not respect line boundaries. A single reader.read() may return half a line, or three lines plus a fragment. You must accumulate bytes in a string buffer and split on \n. Use TextDecoderStream to handle UTF-8 correctly without manual byte math.

const reader = res.body
  .pipeThrough(new TextDecoderStream())
  .getReader();

let buffer = "";
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += value;
  let nl;
  while ((nl = buffer.indexOf("\n")) !== -1) {
    const line = buffer.slice(0, nl).replace(/\r$/, "");
    buffer = buffer.slice(nl + 1);
    handleLine(line);
  }
}

The replace(/\r$/, "") strips carriage returns from CRLF terminators. Leftover text after the loop is an incomplete line; keep it in buffer for the next chunk.

Step 4: Extract data frames and detect event boundaries

Maintain a temporary string for the current event’s data. On a blank line, the event is complete: trim, parse, and dispatch. Ignore event: and id: unless you need reconnection logic.

let currentData = "";

function handleLine(line) {
  if (line === "") {
    if (currentData) {
      emit(currentData.trimEnd());
      currentData = "";
    }
    return;
  }
  if (line.startsWith(":")) return; // comment
  if (line.startsWith("data:")) {
    currentData += line.slice(5).replace(/^ /, "") + "\n";
  }
}

Multi-line data: fields are rare in LLM streams but legal; the concatenation above handles them.

Step 5: Implement the sse async iterator javascript generator

Wrap the read loop in an async function*. This is the core deliverable: a reusable iterator that yields parsed objects and automatically terminates on [DONE] or stream end.

function parseSse(raw) {
  if (raw === "[DONE]") return { done: true };
  return JSON.parse(raw);
}

async function* sseAsyncIterator(response) {
  const reader = response.body
    .pipeThrough(new TextDecoderStream())
    .getReader();
  let buffer = "";
  let currentData = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) {
      if (currentData.trim()) yield parseSse(currentData.trimEnd());
      break;
    }
    buffer += value;
    let nl;
    while ((nl = buffer.indexOf("\n")) !== -1) {
      const line = buffer.slice(0, nl).replace(/\r$/, "");
      buffer = buffer.slice(nl + 1);
      if (line === "") {
        if (currentData) {
          yield parseSse(currentData.trimEnd());
          currentData = "";
        }
      } else if (line.startsWith("data:")) {
        currentData += line.slice(5).replace(/^ /, "") + "\n";
      }
    }
  }
}

The generator yields plain objects. Your caller decides what to do with { done: true }. Backpressure is inherent: the for await loop pauses reader.read() until the consumer requests the next item.

Step 6: Drive the iterator from application code

Consuming the sse async iterator javascript is now trivial. Extract the delta from each OpenAI-style chunk and write it to stdout or a UI.

const res = await openStream([{ role: "user", content: "Explain SSE." }]);
for await (const evt of sseAsyncIterator(res)) {
  if (evt.done) break;
  const delta = evt.choices?.[0]?.delta?.content;
  if (delta) process.stdout.write(delta);
}
process.stdout.write("\n");

No event listeners, no manual buffer flushing, no nested callbacks. If you need to transform the stream (e.g., accumulate text), wrap it in another async generator.

Step 7: Add abort and error handling

Long-lived streams need cancellation. Pass an AbortSignal to fetch, and ensure the reader is released on error so the underlying TCP socket closes.

async function* safeSseIterator(response, signal) {
  const reader = response.body
    .pipeThrough(new TextDecoderStream())
    .getReader();
  try {
    // ... same loop as before ...
  } catch (err) {
    await reader.cancel().catch(() => {});
    throw err;
  } finally {
    if (!signal?.aborted) reader.releaseLock?.();
  }
}

In Node, an unhandled rejection in the loop will terminate the process; wrap the for await in try/catch. In the browser, aborting the fetch causes reader.read() to reject, which your catch converts into a clean teardown.

Step 8: Use it against a real LLM gateway

Point the same code at any OpenAI-compatible endpoint. For example, an OpenAI-compatible endpoint such as n4n.ai streams deltas as SSE and honors the same stream: true contract, so the iterator above works without modification. Because the gateway handles provider fallback and forwards cache-control hints, your client logic stays identical whether you route to a frontier model or a cheap variant.

You can also send a routing directive header if the gateway supports it; the stream parser does not care about request headers, only the byte format on the wire.

Step 9: Verify the stream end-to-end

First, confirm raw SSE with curl to ensure the server behaves:

curl -N -X POST https://api.example.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"stream":true}'

You should see data: {...} lines followed by data: [DONE].

Next, run the Node script from Step 6. Success criteria:

  • Tokens print incrementally, not all at once.
  • The process exits with code 0 after the newline.
  • No UnhandledPromiseRejection appears if you hit Ctrl-C (proves abort works).
  • currentData never leaks across events: add console.assert(!currentData) after each yield during dev.

If you see a SyntaxError from JSON.parse, log the raw currentData before parsing—usually it’s a partial chunk because the buffer split inside a JSON string. The line-based parser avoids this because SSE frames are line-delimited, but a malformed proxy might emit bare JSON without data:. In that case, extend handleLine to accept raw lines when no prefix is present.

Edge cases worth handling in production

  • Retries: retry: field sets reconnect delay; irrelevant for one-shot fetch but useful if you build a persistent client.
  • IDs: id: enables resume via Last-Event-ID; store it if you implement reconnect.
  • Compression: Some gateways gzip the stream. fetch decompresses automatically; do not double-decode.
  • Browser support: TextDecoderStream is available in Chrome 80+, Firefox 113+, Safari 16.4+. For older targets, use a manual TextDecoder and decode chunks yourself.

The sse async iterator javascript pattern scales from a quick script to a hardened client library. Once you have it, every streaming integration—logs, metrics, chat—uses the same for await surface.

Tagsssejavascriptasync-iteratorsstreaming

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 server-sent events (sse) streaming deep dive posts →