Shipping Markdown on Workers Without Parsing at Runtime

How this site's markdown pipeline moved from per-request parsing inside a Cloudflare Worker to build-time compilation with a Vite plugin, and what it bought.

The Setup

This site renders markdown -- the notes, runbooks, and architecture docs you are reading live as .md files in the repo. The original pipeline used Vite's globalThis._importMeta_.glob with ?raw to bundle the files as strings at build time, then ran a unified/remark/rehype chain over them at request time to produce HTML.

I had even written, in an earlier note, that the markdown was "processed at build time." It wasn't. The raw strings were bundled at build time; the processing happened on every request. The distinction is easy to miss because the code looks build-time-ish -- globalThis._importMeta_.glob is a build-time construct -- and everything works either way. The cost is invisible until you look for it.

What Per-Request Parsing Actually Costs on a Worker

On a traditional server the difference would barely matter: parse once, cache in memory, move on. Workers change the math in two ways.

CPU budget. A Worker on the free tier gets roughly 10ms of CPU per request. Parsing a 400-line architecture doc through remark-parse, remark-gfm, remark-rehype, and three rehype plugins eats a meaningful slice of that before the page has rendered anything. It worked, but the margin was thinner than it had any reason to be.

Bundle weight. The pipeline has to ship to run. unified plus its plugin chain and gray-matter is a few hundred KB of parser code inside a bundle with a 3 MB compressed ceiling. That is space I would rather spend on almost anything else.

There was also no caching. Two visitors reading the same note parsed it twice. A module-level Map would have fixed that in five lines -- but it would still pay the parser's bundle weight and the cold-start parse, and it still leaves frontmatter validation as a runtime concern.

The Fix: a Vite Transform Plugin

The version you are reading went the other way: compile everything at build time and ship only the results. A small Vite plugin intercepts .md imports and replaces each one with a precompiled module:

export function markdownCompilePlugin(): Plugin {
  return {
    name: "valkyrie:markdown-compile",
    enforce: "pre",
    async transform(code, id) {
      const [path, query] = id.split("?");
      if (!path.endsWith(".md") || query) return null;
      const compiled = await compileMarkdown(code);
      return { code: `export default ${JSON.stringify(compiled)};`, map: null };
    },
  };
}

The content loader drops the ?raw query from its glob and receives finished objects: HTML, extracted headings for the table of contents, reading time, validated frontmatter. The route code did not change at all -- the loader's public API stayed identical, which kept the diff reviewable.

Three things came along for free:

  1. Frontmatter validation became a build gate. The zod schema that used to throw at request time now fails vinxi build. A typo in a date field is a red CI run, not a 500 on one URL.
  2. Syntax highlighting got cheap. Shiki is far too heavy to ship to a browser or a Worker, but at build time its cost is a few hundred milliseconds once. Every code block on the site is now highlighted with zero runtime and zero client JS.
  3. The parser left the bundle. unified, remark, rehype, gray-matter, and zod all moved to devDependencies. The server bundle landed at 1.3 MB with no parser code in it.

Verifying the Claim This Time

Having been burned by my own "processed at build time" line, I checked the output instead of the source:

grep -rl "micromark\|gray-matter" .output/server/
# (no matches)

grep -o 'class="shiki' .output/server/chunks/build/content-loader-*.mjs | head -1
# class="shiki  -- the highlighted HTML is baked in

If the compiled HTML is in the chunk and the parser is not, the work happened at build time. Anything short of inspecting the artifact is taking the code's word for it, and the code's word is what was wrong before.

The Trade-off

Content changes now require a rebuild and redeploy. For a site where content lives in git and deploys are an automatic consequence of merging, that is no cost at all -- the rebuild was happening anyway. For a site with a CMS or frequent non-developer edits, this approach would be the wrong one, and the runtime pipeline with a cache would be the honest choice.