- Published on
Markdown/MDX Pipeline Performance — What satteri and Native Cores Actually Change
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — 3,000 Pages of Docs, a 2-Minute-22-Second Build
- Where the Time Goes — Five Stages
- Profiling — Measure, Don't Guess
- Why the Plugin Chain Is Expensive
- satteri — What a Native Core Changes, and What It Gives Up
- How Far to Trust the Benchmarks
- What a Thousands-of-Pages Site Should Actually Do
- Closing — A Parser That Doesn't Run Beats a Fast Parser
- References
Introduction — 3,000 Pages of Docs, a 2-Minute-22-Second Build
Once a documentation site grows large enough, the build eventually becomes the longest item in the CI pipeline. What you never noticed at a few hundred pages comes to dominate your deploy lead time at a few thousand, and fixing a single typo locally means you have time to go make coffee before you can check it.
The recent answer to this problem is satteri. GeekNews introduced it as "a high-performance Markdown and MDX processor for the JavaScript ecosystem," and the repository's own summary is accurate — it parses and compiles in Rust, while plugins run in JavaScript. The problem the project points to is just as clear: the JavaScript ecosystem has a rich plugin library but a slow parser, and the Rust ecosystem has the opposite problem.
But before swapping tools, there is something you need to do first: measure where the time is actually going. In large MDX sites, Markdown parsing being the bottleneck is rarer than you'd think. This post first lays out the per-stage cost structure and a profiling process, and only then looks at what a native core like satteri changes and what it gives up. The common reasons builds are slow in general are covered in Why Is My Build So Slow.
Where the Time Goes — Five Stages
The stages an MDX file passes through on its way to becoming HTML are as follows. Each stage has a different cost profile, so lumping them together won't get you anywhere.
| Stage | What it does | Cost vs. page count | Main waste | How to measure |
|---|---|---|---|---|
| Parsing | micromark tokenizes the Markdown and builds an mdast | Linear, proportional to document length | Almost none | micromark frames in a CPU profile |
| Transformation | remark/rehype plugins each traverse the tree once | Linear times the number of plugins | Unused plugins, redundant traversals | Per-plugin timing |
| Compilation | Converts mdast to hast, then to JSX/JS. MDX also parses expressions | Linear | MDX-compiling documents that have no MDX expressions | acorn/estree frames in a CPU profile |
| Bundling | The bundler re-parses, transforms, and tree-shakes the generated JS | Linear but with a large constant | Separate modules per page, no shared-chunk splitting | The bundler's own profiler |
| Prerendering | Executes each page to produce HTML | Linear but with the largest constant | Code highlighter/image-processing initialization | Per-stage timing in the build log |
There is one fact that keeps confirming itself in practice: once the page count reaches the thousands, the bottom two rows often dwarf the top three. Markdown parsing costs milliseconds per document, but prerendering costs tens of milliseconds per page, and code highlighting and image optimization pile on top of that. Shiki-family highlighters in particular have a large upfront cost for loading grammars and themes, and depending on configuration, may redo that work for every page.
So swapping out your Markdown processor is only worth it when parsing and transformation are genuinely the top costs. On sites where they aren't, making the parser five times faster only shaves a few percent off the overall build.
Profiling — Measure, Don't Guess
Three layers of measurement are enough.
Layer 1: run the whole build a few times and look at the variance. Measuring once and drawing a conclusion is usually wrong.
# clear the cache and repeat three times, looking at both mean and spread
hyperfine --warmup 1 --runs 3 --prepare 'rm -rf .next .contentlayer' 'pnpm build'
Layer 2: use a Node CPU profile to see which code spends the time.
# leave behind a V8 CPU profile (produces a .cpuprofile file)
node --cpu-prof --cpu-prof-dir=./prof ./node_modules/.bin/next build
# open the .cpuprofile in Chrome DevTools' Performance panel and sort by self time
What you're looking for in the profile is the character of the top frames. If micromark-family frames are on top, it's parsing; if unist-util-visit is on top, it's plugin traversal; if it's acorn-family frames, it's MDX expression compilation; and if a highlighter's name shows up, that's your culprit.
Layer 3: break the time down per plugin. This gives you the most actionable information of the three. Wrapping unified's attachers lets you time each plugin's traversal separately.
// time-plugins.mjs — measures each plugin's tree-traversal time
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeStringify from 'rehype-stringify'
const totals = new Map()
// wrap the attacher so only the transformer's execution time is accumulated
function timed(plugin, name, options) {
return function (...args) {
const transformer = plugin.call(this, options)
if (typeof transformer !== 'function') return transformer
return async (tree, file) => {
const t0 = performance.now()
const result = await transformer(tree, file)
totals.set(name, (totals.get(name) ?? 0) + (performance.now() - t0))
return result
}
}
}
const processor = unified()
.use(remarkParse)
.use(timed(remarkGfm, 'remark-gfm'))
.use(remarkRehype)
.use(timed(rehypeSlug, 'rehype-slug'))
.use(timed(rehypeAutolinkHeadings, 'rehype-autolink-headings'))
.use(rehypeStringify)
const source = readFileSync(process.argv[2], 'utf8')
const t0 = performance.now()
for (let i = 0; i < 100; i++) await processor.process(source)
const total = performance.now() - t0
console.table(
[...totals].map(([name, ms]) => ({
plugin: name,
ms: +ms.toFixed(1),
share: `${((ms / total) * 100).toFixed(1)}%`,
}))
)
console.log(`total ${total.toFixed(1)}ms for 100 passes`)
Running this script against your repo's largest documents usually turns up something surprising. Either a seemingly minor plugin — a table-of-contents generator, heading anchors — rises to the top, or the opposite happens: the plugins' combined total is negligible, which means swapping the pipeline wouldn't matter. Either way, that's the information that decides what you do next.
Why the Plugin Chain Is Expensive
unified's design is elegant. Plug a plugin into the processor, and each one takes the tree, fixes it up, and hands it to the next. The problem is the cost structure behind that elegance.
Each plugin does its own full tree traversal. GFM tables, task lists, autolinks, strikethrough, smart quotes, directives, heading slugs, anchor links — each one adds another traversal. If a document has twelve plugins, that's twelve passes over the tree. Each individual pass does little work, but chasing pointers through node objects gives it poor cache locality.
Add MDX on top and you get one more layer. The JSX and curly-brace expressions in an MDX document have to be re-parsed by a JavaScript parser into an estree, and the output is an executable JS module rather than HTML. Even after the Markdown compilation is done, the bundler parses that JS again — the same content is effectively parsed three times over.
So there are moves that pay off immediately, even without replacing the pipeline.
- Remove unused plugins. It's genuinely common for plugins inherited from a template to just sit there unused. Pull them out one at a time and diff the resulting HTML.
- Merge several plugins into one traversal. If three plugins all touch headings, you can fold them into a single hand-written one. It's a trade against maintenance cost.
- Process documents that don't need MDX as plain Markdown. Compiling documents that use no components through the MDX path burns JS-parsing cost for nothing.
- Take code highlighting out of the build. Instead of running the highlighter at build time, cache its output, or narrow it to load only the languages and themes you actually use. This one change alone cuts build time in half on a surprising number of sites.
satteri — What a Native Core Changes, and What It Gives Up
satteri's structure is spelled out fairly clearly in its repository. Rust crates make up the pipeline, napi-rs bindings expose it to Node, and a TypeScript layer provides the plugin API.
satteri-pulldown-cmark— a fork of pulldown-cmark with MDX extensions attached. Handles CommonMark parsing.satteri-mdxjs-rs— a fork of Titus Wormer's mdxjs-rs, an MDX compiler modified to use pulldown-cmark and OXC.satteri-arena— the arena allocator and binary buffer primitive types.satteri-ast— mdast/hast node types, codecs, and tree operations.satteri-plugin-api— plugin traits, typed visitors, and the runner.
Pulling out three key design decisions: first, because the parser is built on pulldown-cmark, it operates close to a single-pass event stream. Second, the AST is laid out as binary data in an arena, which eliminates the cost of allocating an object and chasing a pointer for every single node. Third, extensions like GFM tables, task lists, footnotes, strikethrough, math, heading attributes, and YAML frontmatter are built in as parser features rather than plugins. That means the things previously handled through plugin traversals get absorbed into a single parsing pass.
Installation and usage are unremarkable. It ships precompiled native binaries via napi-rs, so no Rust toolchain is required. Binaries are provided for macOS (Apple Silicon and Intel), Linux x86_64 glibc, and Windows x86_64, and other environments fall back to a WASI build.
npm install satteri
# Vite integration — import .md/.mdx files directly
npm install vite-plugin-satteri
import { markdownToHtml, mdxToJs, defineMdastPlugin } from 'satteri'
// an mdast-level plugin — use the define helper for type inference
const stripDrafts = defineMdastPlugin({
name: 'strip-drafts',
visit: {
blockquote(node, ctx) {
if (ctx.text(node).startsWith('DRAFT:')) ctx.remove(node)
},
},
})
const html = await markdownToHtml(source, { mdastPlugins: [stripDrafts] })
const js = await mdxToJs(source, { mdastPlugins: [stripDrafts] })
There's a distinction you have to make here. satteri provides its own plugin API at both the mdast and hast levels. But that does not mean you can drop in existing remark-* / rehype-* npm packages as-is. Those plugins are written assuming a unified processor and plain JavaScript object trees, while satteri's tree crosses the napi boundary packed as binary data in an arena. In fact, this was exactly the constraint announced when Astro added satteri as a selectable processor — switching the processor to satteri replaces the entire unified chain, and ecosystem plugins like remark-toc, rehype-slug, and rehype-autolink-headings simply do not work.
The configuration shape below is quoted from secondary-source coverage, so verify the actual package names and options against Astro's official docs.
// astro.config.mjs — the opt-in shape in Astro, per reporting
import { satteri } from '@astrojs/markdown-satteri'
export default defineConfig({
markdown: {
processor: satteri({
features: { directive: true },
}),
},
})
To sum up the trade-off:
- What you gain: parsing and compilation drop to native speed, and extension features are handled without plugin traversal. The fewer plugins and the more documents a site has, the bigger the payoff.
- What you lose: the entire unified ecosystem plugin asset base. If you need custom processing, you have to rewrite it against satteri's plugin API.
- What still needs checking: attaching even one JS plugin means crossing between Rust and JS for every document. How this boundary cost scales with plugin count isn't documented with actual numbers anywhere public. If you're planning to attach many plugins, it's safer to measure it yourself against your own document set.
How Far to Trust the Benchmarks
Let me be honest about this part.
There are widely cited numbers: an Astro docs site going from 142 seconds to 63 seconds (about 2.25x), Cloudflare docs from 120 seconds to 55 seconds (about 2.18x), a mid-sized marketing site from 38 seconds to 22 seconds (about 1.73x). Alongside these travels a calculation that saving 79 seconds per build works out to roughly 230 CI hours a year at 50 builds a day.
As far as I've been able to confirm, here is the situation.
- These numbers were not presented by satteri's official site or repository. As of when I checked, satteri's official site showed only a documents-per-second figure from a browser WASM demo — no comparison numbers against unified/remark/mdx-js, and no reproducible benchmark harness, were published.
- The table above circulates in the form it's quoted in secondary-source articles covering the Astro integration. Reproduction conditions such as the measurement hardware, cache state, plugin configuration, and number of runs are not given.
- So while the general direction — "roughly twice as fast" — is architecturally plausible, treating the numbers above as your own project's expected outcome is not well supported.
Here's a checklist for reading benchmarks like these.
- What was actually compared against what. Comparing a unified chain with ten plugins attached against a native pipeline with none measures plugin count, not parser performance.
- Whole build, or just the pipeline. Even if the pipeline gets 5x faster, if that stage is 15 percent of the total, the total only drops 12 percent. Amdahl's law applies here just the same.
- Cold or warm. A rebuild with cache still present measures something else entirely.
- Whether the output is identical. If the resulting HTML differs, the speed comparison itself doesn't hold. When you swap parsers, you must diff the rendered output to check it.
The fourth point matters especially. The pulldown-cmark family and the micromark family can both have high CommonMark compliance and still produce subtly different output on edge cases. It's worth building a step, before switching, that renders the entire document set through both engines and compares them.
# a minimal procedure for diffing the full rendered output before and after switching
node scripts/render-all.mjs --engine=unified --out /tmp/html-unified
node scripts/render-all.mjs --engine=satteri --out /tmp/html-satteri
diff -ru /tmp/html-unified /tmp/html-satteri | head -100
What a Thousands-of-Pages Site Should Actually Do
Ranked by impact, replacing your tooling comes later than you'd think.
Priority 1: reduce the number of pages you process. Not running the pipeline is always faster than making the pipeline faster. Cache the compiled output keyed on the content file's hash, and build an incremental path that reprocesses only changed files. Sharing this cache remotely in CI pays off even on cold runners. Getting the cache key exactly right is covered in Monorepo CI Cache Strategies.
Priority 2: fix up prerendering and highlighting. As covered earlier, this dominates more as page count grows. Trim the highlighter down to load only the grammars and themes you actually use, and cache highlighting output keyed on content hash.
Priority 3: clean up the plugin chain. Use the profiling script from earlier to find the top three offenders and remove or merge them. This is a risk-free improvement, since it doesn't require changing tools.
Priority 4: only then, consider replacing the pipeline. If you've gone through this order and parsing and transformation are still at the top, a native core like satteri may genuinely be the answer. There's exactly one way to evaluate it — measure it yourself against your own document set, and diff the output.
You also need to judge the timing of adoption. satteri is a young project, its official docs still have gaps, and as of this writing it has not reached a stable 1.0. A documentation site's rendering accuracy is the kind of risk that's hard to walk back, so for a large production site it's reasonable to run parallel render comparisons for a few weeks before switching. Conversely, if you're starting a new documentation site with little plugin dependency, adopting it now doesn't carry much downside.
Closing — A Parser That Doesn't Run Beats a Fast Parser
satteri targets a real problem. JavaScript Markdown pipelines aren't slow because any individual piece of code is bad, but because the structure itself — every plugin re-traversing the tree — is the cost. Moving the parser and its extensions to a native single pass is an honest response to that structure.
- Measure first.
--cpu-profplus per-plugin timing will surface the bottleneck within 30 minutes. - On thousands-of-pages sites, prerendering and code highlighting are often bigger than parsing. Don't reverse that order.
- Incremental builds and content-hash caches are always priority one. Nothing beats not running at all.
- Move to satteri and the unified ecosystem's plugins don't come with you. The size of that asset base is the real deciding factor.
- The 2x figures in circulation are secondary-source quotes with no published reproduction conditions. Re-measure on your own repository, and diff the rendered output.
Most build-time problems are solved by doing less work, not by using a faster tool. Replacing tools is the option that comes after that.
References
- satteri — repository and crate layout
- satteri — official site and playground
- satteri — npm package
- unified — the processor and plugin model
- remark — the Markdown processor
- MDX — official docs
- mdxjs-rs — the Rust MDX compiler satteri forked
- pulldown-cmark — a Rust CommonMark parser
- Node.js — CPU profiling options
- hyperfine — a command-line benchmarking tool
- Why Is My Build So Slow (related post)
- Monorepo CI Cache Strategies (related post)