Benchmark
We saved six dense reports with 5 editor engines. All 5 rewrote every one of them.
Load a markdown file. Save it without touching anything. Count the lines that changed. On every serialising engine we tested, the answer on these files was never zero.
At a glance
Six files saved with no edits. How many came back byte-identical?
Red bar: how much of each file the engine changed, on average. Tick: its worst file. Right: how many of the six files came back byte-identical.
-
remark
0/6 byte-identical
41% avg
-
prosemirror-markdown
0/6 byte-identical
49% avg
-
marked + turndown
0/6 byte-identical
52% avg
-
Milkdown 7
0/6 byte-identical
43% avg
-
Tiptap 3
0/6 byte-identical
46% avg
-
CodeMirror 6default settings, doc.toString() save
4/6 byte-identical
22% avg
-
Patch-on-savethe technique AsItIs uses (reference implementation)
6/6 byte-identical
0% avg
Every number here is derived from the Result A table below: the average and the worst of the six per-file percentages, and the byte-identical count. These are engines, not apps: see how to read this benchmark.
How to read this benchmark
- Engines, not apps. The rows are engines (the libraries inside editors), not GUI editors such as Typora, Obsidian or VS Code.
- Dense files on purpose. The corpus is six synthetic reports, deliberately dense with the constructs that break serialisers; short, plain files survive far better.
- Defaults, not plugins. Milkdown and Tiptap ran with their default presets; adding plugins fixes specific constructs (images in Tiptap, frontmatter in Milkdown) and leaves the line counts about the same.
- The words. LF and CRLF are the two ways a line can end (Unix and Windows style); a BOM is an invisible marker some tools put at the start of a UTF-8 file. Neither is visible in an editor, and both show up in
git diff. "Byte-identical" means the saved file is exactly the bytes that were opened. - Conflict of interest. This benchmark is published by the maker of AsItIs, which uses the patch-on-save technique in the last column. Everything needed to check it is public.
The CodeMirror 6 row uses default settings and doc.toString(); a configured source editor (line separator set to the file's dominant ending, saved with sliceDoc()) returns all six files byte-identical on a save with no edits, exactly like the reference. Result B (real edits) runs the reference only. In progress: a real-world public corpus, the configured CodeMirror and plugin-complete rows, and Result B through every engine.
Result A: save with no edits
Percent of lines changed when each engine loads and saves the file. Lower is better; 0 is byte-identical.
| Report | remark | prosemirror-markdown | marked + turndown | Milkdown 7 | Tiptap 3 | CodeMirror 6 (default settings) | Patch-on-save (reference) |
|---|---|---|---|---|---|---|---|
| 01 Pentest findings (LF) | 32.5% | 45.4% | 44.8% | 36.2% | 38.7% | 0 | 0 |
| 02 AI code audit (CRLF + BOM) | 88.7% | 100% | 100% | 88.7% | 100% | 100% | 0 |
| 03 SOC 2 readiness (mixed CRLF/LF) | 64.8% | 64.8% | 63.4% | 64.8% | 59.2% | 33.8% | 0 |
| 04 Architecture spec | 14.3% | 37.4% | 38.5% | 18.7% | 25.3% | 0 | 0 |
| 05 LLM chat export | 11.2% | 7.1% | 16.3% | 11.2% | 10.2% | 0 | 0 |
| 06 Edge cases (no final newline) | 36.0% | 42.1% | 48.2% | 38.6% | 40.4% | 0 | 0 |
| Byte-identical files | 0/6 | 0/6 | 0/6 | 0/6 | 0/6 | 4/6 | 6/6 |
Milkdown and Tiptap ran with default presets, without image, frontmatter, math, footnote or alert extensions. Products built on them often add such plugins, which fix some construct-level losses but not the reformatting, escaping and line-ending changes that come from re-serialising. Rows with plugin-complete configurations (Tiptap with its Image extension, Milkdown with a frontmatter plugin) are being added to the public repository; this page will quote them only once they are published and reproducible. The last column is a reference implementation of patch-on-save, the technique AsItIs uses, not the AsItIs app itself.
Method: the six test files
Six report files were built to look like the markdown people actually receive, each seeded with the constructs that break editors:
| File | Simulates | Byte-level traps |
|---|---|---|
| 01 Pentest findings | External pentest report: YAML frontmatter, severity tables with alignment, CVSS, HTTP and JSON proofs of concept, task lists, GitHub alerts, details blocks, footnotes, escaped pipes | LF |
| 02 AI code audit | Agent security review: XML finding tags, emoji severity table, diff blocks, nested quotes, CJK, Greek, Hebrew and Arabic text | CRLF + UTF-8 BOM |
| 03 SOC 2 readiness | 8-column control matrix, exceptions, nested lists with tabs, evidence image with title, trailing spaces | Mixed CRLF and LF, tabs, trailing whitespace |
| 04 Architecture spec | Design RFC: TOML frontmatter, Mermaid, inline and block math, a definition list, reference-style image, HTML reviewer comment | LF |
| 05 LLM chat export | Chat transcript: nginx configs, tables, Japanese, Chinese, Korean, right-to-left text, literal escape sequences | LF |
| 06 Edge cases | Setext headings, every list marker, odd ordered lists, lazy continuation, entities, nested fences, pipe-less tables, three horizontal-rule styles | No final newline |
Method: the seven configurations and the measure
Each file was loaded and saved with no edits by seven configurations (five serialising engines plus CodeMirror 6 saved two ways), using each engine's documented load and save calls: remark (unified 11 with GFM, frontmatter and math), prosemirror-markdown 1.13, marked to HTML to turndown 7, Milkdown 7.22 (commonmark and GFM presets), Tiptap 3.31 with the official markdown extension, CodeMirror 6 with default settings and a doc.toString() save, and CodeMirror 6 with the patch-on-save reference technique.
These are engines, not the branded apps. Closed GUI apps such as Typora, and Obsidian and VS Code's rendered editors, were not tested directly; Obsidian is built on CodeMirror 6, so it is closer to the CodeMirror rows. Obsidian users have reported since 2022 that opening a CRLF file re-saves it with LF (forum.obsidian.md/t/37955); we have not verified this on the current release.
The authors of this benchmark build AsItIs, which uses the patch-on-save technique in the last column.
The measure is the percentage of original lines that a line diff marks as removed. 0 means byte-identical. The corpus is pinned by SHA-256 and every package version is pinned in the lockfile.
What they broke
Content lost
- Tiptap (StarterKit, without the Image extension) turned the SOC 2 evidence image and the spec's reference-style image into plain text; with Image loaded they are kept. It removed the
<details>wrapper, the<dl>definitions and the HTML reviewer comment, and turned the YAML frontmatter into a heading. - Milkdown (default presets) dropped the reference-style image entirely and logged an internal error doing it. It turned the opening
---of the YAML frontmatter into a horizontal rule, so the metadata became body text, and stripped the BOM. - marked + turndown removed all four
<finding>tags with their severity attributes from the AI audit, so the severity metadata is gone, along with<details>,<dl>,kbd,mark,supand the reviewer comment. Task items became plain bullets. - prosemirror-markdown has no table node in its default schema: every table in the pentest report and the SOC 2 control matrix was flattened into one paragraph of pipes. It also escaped footnotes and merged their definitions, escaped task checkboxes, and broke both frontmatter blocks and the math block.
- remark kept most constructs, with frontmatter, GFM and math plugins enabled, but still changed 11% to 89% of lines.
Meaning silently changed
- Every serialising engine escaped the GitHub alert:
> [!CAUTION]became> \[!CAUTION], so GitHub renders an ordinary quote instead of the red Caution box. In a security report, that box is the handling notice. - Tiptap escaped footnotes (
[^idor]became\[^idor\]), so they render as literal text, and double-escaped entities: became&nbsp;, which renders as the literal text . - Line endings: every engine except patch-on-save changed the line endings of the CRLF file and flattened the mixed file. remark and Milkdown also stripped the BOM. In a repository that stores CRLF, that shows up as every line changed in
git diff.
Noise
- Tables re-padded (remark, Milkdown, Tiptap). List markers normalised:
+and-became*in remark, prosemirror-markdown and Milkdown, and-in turndown. The1. 1. 1.list was renumbered1. 2. 3.by all five. - Hard breaks converted: two trailing spaces became a backslash in remark, prosemirror-markdown and Milkdown, and the reverse in turndown and Tiptap.
CodeMirror 6, two ways
CodeMirror 6 with default settings is byte-perfect on LF files but rewrites 100% of the CRLF file and a third of the mixed file, because it normalises line breaks to LF and doc.toString() joins with LF. Configured with lineSeparator set to the file's dominant ending and saved with sliceDoc(), it returns all six files byte-identical on a save with no edits. The difference appears on edits: a line inserted next to a CRLF line in the mixed file gets LF from CodeMirror and CRLF from patch-on-save, which copies the neighbouring line's ending.
Result B: real edits
Edits issued the way rendered-view widgets would issue them, as minimal source patches, through the patch-on-save reference. This table has no comparator yet: running the same edits through each engine's API and counting the collateral lines is in progress.
| File | Edits | Result | A naive save would change |
|---|---|---|---|
| 01 Pentest findings (LF) | Tick a task, edit a table cell, insert a table row, fix a word | 3 changed, 1 added | 3 lines |
| 02 AI code audit (CRLF + BOM) | Edit a table cell, insert a 2-line paragraph | 1 changed, 3 added. BOM kept, new lines written as CRLF (115 to 118 CRLF) | 115 lines |
| 03 SOC 2 (mixed CRLF/LF) | Tick a task, edit a cell in an 8-column table | 2 changed. The 24 CRLF and 47 LF lines stay as they were | 25 lines |
| 06 Edge cases (no final newline) | Edit a cell in a pipe-less table, edit the last line | 2 changed. Still no final newline | 2 lines |
Every byte outside the edited lines (BOM, per-line endings, trailing spaces, tabs, final newline state) is copied from the original.
Result C: speed
| Operation (ms, one run each) | 1 MB | 5 MB | 20 MB |
|---|---|---|---|
| lezer full parse (CodeMirror's markdown parser) | 58 | 217 | 873 |
| Patch-on-save: open, 3 edits, save | 9 | 52 | 222 |
| markdown-it render | 65 | 381 | 1,216 |
| marked render | 51 | 287 | 906 |
| prosemirror-markdown parse | 39 | 184 | skipped |
| remark parse | 1,058 | 18,747 | skipped |
| Milkdown load and save | 20,596 | skipped | skipped |
| Tiptap load and save | 111,611 | skipped | skipped |
Node 26 on Apple silicon (M5). Milkdown and Tiptap ran in jsdom (a simulated browser inside Node, since both need a page to mount on). Tiptap is as slow with no DOM mounted (111 s at 1 MB headless; 0.6 s at 128 KB, 4 s at 256 KB, 23 s at 512 KB), so the time is in its markdown extension, not jsdom. Milkdown needs a DOM, so its absolute time may be pessimistic.
Rows measure different amounts of work: the parsers parse only; the editors parse, build a document and serialise. Timings are single runs and vary by about a factor of two between runs. The round-trip, construct and edit results are deterministic.
Limits of this benchmark
- Six files, deliberately dense with hard constructs. Plain prose with a few headings round-trips much better in every engine (see file 05, where engines changed 7 to 16% of lines).
- "Lines changed" counts formatting changes too. Re-padding a table is not content loss, but it makes review and
git diffnoisy. - UTF-8 only. UTF-16 and legacy encodings are not tested.
- Engines, not products. A configured source editor returns every file here byte-identical on a save with no edits; the rendered-view engines do not. Real edits (Result B) have been run through the reference only.
- Timings are single runs on one machine. Medians of several runs are planned.
Reproduce it
git clone https://github.com/Katta041/markdown-roundtrip-benchmark.git
cd markdown-roundtrip-benchmark
npm ci
# a few seconds: round trip, edits, 1 MB timings
npm run bench -- --quick
# full run, about 3 minutes: adds 5 MB and 20 MB timings
npm run bench Requires Node 24 or later. The run writes results/RESULTS.md and the exact bytes each engine saved to results/output/<engine>/, so you can diff it yourself. To add an engine or correct an unfair configuration, open a pull request: an adapter is one object with a roundTrip function. Everything is on GitHub, MIT licensed.
Results as published in the repository on 2026-10-01.