RSS Amplifier

Rick Cogley · Apr 19, 2026

Cloudflare Workers HTML to Markdown: Free-Plan Edition

0
Sign in to vote or save

Rick Cogley · Rick Cogley

AI crawlers — Gemini, GPT, Claude, Perplexity — are reading your site constantly, and they'd rather parse markdown than HTML. Markdown means cleaner context, fewer tokens, cheaper inference for them, which translates (via the economics of who pays for those tokens) into more efficient use of everyone's budget. If an agent is going to summarize your page anyway, giving it markdown directly means it spends its token budget on your actual content rather than on walking DOM.

There are two ways to serve agents markdown. If your content is already markdown — living in a CMS, a Git repo, a database — you negotiate the format over the wire with Accept: text/markdown and you're done. My companion article, Markdown for Agents on SvelteKit + Cloudflare Workers, covers that case in depth.

If your content is HTML — you're proxying a third-party page, mirroring documentation, building a reader-mode endpoint, feeding an LLM summarizer, or simply serving a static website — you'd have to convert it to markdown inside a Cloudflare Worker. I run cogley.jp on the free plan, with company and client sites on paid, so this trade-off comes up from time to time. On paid Workers you have enough CPU and bundle headroom to pick essentially any library; on the free plan you have 10 ms of CPU per request and 1 MB of compressed Worker bundle, and those limits force a very specific tool choice. This article measures what fits.

The budgets you're buying with paid

A quick clarification before the numbers, because "paid" on Cloudflare is ambiguous. Two different products use the word:

  • Workers Paid — $5/month plus usage. This is the Worker runtime upgrade. Your CPU budget jumps from 10 ms to 30 s, your compressed bundle ceiling from 1 MB to 10 MB. This plan is what changes the HTML-to-markdown calculus.
  • Cloudflare Pro — $20/month per domain. Adds image optimization, advanced WAF, page rules, mobile redirects. A domain plan, not a Worker plan. Does not change any Worker limit.

You can run Pro on your domain without Workers Paid, and vice versa. They bill separately. When this article says "paid" from here on, it means Workers Paid specifically. Throwing $20/month at Cloudflare Pro won't buy you any extra Worker CPU.

On the Workers free plan, each request gets:

Limit Free Paid
CPU time per request 10 ms 30 s (Standard) / 5 min (Unbound)
Compressed Worker bundle 1 MB 10 MB
Subrequests per invocation 50 1000
KV writes per day 1,000 1,000,000

For most Worker work — routing, JSON transformation, response rewriting — 10 ms is plenty. HTML-to-markdown is different. You're parsing a DOM, walking every node, and emitting a transformed string. It's CPU-dense, and every strategy that ships its own DOM implementation tends to bust the bundle budget too.

The interesting question isn't can you do it on free — several things clearly can. The question is: given the budgets, which approach has enough headroom to survive real-world input variance without you babysitting it?

The punchline: HTMLRewriter

HTMLRewriter is built into workerd. (Workerd is Cloudflare's open-source JavaScript/Wasm runtime — the same V8-based engine that executes your Worker at the edge, and the one wrangler dev runs locally.) It has zero npm dependencies and is used by Cloudflare themselves for response transformation.

The built-in HTMLRewriter is streaming and SAX-style: it consumes bytes as they arrive and fires <h1> / text / </h1> events without ever building an in-memory tree. The alternatives turndown / Readability / cheerio family all work the opposite way — buffer the whole document, construct a DOM with every node and parent pointer allocated, then walk it. That construction pass is both a CPU tax (before you emit a single character of markdown) and the reason those libraries ship their own DOM implementation (hundreds of KB of bundle). HTMLRewriter doesn't pay either cost.

On a sample 34 KB HTML article:

  • Bundle: 10.52 KiB uncompressed / 3.74 KiB gzipped (0.4% of the 1 MB free budget)
  • CPU: 2 ms median over 50 runs (min 2, max 8) — 20% of the 10 ms budget
  • Output: 24.9 KB of markdown

That's 5× CPU headroom and 250× bundle headroom versus the free-plan ceiling. Nothing else I measured even came close. Numbers are from wrangler dev local workerd — edge runtime numbers are typically 1.5–2× slower, so plan on a 3–4 ms realistic median. Still well inside the 10 ms limit.

The rest of this article is why HTMLRewriter wins, how to use it, and when to switch to paid instead of fighting the budgets.

Why the alternatives don't fit

turndown + DOM shim

turndown is the canonical "HTML to markdown" library in JavaScript. It's battle-tested and produces clean output. But turndown needs a DOM — it calls DOMParser internally — and workerd doesn't ship one.

The pragmatic shim is @mixmark-io/domino: a pure-JS DOM implementation, weighing ~240 KB compressed. turndown itself is another ~80 KB. So turndown-on-Workers starts at ~320 KB of bundle before you write a single line of your own code. That's roughly a third of the free-plan budget spent before you've done any real work.

The alternative is jsdom, which is much more complete than domino — and weighs ~2 MB. That's twice the entire free-plan budget, before turndown. Not viable.

Even with domino, expect turndown CPU time on a 34 KB doc to land in the 15–30 ms range based on published benchmarks of turndown against full-page inputs. That's over the 10 ms budget. Add the shim's overhead per-parse and it gets worse.

Verdict: use turndown on paid, not on free.

Readability + turndown

Mozilla's @mozilla/readability is the extractor that powers Firefox's reader mode. Great at separating the "article" from the chrome. Combined with turndown, it's the standard stack for "give me the meaningful markdown from an arbitrary web page."

Readability also needs a DOM. Same shim story. Bundle: Readability ~80 KB + turndown ~80 KB + domino ~240 KB = ~400 KB. CPU: Readability does its own DOM walk before turndown starts, so the total is roughly "turndown CPU + Readability CPU" — easily 20–40 ms on representative input.

Verdict: great stack, but it belongs on paid.

cheerio + handwritten emitter

cheerio uses parse5 internally — a spec-conformant HTML parser that builds an internal tree without exposing a full DOM. Smaller than turndown's DOM dependencies. Works under nodejs_compat_v2.

You still write your own markdown emitter. cheerio gives you jQuery-style traversal, which is ergonomic but adds bundle weight. Cheerio + parse5 together come in around 100–150 KB compressed. CPU is usually better than DOM-based approaches because there's no layout or styling pretense — just a parsed tree — but still a full document walk.

Viable on free if you keep the emitter tiny. Tighter than HTMLRewriter on both bundle and CPU budgets, with no obvious upside.

Verdict: plausible, but HTMLRewriter is still smaller and faster for the same job.

node-html-parser

node-html-parser is the smallest serious option. ~40 KB compressed, self-contained, pure JS. Parses to a simple tree, you walk it. Fast — published benchmarks put it at 2–5× faster than cheerio on similar inputs.

Realistically the second-best free-plan choice if for some reason you need post-parse tree traversal (e.g., selector queries across the document). If you only need streaming conversion, HTMLRewriter still wins on bundle because it's already in the runtime.

Verdict: a good fallback when HTMLRewriter's streaming model doesn't fit your use case.

How to use HTMLRewriter for markdown

HTMLRewriter doesn't give you a DOM. It gives you element handlers that fire on start-tag, end-tag, and text events as the document streams through. To emit markdown, you insert markdown punctuation as text adjacent to each matched element:

const rewriter = new HTMLRewriter()
  // Strip the chrome entirely.
  .on('head, nav, aside, footer, script, style, figure', {
    element(el) {
      el.remove();
    },
  })

  // Wrap headings with their markdown prefix.
  .on('h1', {
    element(el) {
      el.before('\n\n# ', { html: false });
      el.after('\n\n', { html: false });
    },
  })
  .on('h2', {
    element(el) {
      el.before('\n\n## ', { html: false });
      el.after('\n\n', { html: false });
    },
  })

  // Lists: add the bullet prefix to each <li>.
  .on('li', {
    element(el) {
      el.before('\n- ', { html: false });
    },
  })

  // Links: wrap with [text](href).
  .on('a', {
    element(el) {
      const href = (el.getAttribute('href') || '').replace(/\s+/g, '');
      el.before('[', { html: false });
      el.after(`](${href})`, { html: false });
    },
  })

  // Catch-all: drop any tag we didn't specifically handle, keep its text.
  .on('*', {
    element(el) {
      el.removeAndKeepContent();
    },
  });

const res = new Response(html, { headers: { 'content-type': 'text/html' } });
const raw = await rewriter.transform(res).text();

After the rewriter runs, raw is (approximately) markdown mixed with collapsed whitespace. Minimal post-processing cleans it up:

const markdown = raw
  .replace(/<!doctype[^>]*>/gi, '')
  .replace(/&amp;/g, '&')
  .replace(/&lt;/g, '<')
  .replace(/&gt;/g, '>')
  .replace(/&quot;/g, '"')
  .replace(/&#39;/g, "'")
  .replace(/&nbsp;/g, ' ')
  .split('\n')
  .map((line) => line.replace(/[ \t]+/g, ' ').trim())
  .join('\n')
  .replace(/\n{3,}/g, '\n\n')
  .trim();

That's the entire converter, with no DOM, no npm install, and nothing to shim.

Known limits of this approach

HTMLRewriter selectors fire independently, so cross-element state is awkward. Specifically:

  • Ordered lists (<ol>) come out as - item not 1. item. To number them you'd need a handler on <ol> that tracks index and a handler on <li> that reads from a stack, and HTMLRewriter doesn't give you parent context — you'd keep the stack in a closure outside the rewriter.
  • Inline code inside pre blocks drops its backticks because we can't tell from a <code> handler whether its parent is <pre> or a paragraph.
  • Link text spanning formatting tags (e.g., <a><em>italic</em></a>) loses the emphasis when the * catch-all strips the <em>.

These matter if you're round-tripping content. For the "give AI agents clean markdown" use case, none of them matter — agents tolerate unordered lists in place of ordered ones and don't care about nested emphasis. Extend the emitter with more selectors if your use case needs them.

Measure it yourself

The harness that produced these numbers is its own public repo: cf-workers-html-to-markdown-harness. It's a single Worker with one route per strategy — a measurement rig, not a library.

git clone https://github.com/RickCogley/cf-workers-html-to-markdown-harness
cd cf-workers-html-to-markdown-harness
npm install --ignore-scripts
npm run size     # bundle size via wrangler --dry-run
npm run dev      # start local workerd on :8791

# In another terminal:
curl 'http://127.0.0.1:8791/bench?strategy=htmlrewriter&runs=50'
curl 'http://127.0.0.1:8791/output?strategy=htmlrewriter'

Adding a strategy (e.g., cheerio, node-html-parser, turndown with the domino shim) is a single handler file under src/handlers/ plus one map entry in src/index.ts. The ADD_A_STRATEGY.md in the harness repo has a complete example for turndown.

If you measure a library I didn't and find numbers that contradict what I've said here, open an issue on the repo and I'll update the article with your data.

When to stop fighting and go paid

If your use case needs any of the following, stop trying to stay on free:

  • Round-trippable markdown — you want to re-render back to HTML and get something close to the original. Use turndown.
  • Article extraction — give the reader just the article body, not nav/sidebars/comments. Use Readability.
  • HTML tables → markdown tables — HTMLRewriter can't do row/column alignment cleanly. Use turndown or cheerio with a custom emitter.
  • CPU margins — your inputs are larger than 50 KB on average, or vary wildly in shape. Paid's 30 s budget means you don't have to think about this.

Workers Paid is $5/month plus usage — again, the Worker runtime upgrade, not the Cloudflare Pro domain plan. That's cheaper than an afternoon of engineering around the free-plan budgets if your use case really needs a fuller converter.

Related

References

Read the original on cogley.jp

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.