Appearance
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/coreplus 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-listVanilla 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 }ondecoder.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-Lengthand buffers the body, you'll see all chunks at once instead of a stream. Verify withcurl -Nor DevTools Network tab.
Related
- SSE recipe — same idea with auto-reconnect
- OpenAI recipe — SDK on top of this
- Streaming guide — how Generative DOM's buffer and scheduler interact with chunk cadence