02. streamHTML into an element
A static page loads first. A button kicks off a fetch() whose body is piped into a target element via streamHTMLUnsafe(). Tokens appear as they arrive on the wire.
Target
click the button to stream content here.
The minimal JS
const res = await fetch('/02/stream');
res.body
.pipeThrough(new TextDecoderStream())
.pipeTo(document.getElementById('target').streamHTMLUnsafe());
learn more
- Chrome blog: Declarative Partial Updates — Includes the streaming-html walkthrough.
- WICG explainer: dynamic markup revamped — Why
streamHTMLUnsafe()and friends exist; the full set of new HTML insertion methods. - html-setters polyfill — streamHTMLUnsafe / streamAppendHTMLUnsafe etc. for unsupported browsers.
- Chrome Status: Out of order streaming — DevTrial in 148 (behind flag), shipping target 150.
- Chrome Status: setHTMLUnsafe and parseHTMLUnsafe — The Chrome 124 building blocks the streaming methods extend.
- WHATWG HTML PR #11818 — The actual spec change.
- WHATWG HTML issue #2142 — The original 2017 thread that started it all.
source
Everything the server runs for this example. Each file links to GitHub so you can copy or fork it.
handler.ts/app/src/examples/02-streaming-fetch/handler.ts
import { sleep, streamingResponse } from "../../lib/streaming.ts";
import { loadShell, tryStatic } from "../../lib/files.ts";
const SHELL = loadShell(import.meta.url);
const TOKENS = [
"<h2>streamed paragraph</h2>",
"<p>this content is being emitted ",
"one chunk at a time, ",
"with a 200ms pause between each piece. ",
"Each chunk is parsed and reflected ",
"in the DOM as it arrives, ",
"no buffering, no full replace.</p>",
"<ul>",
"<li>first item</li>",
"<li>second item</li>",
"<li>third item</li>",
"</ul>",
'<p style="color:var(--muted);font-size:.85rem;">stream ended.</p>',
];
export default function handle(_req: Request, path: string): Response | Promise<Response> {
if (path === "/" || path === "/index.html") {
return new Response(SHELL, { headers: { "content-type": "text/html; charset=utf-8" } });
}
if (path === "/stream") {
return streamingResponse(async (write) => {
for (const chunk of TOKENS) {
await write(chunk);
await sleep(220);
}
});
}
return tryStatic(import.meta.url, path) ?? new Response("Not found", { status: 404 });
}
client.js/app/src/examples/02-streaming-fetch/client.js
const target = document.getElementById("target");
document.getElementById("go").addEventListener("click", async () => {
target.replaceChildren();
const res = await fetch("/02/stream");
if (!("streamHTMLUnsafe" in target)) {
target.textContent =
"streamHTMLUnsafe not supported. Enable chrome://flags/#enable-experimental-web-platform-features";
return;
}
await res.body
.pipeThrough(new TextDecoderStream())
.pipeTo(target.streamHTMLUnsafe());
});
document.getElementById("clear").addEventListener("click", () => {
target.innerHTML = '<p style="color:var(--muted);margin:0;">cleared.</p>';
});
index.html/app/src/examples/02-streaming-fetch/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>02 · Streaming fetch</title>
<link rel="stylesheet" href="/public/styles.css">
</head>
<body>
<main>
<p class="crumbs"><a href="/">← back to index</a></p>
<h1>02. streamHTML into an element</h1>
<p class="lede">A static page loads first. A button kicks off a <code>fetch()</code> whose body is piped into a target element via <code>streamHTMLUnsafe()</code>. Tokens appear as they arrive on the wire.</p>
<button id="go">stream into target</button>
<button id="clear">clear</button>
<h3>Target</h3>
<section class="demo-area" id="target">
<p style="color:var(--muted);margin:0;">click the button to stream content here.</p>
</section>
<h3>The minimal JS</h3>
<pre><code>const res = await fetch('/02/stream');
res.body
.pipeThrough(new TextDecoderStream())
.pipeTo(document.getElementById('target').streamHTMLUnsafe());</code></pre>
<script src="/02/client.js"></script>
<section class="refs">
<h3>learn more</h3>
<ul>
<li><a href="https://developer.chrome.com/blog/declarative-partial-updates?hl=en" target="_blank" rel="noopener">Chrome blog: Declarative Partial Updates</a> <span class="note">— Includes the streaming-html walkthrough.</span></li>
<li><a href="https://github.com/WICG/declarative-partial-updates/blob/main/dynamic-markup-revamped-explainer.md" target="_blank" rel="noopener">WICG explainer: dynamic markup revamped</a> <span class="note">— Why <code>streamHTMLUnsafe()</code> and friends exist; the full set of new HTML insertion methods.</span></li>
<li><a href="https://github.com/GoogleChromeLabs/html-setters-polyfill" target="_blank" rel="noopener">html-setters polyfill</a> <span class="note">— streamHTMLUnsafe / streamAppendHTMLUnsafe etc. for unsupported browsers.</span></li>
<li><a href="https://chromestatus.com/feature/5111042975465472" target="_blank" rel="noopener">Chrome Status: Out of order streaming</a> <span class="note">— DevTrial in 148 (behind flag), shipping target 150.</span></li>
<li><a href="https://chromestatus.com/feature/6560361081995264" target="_blank" rel="noopener">Chrome Status: setHTMLUnsafe and parseHTMLUnsafe</a> <span class="note">— The Chrome 124 building blocks the streaming methods extend.</span></li>
<li><a href="https://github.com/whatwg/html/pull/11818" target="_blank" rel="noopener">WHATWG HTML PR #11818</a> <span class="note">— The actual spec change.</span></li>
<li><a href="https://github.com/whatwg/html/issues/2142" target="_blank" rel="noopener">WHATWG HTML issue #2142</a> <span class="note">— The original 2017 thread that started it all.</span></li>
</ul>
</section>
<!-- source-viewer goes here -->
<footer class="byline">made by <a href="https://paul.kinlan.me/" target="_blank" rel="noopener">Paul Kinlan</a></footer>
</main>
</body>
</html>
lib/streaming.ts/app/src/lib/streaming.ts
export class StreamAborted extends Error {
constructor() {
super("stream aborted by client");
this.name = "StreamAborted";
}
}
export type ChunkSource = (write: (chunk: string) => Promise<void>) => Promise<void>;
const encoder = new TextEncoder();
export function streamingResponse(
source: ChunkSource,
contentType = "text/html; charset=utf-8",
): Response {
let aborted = false;
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const write = async (chunk: string) => {
if (aborted) throw new StreamAborted();
try {
controller.enqueue(encoder.encode(chunk));
} catch {
aborted = true;
throw new StreamAborted();
}
};
try {
await source(write);
} catch (err) {
if (!(err instanceof StreamAborted)) console.error("stream source error", err);
} finally {
if (!aborted) {
try {
controller.close();
} catch {
// already closed
}
}
}
},
cancel() {
aborted = true;
},
});
return new Response(body, {
headers: {
"content-type": contentType,
"cache-control": "no-store",
"x-content-type-options": "nosniff",
},
});
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
lib/files.ts/app/src/lib/files.ts
// File helpers for examples: read sibling files, render the page shell with the
// source viewer injected, and serve sibling static assets (CSS/JS/HTML) on demand.
import { sourceBlock } from "./source.ts";
const MIME: Record<string, string> = {
css: "text/css; charset=utf-8",
js: "application/javascript; charset=utf-8",
html: "text/html; charset=utf-8",
svg: "image/svg+xml",
json: "application/json; charset=utf-8",
};
function dirFor(handlerUrl: string): string {
return new URL(".", handlerUrl).pathname;
}
export function readSibling(handlerUrl: string, name: string): string {
return Deno.readTextFileSync(dirFor(handlerUrl) + name);
}
// Reads a sibling HTML file. The marker `<!-- source-viewer goes here -->` is replaced
// with the source-viewer block. Extra named placeholders ({{name}}) can be supplied via `vars`.
const SOURCE_MARKER = "<!-- source-viewer goes here -->";
export function loadShell(
handlerUrl: string,
name = "index.html",
vars: Record<string, string> = {},
): string {
let raw = readSibling(handlerUrl, name);
raw = raw.replace(SOURCE_MARKER, sourceBlock(handlerUrl));
for (const [k, v] of Object.entries(vars)) {
raw = raw.replaceAll(`{{${k}}}`, v);
}
return raw;
}
// Try to serve a sibling static file from the example folder. Returns null when the
// file is missing, the path escapes the folder, or the path is empty.
export function tryStatic(handlerUrl: string, requestPath: string): Response | null {
const safe = requestPath.replace(/^\/+/, "");
if (!safe || safe.includes("..")) return null;
// Never let the browser fetch handler.ts or README.md through this fallback.
if (safe === "handler.ts" || safe === "README.md") return null;
try {
const content = Deno.readFileSync(dirFor(handlerUrl) + safe);
const ext = safe.split(".").pop() ?? "";
return new Response(content, {
headers: {
"content-type": MIME[ext] ?? "application/octet-stream",
"cache-control": "no-store",
},
});
} catch {
return null;
}
}