# Performance

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.md). 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`](https://github.com/ubugeeei-prod/ox-content/blob/main/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](#commonmark-conformance) for how Ox Content scores and
how the rate is measured.

<!-- benchmark:tables:start -->

_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% |

<!-- benchmark:tables:end -->

The tables above are regenerated from a clean Blacksmith 32 vCPU CI environment by the
[Benchmark docs workflow](https://github.com/ubugeeei-prod/ox-content/blob/main/.github/workflows/benchmark-docs.yml);
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](https://spec.commonmark.org/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](./built-in/markdown.md) 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:

```bash
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](./examples/cjk-emphasis.md).

## 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`](https://github.com/ubugeeei-prod/ox-content/blob/main/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:

```bash
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:

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

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

```bash
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):

```bash
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:

```bash
cargo bench -p ox_content_parser
```

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

```bash
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](./packages/napi.md#mdast-transfer-micro-benchmark).
