Transforming HTML with HTMLRewriter at the Edge
This guide is part of Response Streaming and Transformation at the Edge. It shows how to mutate an HTML document on its way to the browser without ever holding the whole document in memory, and what changes about your code when the parser hands you a text node in pieces.
Edge middleware is the only place in your stack that sees every byte of every HTML response and can change it without a redeploy of the origin. That makes it the natural home for a per-request CSP nonce, a locale attribute on <html>, an experiment class on <body>, or a swapped asset host. The catch is that the byte stream is the only thing you have — there is no document, no DOM, and no guarantee that the tag you care about arrives in one piece.
The problem
You add a small transform to your middleware: set lang on the <html> element from a geo lookup, and stamp a nonce onto every <script>. The obvious implementation is await response.text(), a couple of String.replace calls, and a new Response. It works on your laptop. Then production shows three separate failures.
Time to first byte roughly doubles for large pages, because you are no longer streaming — the browser waits for the last origin byte before it receives the first. Memory usage spikes on your biggest pages, and on a runtime with a hard memory ceiling a 12 MB product listing pushes the isolate over the edge. And the regex that matched <script> starts silently mangling a page where a <script> tag carries a type="application/json" payload containing the literal string </script> inside an escaped sequence, or where an attribute value happens to contain a > character.
Root cause: HTML is a stream, and a regex is not a parser
Two constraints collide. The first is the streaming constraint. An HTTP response body at the edge is a ReadableStream. Reading it to a string with .text() collapses the stream into a single value, which means the whole document must exist in isolate memory at once and nothing can be forwarded downstream until the origin has finished. Every millisecond of origin think-time and every kilobyte of transfer now sits in front of the browser’s first paint instead of overlapping with it.
The second is the parsing constraint. HTML is not a regular language. Attribute values can contain angle brackets, comments can contain tags, <script> and <style> have their own tokenization rules, and character references decode to different bytes than they occupy. A regex that appears to work does so because your test fixtures were tame. The failure mode is not a crash; it is a corrupted document served to a real user.
HTMLRewriter resolves both at once. It is a streaming, spec-compliant HTML tokenizer built into the Cloudflare Workers runtime, exposed as a TransformStream over the response body. You register handlers against CSS selectors, and the runtime calls them as matching tokens pass by. Bytes flow through continuously; nothing is buffered beyond the current token.
Step 1 — Wrap the origin response
HTMLRewriter is constructed, configured with handlers, and then applied to a Response. The call returns immediately with a new Response whose body is a lazily-transformed stream; no parsing has happened yet.
// middleware/rewrite.ts
export interface RewriteOptions {
nonce: string;
locale: string;
assetHost: string;
}
export function rewriteHtml(response: Response, opts: RewriteOptions): Response {
const contentType = response.headers.get('content-type') ?? '';
// Only touch documents. Images, JSON and JS must pass through untouched;
// running a tokenizer over a 4 MB image is pure waste.
if (!contentType.toLowerCase().includes('text/html')) return response;
return new HTMLRewriter()
.on('html', new LocaleHandler(opts.locale))
.on('script', new NonceHandler(opts.nonce))
.on('link[rel="stylesheet"]', new NonceHandler(opts.nonce))
.on('img[src^="/static/"]', new AssetHostHandler(opts.assetHost))
.transform(response);
}
The content-type guard is the highest-value line in the file. Without it, every asset that shares your middleware matcher pays tokenizer cost for nothing, and any response that is not HTML risks being reinterpreted as if it were.
Step 2 — Write element handlers
An element handler is any object with an element(el) method. It is invoked when the start tag has been fully tokenized, which is why attribute mutation is always safe there — every attribute of that tag has already been seen.
class LocaleHandler {
constructor(private readonly locale: string) {}
element(el: Element): void {
// setAttribute overwrites; getAttribute reads what the origin sent.
el.setAttribute('lang', this.locale);
el.setAttribute('data-edge-locale', this.locale);
}
}
class NonceHandler {
constructor(private readonly nonce: string) {}
element(el: Element): void {
// Never nonce a subresource-integrity-protected external script that
// already carries one; overwriting breaks the origin's own policy.
if (el.getAttribute('nonce')) return;
el.setAttribute('nonce', this.nonce);
}
}
class AssetHostHandler {
constructor(private readonly host: string) {}
element(el: Element): void {
const src = el.getAttribute('src');
if (!src || !src.startsWith('/static/')) return;
el.setAttribute('src', `https://${this.host}${src}`);
}
}
Element also exposes structural methods — el.before(), el.after(), el.prepend(), el.append(), el.replace(), el.remove() and el.removeAndKeepContent(). Each takes a second argument { html: boolean }. The default is { html: false }, meaning your string is HTML-escaped before insertion. That default is the safe one: only pass { html: true } for markup you constructed yourself, never for anything derived from the request.
class PreconnectInjector {
constructor(private readonly origin: string) {}
element(el: Element): void {
el.append(
`<link rel="preconnect" href="${this.origin}" crossorigin>`,
{ html: true },
);
}
}
Step 3 — Understand why text handlers receive chunks
This is the part that produces bugs which pass every test and then fail in production. A text(chunk) handler is not called once per text node. It is called once per chunk of that text node, and the tokenizer decides the chunk boundaries based on how the bytes arrived over the network. A text node reading Welcome back, Ada can arrive as Welcome ba, ck, A, da across three network packets, and your handler runs three times.
Each chunk carries chunk.lastInTextNode, a boolean that is true exactly once per text node — on the final chunk. Critically, that final call may have chunk.text === '': the tokenizer emits a zero-length terminator so you always get a reliable end signal.
So any logic that inspects the whole text of a node must accumulate across calls, act on lastInTextNode, and reset. Anything else — a chunk.text.includes('{{name}}') check, a replace on a single chunk — will match only when the placeholder happens not to straddle a boundary.
Step 4 — Accumulate correctly in a text handler
The pattern is always the same: suppress output on non-final chunks, buffer their text, and emit the transformed whole on the final chunk.
type Substitutions = Readonly<Record<string, string>>;
class PlaceholderHandler {
private buffer = '';
constructor(private readonly values: Substitutions) {}
text(chunk: Text): void {
this.buffer += chunk.text;
if (!chunk.lastInTextNode) {
// Swallow this chunk. Its content is safe in the buffer and will be
// re-emitted as part of the whole node.
chunk.remove();
return;
}
const rendered = this.buffer.replace(
/\{\{(\w+)\}\}/g,
(whole, key: string) => this.values[key] ?? whole,
);
// html: false escapes the substituted values, so a value containing
// a tag cannot inject markup into the document.
chunk.replace(rendered, { html: false });
this.buffer = '';
}
}
Three details make this correct. chunk.remove() on non-final chunks is mandatory — without it the raw text is emitted and the rendered text is emitted, duplicating content. Resetting this.buffer is mandatory because handler instances are reused for every matching node in the document; a stale buffer leaks one node’s text into the next. And { html: false } keeps substituted values inert, which matters because those values often come from a cookie or a query string.
One more constraint worth naming: handler instances are per-HTMLRewriter, and you must construct a fresh HTMLRewriter for every request. Hoisting one to module scope shares mutable buffer state across concurrent requests in the same isolate, which is a data-leak bug, not just a correctness bug.
Step 5 — Wire it into the middleware with a nonce
The nonce must be generated per request, injected into the document, and declared in the Content-Security-Policy header. Doing one without the other silently disables your scripts or silently disables your policy.
// middleware.ts
import { rewriteHtml } from './middleware/rewrite';
function makeNonce(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes); // Web Crypto, available at the edge
return btoa(String.fromCharCode(...bytes)).replace(/=+$/, '');
}
export default async function middleware(request: Request): Promise<Response> {
const origin = await fetch(request);
const nonce = makeNonce();
const locale = request.headers.get('x-vercel-ip-country') === 'FR' ? 'fr' : 'en';
const rewritten = rewriteHtml(origin, {
nonce,
locale,
assetHost: 'cdn.example.com',
});
const headers = new Headers(rewritten.headers);
headers.set(
'content-security-policy',
`default-src 'self'; script-src 'self' 'nonce-${nonce}'; object-src 'none'; base-uri 'none'`,
);
// The document is now per-request. Never let a shared cache keep it.
headers.set('cache-control', 'private, no-store');
return new Response(rewritten.body, { status: rewritten.status, headers });
}
Scope the matcher so the rewriter never runs on assets:
export const config = {
runtime: 'edge',
matcher: ['/((?!_next/static|_next/image|static|favicon.ico|robots.txt|api).*)'],
};
The cost model versus a regex
The instinct that a regex is “cheaper” is half right and entirely misleading. Per byte of HTML, a tokenizer does more work than String.replace — it maintains parser state, decodes character references and tracks the open element stack. On a 200 KB document that difference is on the order of a millisecond of CPU, well inside any edge CPU budget.
What the regex approach actually costs is the buffering it forces. To run a regex you need the string; to have the string you need the whole body; to have the whole body you have paid full origin latency before emitting a byte and pinned the document in memory. On a runtime with a hard memory ceiling — and on Cloudflare Workers that ceiling is 128 MB shared with everything else the isolate is doing — a handful of concurrent large documents is enough to fail requests outright. Add the correctness gap and the trade is not close.
Providers without HTMLRewriter
HTMLRewriter is a Cloudflare runtime global. Vercel Edge Middleware and Netlify Edge Functions do not expose it, and no npm shim reproduces it without pulling in a full parser that will blow your bundle budget. You have three portable options, in descending order of preference.
Do the transform at origin and vary the cache key. If the change is coarse — a locale, an experiment bucket — render it at origin and let the edge pick the right variant via cache key normalization. Nothing is rewritten in the isolate at all.
Rewrite the <head> only, with a bounded streaming scanner. Most edge injections target <html>, <head> or the first <script>, all of which live in the first few kilobytes. Scan for a single marker with a carry buffer, then pass the remainder of the stream through untouched.
// Portable: injects once at the first occurrence of a marker, then streams.
export function injectOnce(body: ReadableStream<Uint8Array>, marker: string, html: string) {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let done = false;
let carry = '';
return body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
if (done) return controller.enqueue(chunk);
carry += decoder.decode(chunk, { stream: true });
const at = carry.indexOf(marker);
if (at !== -1) {
const out = carry.slice(0, at) + html + carry.slice(at);
controller.enqueue(encoder.encode(out));
carry = '';
done = true;
return;
}
// Keep only enough tail to catch a marker split across chunks.
const keep = marker.length - 1;
controller.enqueue(encoder.encode(carry.slice(0, Math.max(0, carry.length - keep))));
carry = carry.slice(Math.max(0, carry.length - keep));
},
flush(controller) {
if (carry) controller.enqueue(encoder.encode(carry));
},
}),
);
}
The keep tail is the same insight as lastInTextNode: a marker can straddle a chunk boundary, so you must retain marker.length - 1 characters before emitting. This is deliberately not a parser — it is a single-marker injector with an explicit, narrow contract.
Move the rewrite to a Cloudflare Worker in front. If the transform is genuinely document-wide, put a Worker in the path and keep the rest of the stack where it is. See migrating Vercel Edge Middleware to Cloudflare Workers.
Local versus production divergence
| Behaviour | Local dev | Edge production |
|---|---|---|
| Text node chunking | Usually one chunk per node — fixtures are small and arrive at once | Multiple chunks; boundaries follow network packets |
HTMLRewriter availability |
Present under wrangler dev and workerd; absent in plain Node |
Present on Cloudflare only |
| Origin latency | Near zero against a local server | Tens to hundreds of milliseconds, which is what streaming hides |
| Handler instance reuse | Few matching elements, so stale buffers rarely surface | Hundreds of matches per document; a missing reset corrupts output |
| Compression | Often disabled | Origin may send content-encoding: gzip, which the runtime decodes before the rewriter |
| Memory ceiling | Your laptop’s RAM | 128 MB on Workers, tighter on some plans |
The first row is the reason chunked-text bugs reach production. A local fixture almost always delivers a text node in a single call, so a handler that ignores lastInTextNode passes every test you wrote. Force the split in your suite rather than hoping to observe it.
Validating with Vitest
Test handlers as plain objects with a fake Text chunk, so the suite runs anywhere and can deliberately reproduce chunk splitting.
// rewrite.test.ts
import { describe, expect, it } from 'vitest';
interface FakeText {
text: string;
lastInTextNode: boolean;
removed: boolean;
replacedWith: string | null;
remove(): void;
replace(s: string, o?: { html: boolean }): void;
}
function chunk(text: string, last: boolean): FakeText {
return {
text,
lastInTextNode: last,
removed: false,
replacedWith: null,
remove() { this.removed = true; },
replace(s) { this.replacedWith = s; },
};
}
/** Feed a node to a handler as N chunks and return the emitted document text. */
function feed(handler: { text(c: FakeText): void }, parts: string[]): string {
const chunks = parts.map((p, i) => chunk(p, i === parts.length - 1));
for (const c of chunks) handler.text(c as unknown as Text);
return chunks
.map((c) => (c.replacedWith !== null ? c.replacedWith : c.removed ? '' : c.text))
.join('');
}
describe('PlaceholderHandler', () => {
it('substitutes a placeholder delivered in one chunk', () => {
const h = new PlaceholderHandler({ first: 'Ada' });
expect(feed(h, ['Hello {{first}}!'])).toBe('Hello Ada!');
});
it('substitutes a placeholder split across chunk boundaries', () => {
const h = new PlaceholderHandler({ first: 'Ada' });
expect(feed(h, ['Hello {{fir', 'st}}, welco', 'me back'])).toBe('Hello Ada, welcome back');
});
it('emits each non-final chunk exactly once', () => {
const h = new PlaceholderHandler({ first: 'Ada' });
// Duplication is the signature of a missing chunk.remove().
expect(feed(h, ['aaa', 'bbb', 'ccc'])).toBe('aaabbbccc');
});
it('resets its buffer between text nodes', () => {
const h = new PlaceholderHandler({ first: 'Ada' });
feed(h, ['first node']);
expect(feed(h, ['second node'])).toBe('second node');
});
it('leaves unknown placeholders untouched', () => {
const h = new PlaceholderHandler({ first: 'Ada' });
expect(feed(h, ['{{unk', 'nown}}'])).toBe('{{unknown}}');
});
it('handles a zero-length terminating chunk', () => {
const h = new PlaceholderHandler({ first: 'Ada' });
expect(feed(h, ['Hi {{first}}', ''])).toBe('Hi Ada');
});
});
The second and third tests are the ones that matter. Splitting mid-placeholder is exactly the production condition you cannot reproduce by accident, and asserting the concatenated output catches the duplication bug that a per-chunk assertion misses entirely. For an integration-level pass, run the real rewriter under wrangler dev against a fixture served with a deliberately slow, chunked body — see local emulation with wrangler dev versus production.
Common pitfalls and resolutions
Text appears twice in the output — a text handler buffers but never calls chunk.remove() on non-final chunks. The original bytes are emitted alongside the replacement.
A replacement matches only sometimes — the handler tests chunk.text directly instead of an accumulated buffer, so it fires only when the pattern happens to land inside one chunk.
Content from one element leaks into the next — the handler instance is reused across matching nodes and the buffer is not reset after lastInTextNode.
Every request sees another request’s data — the HTMLRewriter or a stateful handler was hoisted to module scope. Construct both per request.
Scripts blocked by CSP after adding a nonce — the nonce was injected into the document but not into the script-src directive, or a shared cache served a document whose nonce no longer matches the header. Set cache-control: private, no-store on nonce-bearing documents.
Injected markup shows as literal text — { html: true } was omitted on a call that inserts real markup. Conversely, if user-derived text is being interpreted as markup, { html: true } was passed where it should not have been.
Binary responses are corrupted — the content-type guard is missing and the tokenizer is running over a non-HTML body.
Production deployment checklist
Frequently Asked Questions
Why does my text handler run several times for one text node?
Because the handler receives chunks of the text node, not the node itself, and the chunk boundaries follow how the bytes arrived over the network rather than the document structure. Each chunk carries a lastInTextNode flag that is true exactly once, on the final chunk, and that final chunk may be zero length.
Why is my replaced text appearing twice in the page?
Because the non-final chunks were buffered but never removed. If you accumulate a chunk into a buffer and then also let it pass through, the original bytes are emitted and the rendered whole is emitted after them. Call remove on every chunk where lastInTextNode is false.
Is HTMLRewriter slower than a regular expression?
Per byte of HTML it uses slightly more CPU, on the order of a millisecond for a two hundred kilobyte document. The regex approach costs far more overall because it forces you to buffer the whole body first, which adds the entire origin transfer time in front of the first byte and pins the document in isolate memory.
Can I use HTMLRewriter on Vercel or Netlify?
No. It is a Cloudflare Workers runtime global and is not available on Vercel Edge Middleware or Netlify Edge Functions. The portable options are to render the variant at origin and vary the cache key, to write a bounded streaming injector that targets a single marker near the top of the document, or to put a Cloudflare Worker in front of the existing stack.
Can I create the HTMLRewriter once at module scope for speed?
No. Handler instances hold mutable buffers, and module scope is shared by every concurrent request the isolate serves, so one visitor’s accumulated text can be emitted into another visitor’s document. Construct both the rewriter and its handlers inside the request handler.
How do I inject a nonce without breaking my Content-Security-Policy?
Generate the nonce per request with Web Crypto getRandomValues, set it on every script element that does not already have one, and add the matching nonce source to the script-src directive of the response header. Mark the document private and no-store so a shared cache cannot serve a body whose nonce no longer matches the header.