Skip to content

Recipe: fetch + ReadableStream + Generative DOM ​

The lowest-level streaming pattern — no SDK, no EventSource, just the Streams API.

Why this recipe ​

When every other recipe is too opinionated, fetch() plus the Streams API is the escape hatch. It works everywhere fetch works, it doesn't care what your server is (JSON-lines, plain text, length-prefixed, SSE-over-POST), and it gives you byte-level control over how chunks reach Generative DOM. If you're rolling your own protocol between a gateway and your frontend, this is the recipe.

What you need ​

  • A modern browser or Node 18+ (native fetch + ReadableStream)
  • An endpoint that streams the response body (backend returns a streaming response, not a buffered one)
  • @generative-dom/core plus plugins
  • Optional: @generative-dom/react
sh
pnpm add @generative-dom/core @generative-dom/plugin-markdown-base \
  @generative-dom/plugin-markdown-inline @generative-dom/plugin-markdown-heading \
  @generative-dom/plugin-markdown-code @generative-dom/plugin-markdown-list

Vanilla example: plain text stream ​

If the backend returns raw markdown tokens (no envelope, no framing), the loop is five lines:

ts
import { GenerativeDom } from '@generative-dom/core';
import { markdownBase } from '@generative-dom/plugin-markdown-base';
import { markdownInline } from '@generative-dom/plugin-markdown-inline';
import { markdownHeading } from '@generative-dom/plugin-markdown-heading';
import { markdownCode } from '@generative-dom/plugin-markdown-code';
import { markdownList } from '@generative-dom/plugin-markdown-list';

export async function streamPlainText(url: string, container: HTMLElement): Promise<void> {
  const md = new GenerativeDom({
    container,
    plugins: [
      markdownBase(),
      markdownInline(),
      markdownHeading(),
      markdownCode(),
      markdownList(),
    ],
  });

  const response = await fetch(url);
  if (!response.ok || !response.body) throw new Error(`stream failed: ${response.status}`);

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

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      md.push(decoder.decode(value, { stream: true }));
    }
    md.push(decoder.decode()); // flush any trailing multi-byte sequence
  } finally {
    md.flush();
  }
}

The { stream: true } flag on decoder.decode() is the detail most people miss. It tells TextDecoder to hold incomplete multi-byte UTF-8 sequences until the next chunk, instead of emitting a replacement character. Without it, emoji and non-ASCII text that straddle chunk boundaries render as question marks.

Vanilla example: JSON-lines stream ​

Many LLM gateways frame tokens as one JSON object per line ({"text": "hello"}\n{"text": " world"}\n). You need a small splitter because line boundaries don't align with chunk boundaries.

ts
export async function streamJsonLines(url: string, container: HTMLElement): Promise<void> {
  const md = new GenerativeDom({
    container,
    plugins: [
      markdownBase(),
      markdownInline(),
      markdownHeading(),
      markdownCode(),
      markdownList(),
    ],
  });

  const response = await fetch(url);
  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });

      let newlineIdx: number;
      while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
        const line = buffer.slice(0, newlineIdx);
        buffer = buffer.slice(newlineIdx + 1);
        if (!line) continue;
        const { text } = JSON.parse(line) as { text: string };
        md.push(text);
      }
    }
    // Handle the last line if the server didn't terminate with \n
    buffer += decoder.decode();
    if (buffer.trim()) {
      const { text } = JSON.parse(buffer) as { text: string };
      md.push(text);
    }
  } finally {
    md.flush();
  }
}

The pattern generalizes: keep an accumulating buffer, split on your framing delimiter, parse each complete frame, push the extracted token. Generative DOM itself handles the case where a markdown syntax token (like ```) is split across your frames — you don't need to rejoin tokens, only to rejoin your transport frames.

React example ​

tsx
import { useEffect, useMemo, useRef } from 'react';
import { useGenerativeDom } from '@generative-dom/react';
import { markdownBase } from '@generative-dom/plugin-markdown-base';
import { markdownInline } from '@generative-dom/plugin-markdown-inline';
import { markdownHeading } from '@generative-dom/plugin-markdown-heading';
import { markdownCode } from '@generative-dom/plugin-markdown-code';
import { markdownList } from '@generative-dom/plugin-markdown-list';

export function FetchStreamed({ url }: { url: string }) {
  const plugins = useMemo(
    () => [
      markdownBase(),
      markdownInline(),
      markdownHeading(),
      markdownCode(),
      markdownList(),
    ],
    [],
  );
  const { ref, push, flush, reset } = useGenerativeDom({ plugins });
  const abortRef = useRef<AbortController>();

  useEffect(() => {
    reset();
    const controller = new AbortController();
    abortRef.current = controller;

    (async () => {
      try {
        const res = await fetch(url, { signal: controller.signal });
        const reader = res.body!.getReader();
        const decoder = new TextDecoder();
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          push(decoder.decode(value, { stream: true }));
        }
        push(decoder.decode());
      } catch (err) {
        if ((err as Error).name !== 'AbortError') console.error(err);
      } finally {
        flush();
      }
    })();

    return () => controller.abort();
  }, [url, push, flush, reset]);

  return <div ref={ref} className="prose" />;
}

The AbortController is the part you'll forget and regret. Without it, navigating away from the page while the stream is active leaks a connection and keeps pushing chunks into a detached Generative DOM instance. React's StrictMode will reveal the bug by running your effect twice; abort on cleanup and both runs shut down cleanly.

What this gets you ​

  • A transport that works with any streaming response format you control
  • Correct UTF-8 handling at chunk boundaries via TextDecoder({ stream: true })
  • Cancellation via AbortController — critical for chat UIs where users click "stop"
  • No dependencies — just the platform

Common pitfalls ​

  • Omitting { stream: true } on decoder.decode() — multi-byte characters at chunk boundaries become replacement characters.
  • Forgetting the final decoder.decode() with no arguments — flushes any remaining multi-byte sequence. Skip it and you may drop the last character of a stream ending on a non-ASCII codepoint.
  • Not aborting on unmount — a stream that outlives its component pushes into a destroyed Generative DOM. The hook's teardown guards against crashes, but it's wasted network and CPU.
  • Buffering the entire response — if your server accidentally returns Content-Length and buffers the body, you'll see all chunks at once instead of a stream. Verify with curl -N or DevTools Network tab.