Skip to content

Performance#

View Markdown

This page tracks benchmark results, bundle-size checks, and the optimizations that affect shipped output. Architecture and package pages should describe boundaries and APIs; measured performance belongs here.

For allocation and span-level investigation while developing parser or renderer changes, use Profiling Mode. Profiling answers "where is the work happening?" Benchmarking answers "how fast is this workload?"

What We Measure#

Ox Content has four performance surfaces, all reported on pull requests:

  • Runtime throughput for Markdown parsing and rendering.
  • Fixture production build time.
  • Static output weight for generated sites, including gzip size and rendered HTML gzip.
  • Initial request count for index.html and local critical-path assets.

Runtime matters for CLIs, dev servers, editor integrations, and batch builds. Output weight matters for documentation sites because generated HTML, CSS, and JS are what users fetch on every navigation. Absolute ceilings live in tools/benchmarks/perf-budgets.json. Relative base/head deltas still use the PR Benchmark comment.

Out of scope#

These are intentionally not gated by the budget file:

  • Host-absolute ops/sec on developer laptops. Use the published relative ordering; the floors are for the Blacksmith runner class.
  • Competitor app sizes (VitePress, Astro) as fail-the-build ceilings.
  • Full docs-site output (docs/dist), OG image binaries, and fetched fonts.
  • vp run build:npm wall time. The fixture buildMs field is the build-time budget; the package graph is not timed by this harness.
  • Isolated search, theme, embed, Code Play, or MDX island payloads. The default fixture does not enable those features, so they stay out of the default-shell number.

Runtime Snapshot#

Ox Content is positioned both as a document generator and as a high-performance Markdown toolkit. The numbers below focus on the Markdown engine side.

Speed is only half of a fair comparison: Markdown engines differ in how much of CommonMark they implement, and some deliberately trade spec coverage for throughput. Every row therefore carries a measured CommonMark conformance rate next to its speed, so a faster engine that skips spec behavior is visible as such rather than simply ranking higher. See CommonMark Conformance for how Ox Content scores and how the rate is measured.

Benchmark sweep generated on 2026-08-30 (median of 7 runs). Numbers track the host machine; the relative ordering between engines is the stable signal. Regenerated by tools/scripts/render-benchmark-tables.mjs.

Environment: runner blacksmith-32vcpu-ubuntu-2404, Node v26.8.1, Bun 1.3.14, CPU AMD EPYC, 32 logical cores.

CommonMark column: share of the 652 CommonMark 0.31.2 spec examples an engine renders correctly, measured by tools/benchmarks/commonmark-conformance/run.mjs. Each engine runs in the most spec-faithful configuration it exposes, and both sides of the comparison pass through the conformance suite's HTML normalizer, so engines are ranked by behavior rather than by markup spelling.

Parse Only (48.7 KB)#

Library ops/sec avg time throughput CommonMark
ox-content (native) 11,411 0.09 ms 542.99 MB/s 100.0%
@ox-content/napi 5,880 0.17 ms 279.79 MB/s 99.5%
pulldown-cmark 5,066 0.20 ms 241.06 MB/s 100.0%
xai-grok-markdown-core (Grok Build) 4,563 0.22 ms 217.13 MB/s 100.0%
md4x (napi) 1,400 0.71 ms 66.64 MB/s 99.5%
satteri 1,378 0.73 ms 65.59 MB/s 98.9%
md4x (wasm) 1,066 0.94 ms 50.72 MB/s 99.5%
md4w (md4c) 1,060 0.94 ms 50.46 MB/s 91.7%
markdown-it-ts 868 1.15 ms 41.30 MB/s 100.0%
@tanstack/markdown 742 1.35 ms 35.32 MB/s 47.4%
@mizchi/markdown (native) 708 1.41 ms 33.71 MB/s 100.0%
@mizchi/markdown (js) 555 1.80 ms 26.41 MB/s 100.0%
@mizchi/markdown (wasm) 551 1.81 ms 26.24 MB/s 100.0%
marked 387 2.58 ms 18.44 MB/s 93.4%
markdown-it 314 3.18 ms 14.97 MB/s 100.0%
remark 31 32.73 ms 1.45 MB/s 99.8%

Parse + Render (48.7 KB)#

Library ops/sec avg time throughput CommonMark
ox-content (native) 7,445 0.13 ms 354.28 MB/s 100.0%
@ox-content/napi 7,375 0.14 ms 350.97 MB/s 99.5%
pulldown-cmark + push_html 4,742 0.21 ms 225.66 MB/s 100.0%
ferromark 4,389 0.23 ms 208.87 MB/s 88.8%
Bun.markdown.html 3,886 0.26 ms 184.93 MB/s 100.0%
md4x (napi) 3,729 0.27 ms 177.47 MB/s 99.5%
satteri 3,332 0.30 ms 158.56 MB/s 98.9%
md4w (md4c) 2,370 0.42 ms 112.78 MB/s 91.7%
md4x (wasm) 2,211 0.45 ms 105.22 MB/s 99.5%
markdown-it-ts 703 1.42 ms 33.46 MB/s 100.0%
@mizchi/markdown (native) 664 1.51 ms 31.60 MB/s 100.0%
@mizchi/markdown (wasm) 520 1.92 ms 24.73 MB/s 100.0%
@tanstack/markdown 493 2.03 ms 23.44 MB/s 47.4%
@mizchi/markdown (js) 482 2.08 ms 22.92 MB/s 100.0%
marked 366 2.73 ms 17.42 MB/s 93.4%
markdown-it 285 3.51 ms 13.55 MB/s 100.0%
micromark 30 32.82 ms 1.45 MB/s 100.0%
remark 25 39.26 ms 1.21 MB/s 99.8%

Parse Only (~1 MB)#

Library ops/sec avg time throughput CommonMark
ox-content (native) 528 1.89 ms 539.95 MB/s 100.0%
@ox-content/napi 303 3.30 ms 309.82 MB/s 99.5%
pulldown-cmark 233 4.28 ms 238.82 MB/s 100.0%
xai-grok-markdown-core (Grok Build) 213 4.70 ms 217.84 MB/s 100.0%
md4w (md4c) 48 20.81 ms 49.16 MB/s 91.7%
md4x (napi) 46 21.59 ms 47.39 MB/s 99.5%
md4x (wasm) 44 22.98 ms 44.52 MB/s 99.5%
satteri 26 37.99 ms 26.94 MB/s 98.9%
@tanstack/markdown 23 43.71 ms 23.41 MB/s 47.4%
@mizchi/markdown (native) 22 44.47 ms 23.01 MB/s 100.0%
markdown-it-ts 22 45.92 ms 22.28 MB/s 100.0%
marked 14 70.42 ms 14.53 MB/s 93.4%
@mizchi/markdown (wasm) 11 87.27 ms 11.72 MB/s 100.0%
markdown-it 10 97.51 ms 10.49 MB/s 100.0%
@mizchi/markdown (js) 10 98.26 ms 10.41 MB/s 100.0%
remark 1 1169.06 ms 0.88 MB/s 99.8%

Parse + Render (~1 MB)#

Library ops/sec avg time throughput CommonMark
ox-content (native) 329 3.04 ms 336.45 MB/s 100.0%
@ox-content/napi 291 3.44 ms 297.82 MB/s 99.5%
pulldown-cmark + push_html 222 4.50 ms 227.46 MB/s 100.0%
ferromark 207 4.82 ms 212.13 MB/s 88.8%
Bun.markdown.html 189 5.30 ms 192.92 MB/s 100.0%
md4x (napi) 168 5.96 ms 171.66 MB/s 99.5%
satteri 151 6.64 ms 154.10 MB/s 98.9%
md4w (md4c) 120 8.32 ms 122.98 MB/s 91.7%
md4x (wasm) 106 9.46 ms 108.11 MB/s 99.5%
@mizchi/markdown (native) 19 51.49 ms 19.87 MB/s 100.0%
markdown-it-ts 17 58.20 ms 17.58 MB/s 100.0%
@tanstack/markdown 16 63.37 ms 16.15 MB/s 47.4%
marked 13 79.68 ms 12.84 MB/s 93.4%
@mizchi/markdown (wasm) 12 81.01 ms 12.63 MB/s 100.0%
@mizchi/markdown (js) 11 90.76 ms 11.27 MB/s 100.0%
markdown-it 9 107.83 ms 9.49 MB/s 100.0%
micromark 1 822.92 ms 1.24 MB/s 100.0%
remark 1 1975.03 ms 0.52 MB/s 99.8%

The tables above are regenerated from a clean Blacksmith 32 vCPU CI environment by the Benchmark docs workflow; run OX_CONTENT_BENCHMARK_RUNS=7 vp run bench:docs to refresh them locally. Absolute ops/sec track the host (earlier sweeps on faster hardware report higher numbers); the relative ordering between engines is the stable signal.

On the 48.7 KB document the boundary-free native row leads the fastest other engine (pulldown-cmark) by ~1.6x on parse-only and ~1.2x on parse+render; at ~1 MB those leads are ~1.5x and ~1.2x. On parse+render the second row is now @ox-content/napi rather than a third-party engine, so the N-API boundary — not the engine — is what separates the top two rows there. The JavaScript-facing @ox-content/napi row is 4.0–5.1x faster than the two TypeScript renderers on parse-only and 7.1–12.1x faster on parse+render. At ~1 MB those N-API leads grow to 7.7–8.7x and 11.4–12.0x respectively, while the native pipeline sustains 267–370 MB/s. The @mizchi/markdown rows are split by JS, Wasm, and native runtime so package-boundary and native-build costs stay visible, while the unified/remark pipeline stays at ~1 op/sec in both ~1 MB tables and micromark measures ~1 op/sec on parse+render.

The runtime sweep covers more than the tables above. The harness also runs small and medium Markdown inputs, an async parse+render target for the N-API package (so PR checks can catch JavaScript-boundary overhead regressions), and an optional Bun.markdown comparison when the harness is run under Bun.

The TypeScript renderer comparisons use each package's public default API. @tanstack/markdown is measured through parseMarkdown for parse-only and renderHtml for parse+render. markdown-it-ts reuses one initialized instance and measures parse and render, matching the existing markdown-it setup. Parser construction, module loading, and benchmark warmup happen outside the timed loop; each parse+render operation starts from the same Markdown string.

CommonMark Conformance#

Ox Content targets full CommonMark conformance. The engine is checked against the vendored CommonMark 0.31.2 specification on every CI run, not only when the benchmark tables are refreshed:

  • Core profile: 652 / 652 examples. cargo test -p ox_content_renderer --test spec_commonmark fails both when a passing example regresses and when a recorded failure starts passing, so the baseline cannot quietly drift.
  • GFM profile: 649 / 652 examples. The three differences are spec examples 608, 611, and 612, where the GFM autolink extension deliberately linkifies bare URLs and emails that plain CommonMark leaves as text. They are listed in crates/ox_content_renderer/tests/spec_fixtures/commonmark-known-failures.txt.
  • GFM extensions: every example in the GitHub Flavored Markdown 0.29-gfm spec sections for tables, task lists, strikethrough, autolinks, and disallowed raw HTML, driven by spec_gfm.rs.

Extensions beyond CommonMark — GFM tables, task lists, strikethrough, footnotes, and the built-in embeds — are opt-out rather than opt-in, so a document that uses none of them conforms to the specification under the normalization rule described below (ox-content adds slug id attributes to headings, so the HTML is not byte-identical to the spec's). Markdown Baseline lists each toggle.

Reading the CommonMark Column#

The rate beside each engine is measured, not claimed. tools/benchmarks/commonmark-conformance/run.mjs renders all 652 spec examples with every engine in the tables and compares the output to the specification.

Two choices make that comparison fair, and both matter when reading the numbers:

  • Each engine runs in the most spec-faithful configuration it exposes, not in its benchmark defaults. markdown-it and markdown-it-ts use their commonmark presets, micromark and remark-html are told to pass raw HTML through instead of escaping it, and md4w runs with parseFlags: 0. Judging an engine by a default preset that enables GFM extensions would measure the preset rather than the engine. The @mizchi/markdown JS, Wasm, and native rows disable their default autolink and tagfilter extensions for this column. Engines that expose no such mode — marked, md4x, @tanstack/markdown — are scored as they ship.
  • Both sides pass through the conformance suite's HTML normalizer, so differences that do not change how a document renders (entity spelling, attribute order, <br /> vs <br>, whitespace between block tags) are not counted as conformance gaps. Without it the table would rank engines by markup spelling: ox-content scores 82.5% under byte-exact comparison purely because it adds slug id attributes to headings.

The comparison is symmetric — pulldown-cmark, an independent Rust implementation unrelated to ox-content, also scores 100% — so the normalizer is not tuned to any one engine.

Two rows are the same engine at different layers. ox-content (native) is the core profile at 100%; @ox-content/napi scores 99.5% because its defaults enable the bare-URL autolinking builtin, which linkifies examples 602, 608, and 611. Pass autolinkUrls: false to turn it off.

Regenerate the results with:

node tools/benchmarks/commonmark-conformance/run.mjs --json tools/benchmarks/commonmark-conformance/results.json

CJK Emphasis#

CommonMark's emphasis rules do not recognize ** placed immediately inside CJK punctuation, so A**強調。**B renders as literal text rather than bold. This affects every spec-conformant engine — it is a property of the specification's left/right-flanking delimiter rules, not an implementation gap. Emphasis that merely sits next to CJK charactersこれは**重要**です。 — is allowed by the specification and works everywhere.

Ox Content ships an opt-in deviation for it. cjkEmphasis classifies East Asian punctuation as an ordinary character when deciding whether a delimiter run may open or close, which lets those runs pair; halfwidth ASCII punctuation is left alone, so Latin documents parse identically. It is off by default, which is what keeps the 652/652 figure above true of the shipped default. See CJK Emphasis.

Bundle Size#

The bundle-size benchmark builds representative docs applications and measures the generated production output. It reports:

  • Total bytes across the generated output directory.
  • Gzipped bytes for JS, CSS, HTML, and JSON assets.
  • File count in the output directory.
  • Estimated initial requests for index.html and local assets referenced from HTML or CSS.

Latest Blacksmith fixture sweep on 2026-08-26 (blacksmith-32vcpu-ubuntu-2404, Node v26.7.0), taken from PR Benchmark comments such as #1001 and #1005.

App Gzipped HTML gzip Requests Files
ox-content (bare) 5.8 KB 2.9 KB 1 5
ox-content 31.4 KB 9.8 KB 5 10
ox-content + Vue 53.9 KB 9.8 KB 5 10
VitePress (bare) 47.3 KB 8.4 KB 6 14
Astro + Vue 33.1 KB 5.4 KB 3 7
VitePress (default) 717.2 KB 14.2 KB 21 29

ox-content (bare) is the no-JS baseline. ox-content includes the built-in docs shell. ox-content + Vue adds framework island support. VitePress rows use the same benchmark content so the comparison is focused on generated output shape rather than authoring content.

Bundle-size comparisons are intentionally reported beside request count. A smaller gzip number is not always better if it creates too many blocking requests; a larger shared chunk can be preferable when it removes repeated bytes from every generated page and is cacheable across navigation.

Chunk Optimization#

The SSG pipeline first renders complete HTML pages, then extracts shared assets after all pages are known. The TypeScript plugin calls the Rust-backed externalizeSsgAssets implementation, which rewrites each generated page and writes hashed assets under assets/.

The optimizer keeps the rules conservative:

  • Identical CSS and JS content is deduplicated by content, then emitted once with a content hash in the filename.
  • Core CSS sections such as base and footer styles are linked as a shared ox-content-core-*.css file.
  • Theme CSS stays inline when it is small or when it contains relative url(...) references, so path resolution does not change.
  • Search payload code is split from the main boot script into a separate ox-content-search-*.js chunk when the generated script contains the search placeholder.
  • Generated scripts are emitted with defer, and public asset paths honor the configured site base.

The goal is not to split every feature into a separate file. The docs shell is small enough that excessive chunking can increase startup work. The current policy favors stable shared chunks for repeated bytes and avoids moving content when doing so would create path-resolution risk or extra request overhead.

PR Regression Gate#

Pull requests run the benchmark workflow against both the base commit and the head commit on blacksmith-32vcpu-ubuntu-2404. The workflow posts one report with runtime, competitive snapshot, environment, bundle-size, and budget sections.

Runtime rows compare the large benchmark target for @ox-content/napi and @ox-content/napi (async). Changes within +/-5% are treated as noise, and the check fails when head throughput is more than 10% slower than base.

The competitive snapshot is not a gate. It uses the head commit to show the target package against the next fastest comparison package for the same large input corpus.

Bundle rows compare gzipped output for each successful benchmark app. The check fails when gzipped size grows by more than 5%. Head measurements also fail when they exceed tools/benchmarks/perf-budgets.json. Maintainers can intentionally accept either failure with the benchmark-regression-accepted PR label.

Area Audit#

Each major surface has a measured baseline and either a target or an explicit no-op. Numbers are from the 2026-08-26 Blacksmith reports cited above, not from a local laptop run.

Area Baseline Target or no-op
Fixture build time Default shell ~370–430 ms; cold runs near 750 ms Ceiling 5 s. No tighter gate: #1003 swung the same fixture +45% with no output-size change.
Runtime parse/render large @ox-content/napi parse ~5.3k–6.1k ops/sec, parse+render ~6.5k–7.3k Floors 2500 / 4000 ops/sec. Keep beating pulldown-cmark on the published parse+render tables.
Bundle gzip bare 5.8 KB, default 31.4 KB, +Vue 53.9 KB Keep default under 48 KB. Shared chunks already beat VitePress default (717.2 KB). Measured no-op for extra default-shell splits: 5 requests vs VitePress 21.
Rendered HTML gzip bare 2.9 KB, default/Vue 9.8 KB Ceiling 16 KB for the default shell.
Initial requests bare 1, default/Vue 5 Ceiling 8. Do not add blocking requests to the default fixture.
Published package weight Not measured by measure.mjs No-op: CI gates generated site output, not npm tarball weight.
Search / embeds / Code Play / MDX islands Fixture apps do not enable these No-op: keep feature payloads out of the 31.4 KB default-shell number until a fixture exercises them.

Reproduce#

Run the JavaScript benchmark harness from the repository root:

node tools/benchmarks/bundle-size/parse-benchmark.mjs

The benchmark includes @tanstack/markdown, markdown-it-ts, md4w (md4c), and md4x (napi) by default, and adds Bun.markdown.html automatically when bun is available.

Run the bundle-size benchmark from the repository root:

node tools/benchmarks/bundle-size/measure.mjs

For a faster local rerun after dependencies are installed, use:

node tools/benchmarks/bundle-size/measure.mjs --skip-install

Write JSON for the budget checker, and optionally run the dedicated build-time sweep (also from the repository root):

node tools/benchmarks/bundle-size/measure.mjs --json /tmp/bundle.json
node tools/benchmarks/bundle-size/build-time-benchmark.mjs --json /tmp/build.json
node tools/benchmarks/bundle-size/check-budgets.mjs --bundle /tmp/bundle.json --build /tmp/build.json

For Rust-side parser benchmarks, use:

cargo bench -p ox_content_parser

For real-world Markdown corpus benchmarks, populate the optional corpus first:

node tools/scripts/fetch-bench-corpus.mjs
cargo bench -p ox_content_parser --bench corpus

For N-API transfer-format micro-benchmarks, see @ox-content/napi.

Last updated: