HTML Streaming and Rewriting in Edge Functions: Modifying Pages Without Buffering

Last Black Friday, a commerce client asked us to inject a real-time inventory banner into every product page at the edge. Their origin was already struggling, and their first attempt — buffering the entire HTML response in an Edge Function, running a regex, then sending it — added 400ms to Time to First Byte and OOM-killed the function on their largest category pages. We'd seen this exact failure before. You can't treat HTML as a string you hold in memory when you're running on 128MB of RAM 300ms from the user.

Why Buffering the Whole Response Destroys TTFB

When you call await response.text() or await response.arrayBuffer() inside an edge worker, you're telling the runtime to pause everything until the origin has finished sending the last byte. The client gets nothing during that time. For a 250KB document coming from an origin 80ms away, that's not just the download time — it's the full origin latency plus parsing plus your transform plus the downstream flush. I've debugged traces where this naive pattern doubled TTFB from 120ms to over 600ms, and that was before we even touched the HTML.

It gets worse at scale. Edge Functions aren't Node servers with gigabytes to spare. Most providers cap you at 128-256MB, and that memory is shared across concurrent invocations on the same isolate. Buffer a few 2MB pages simultaneously and you'll hit the wall described in Edge Function Memory Management: Working Within Tight Limits at CDN Scale. We once had a news site try to buffer and rewrite their homepage — 1.8MB of heavily commented HTML — and their p99 memory spiked to 190MB. The runtime started evicting isolates mid-request.

The HTTP spec was designed to avoid this. As defined in the HTTP Working Group Specifications, responses are fundamentally streams, and browsers start parsing incrementally as chunks arrive. If you buffer at the edge, you break that pipeline. The alternative is to transform the stream while it flows — inspect bytes as they pass, emit them immediately, and never hold the whole document:

// Don't do this at the edge - it blocks streaming and blows memory
export default async (request, context) => {
  const response = await context.next();
  // This buffers the ENTIRE body before the client sees a byte
  let html = await response.text();
  
  // Fragile regex, blocks TTFB, fails on chunked encoding
  html = html.replace(
    '',
    '<link rel="preconnect" href="https://cdn.example.com"></head>'
  );
  
  return new Response(html, {
    headers: response.headers,
    status: response.status
  });
};

How HTMLRewriter Gives You Streaming Without a DOM

Cloudflare's HTMLRewriter — now ported to other runtimes as html-rewriter-wasm — solved this by implementing what the spec calls a streaming tokenizer. It doesn't build a DOM. It doesn't need closing tags to start. It operates as a SAX-style parser that emits selectors as it sees tokens, and your handlers mutate that token stream in place. I didn't appreciate how clever this was until I tried to build a poor man's version with regex and spent a week chasing partial-tag bugs when a chunk boundary split right through <div class=".

Because it’s token-based, you can attach handlers with CSS selectors and the rewriter will call them the instant it recognizes an element, even if the rest of the document hasn't arrived. That means your transformed bytes can be forwarded to the client while the origin is still streaming. You keep the browser's progressive rendering, you keep TTFB low, and your memory stays flat — you're only ever holding a small window of bytes, not the document. If you're already fighting startup delays, this model pairs directly with the techniques in Edge Function Cold Start Optimization: Reducing Initialization Latency to Milliseconds because the rewriter itself initializes in under a millisecond.

Here's the same <head> injection, but done as a true stream transformation. Notice we never await the body — we pipe it straight through the rewriter:

export default {
  async fetch(request) {
    const response = await fetch(request);
    
    // No buffering - transform() returns a streaming Response
    return new HTMLRewriter()
      .on('head', {
        element(el) {
          // Appended immediately when <head> is tokenized
          el.append(
            '<link rel="preconnect" href="https://cdn.example.com">',
            { html: true }
          );
        }
      })
      .on('img[data-src]', {
        element(el) {
          // Rewrite lazy-load attributes on the fly
          const src = el.getAttribute('data-src');
          if (src) {
            el.setAttribute('src', src);
            el.removeAttribute('data-src');
          }
        }
      })
      .transform(response);
  }
}

// As covered in web.dev Articles (https://web.dev/articles/),
// browsers can discover preconnect hints earlier when streamed
// with the first chunk, shaving 100-300ms off LCP.

Streaming vs. Buffered Rewrites: What We Measured in Production

We A/B tested both approaches on a mid-size e-commerce property doing about 4M requests/day. Same edge location (IAD), same origin, same 180KB product template. The buffered version used response.text() plus regex; the streaming version used HTMLRewriter with two selectors. The results weren't subtle. Streaming cut p95 TTFB by 68% because the first byte reached the client after our transform of the <head> — about 12ms after the origin's first chunk — instead of after the full 180KB was downloaded to the worker.

Memory and CPU tell the same story. Buffered rewrites allocate proportional to document size, and that allocation counts against your per-invocation limit for the entire duration. Streaming stays constant. That flat profile is why you can safely run it on massive pages that would otherwise exceed limits — we've streamed 8MB pre-rendered HTML reports through a 128MB worker without issues, something that consistently hit CPU wall time and memory errors before. If you're bumping into those hard caps, you'll recognize the patterns from Edge Function Execution Limits: CPU Time, Subrequests, and Creative Workarounds.

Here's the averaged production data over 7 days:

Metric (p95, 180KB HTML) Buffered (text + regex) Streaming (HTMLRewriter) Delta
Time to First Byte 412 ms 133 ms -67.7%
Peak Memory per Request 4.2 MB 0.38 MB -91%
CPU Time 18.4 ms 6.1 ms -66.8%
Max Document Size Before Failure ~1.2 MB >10 MB (tested) +733%+
Client First Paint Impact +290 ms vs. origin +14 ms vs. origin -95%

Putting HTMLRewriter to Work: A Real Edge Implementation

We've run this in production on storefronts doing 40k rps and the pattern that actually sticks is dead simple: don't build a DOM, don't buffer, just latch onto tokens as they flow past. HTMLRewriter is a SAX-style parser — it never materializes the full tree, it fires your handlers the instant it sees an opening tag, attribute, or text chunk. That's why the overhead is so tiny. On a typical 85KB document with 4-5 selectors, we're seeing 3-7ms added to TTFB. Compare that to 140-260ms when you do the await response.text() + DOMParser dance, and you've just killed every caching gain you had.

The core trick is to keep your handlers synchronous and stateless. You can't await inside element() — if you need personalization data, A/B flags, or geo lookups, fetch them before you instantiate the rewriter. We usually do a single KV or origin sub-fetch that resolves in 8-12ms, stash it in a closure, then let the rewriter inject synchronously as bytes stream. The browser starts painting in ~18ms instead of waiting 200ms+ for you to reassemble the body.

Here's the skeleton we ship for Cloudflare Workers — same idea ports to Fastly's Compute or CloudFront Functions with minor API tweaks. Notice we're piping the origin stream directly and never touching Content-Length:

class HeadInjector {
  constructor(variant) { this.variant = variant; }
  element(el) {
    // Inject preloaded data as JSON, no async needed
    el.append(`<script>window.__VARIANT__="${this.variant}"</script>`, { html: true });
    el.append(`<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>`, { html: true });
  }
}

export default {
  async fetch(req, env) {
    const variant = await env.KV.get(req.cf.country + ":exp-42") || "control";
    const res = await fetch(req);
    
    // Don't rewrite non-HTML or error responses
    if (!res.headers.get("content-type")?.includes("text/html")) return res;

    return new HTMLRewriter()
      .on("head", new HeadInjector(variant))
      .on("div[data-price]", {
        element(el) { el.setAttribute("data-rewritten", "1"); }
      })
      .on("template#personalized", {
        element(el) { el.remove(); } // strip server template artifacts
      })
      .transform(res);
  }
}

Advanced Patterns and Where Streaming Quietly Breaks

Once you've got the basics, you can get pretty surgical without losing streaming. We chain rewriters for different concerns — one for SEO (canonical, hreflang, meta og tags), one for performance (preload hints, script defer), one for personalization. It's cleaner than one mega-handler and keeps CPU under 5ms per rewriter. For conditional injection, don't use if inside the stream; use attribute selectors like html[data-theme="dark"] head or inject a placeholder <div id="edge-inject"></div> at origin and replace it. That avoids parsing logic in the handler and keeps things deterministic even when origin HTML is malformed — which it will be 3-4% of the time.

Where folks get bitten is headers and encoding. If you modify content length, remove the Content-Length header entirely and let the edge use chunked transfer — otherwise you'll truncate at 1.8MB and get half-rendered pages that are brutal to debug. Same with Content-Encoding: gzip — most runtimes auto-decompress before the rewriter, but if you're fetching with manual decompression you've got to handle it. When things do go wrong, you won't see it in a simple console.log. You'll want proper Edge Function Debugging Techniques: Console, Tail Workers, and Remote Inspection — tail workers and live log streaming are the only way we caught a selector that matched 14,000 elements and spiked CPU time from 6ms to 48ms.

And don't forget you're operating on a live stream with backpressure. If your handler does heavy string manipulation on every text() chunk, you'll stall the stream and TTFB looks great but TTLB collapses. Keep text handlers to simple replaces and bail early. We benchmark every handler in isolation: anything over 1.5ms per element on a 100KB payload gets refactored. If you need bulk DOM changes — like re-ordering product grids — do it client-side or at origin, not at the edge.

Frequently Asked Questions

Can I use HTMLRewriter with framework-rendered pages (Next.js, Nuxt)?

Yes, and that's where it shines. Don't try to rewrite RSC payloads or JSON — only intercept the initial document response with content-type: text/html. For Next.js App Router, filter on Accept: text/html and skip _next/data requests. You'll stream the shell in 20-30ms and let the framework hydrate normally. Buffering those 250KB SSR responses is what pushes p95 TTFB from 45ms to 310ms.

Why is my rewritten page blank or truncated?

99% of the time it's a leftover Content-Length header. After rewriting, the body size changes but the header still reflects the origin size, so the browser stops reading early. Delete it: newRes.headers.delete("content-length") — the edge will switch to Transfer-Encoding: chunked automatically. The other culprit is removing a tag without closing it; always use the rewriter's remove() not manual string slicing.

How do I test streaming rewrites without deploying?

Don't rely solely on wrangler dev — it doesn't simulate streaming backpressure accurately. We run unit tests on handlers with Miniflare + snapshot comparisons on real origin HTML dumps, then run integration tests against a preview origin. For a full workflow, see Edge Function Testing Strategies: Unit, Integration, and End-to-End at the Edge. We catch about 80% of selector regressions that way before they hit staging.

What's the safest way to roll out a new rewriter?

Never ship a rewriter to 100% immediately. A bad selector can add 30-50ms of CPU and start tripping the 50ms limit. Use a canary or gradual rollout tied to a header or cookie so you can monitor CPU time and error rates. Our standard is 1% → 10% → 50% over an hour with automatic rollback if p95 CPU exceeds 12ms. The patterns in Edge Function Versioning and Deployment Patterns: Blue-Green, Canary, and Gradual map perfectly to this — treat HTML transforms like any other edge deployment.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker