Reference
Benchmarks
Every figure on the landing page comes from one run of one harness with all four editors mounted in the same page. Numbers gathered from different browsers do not belong in the same table, so they are not gathered that way.
Method
- Chrome 152, one page, one run · all four editors mounted side by side · Matra 1.0.1, Tiptap 3.31, Lexical 0.50, Slate 0.126, on 2026-09-05.
- Median of seven samples, after five warm-up rounds.
- A forced layout read every round, so the DOM work is really done.
- Each published figure is the median of three such runs.
That is not ceremony. A single sample moved one metric by 6× between runs on a change that could not affect it, and the keystroke row swings by about 30% between runs for every editor here. One sample is a story, not a measurement.
Milliseconds, lower is better
| Operation | No editor | Matra | Tiptap | Lexical | Slate |
|---|---|---|---|---|---|
| Read a document in, 2,000 ¶ | — | 2.2 | 8.1 | 35.8 | — |
| Write it back as HTML, 2,000 ¶ | — | 0.3 | 1.7 | 5.3 | — |
| One keystroke, 200 ¶ | — | 0.073 | 0.158 | 0.102 | — |
| One keystroke, 2,000 ¶ | — | 0.480 | 0.622 | 0.547 | — |
| Put an editor on screen, 200 ¶ | 1.0 | 1.2 | 4.6 | 2.3 | 5.0 |
| Put an editor on screen, 2,000 ¶ | 12.2 | 14.6 | 34.1 | 21.6 | 50.2 |
These numbers are slower than the ones that were here before
The machine is why. Same harness, same browser, a different day: Lexical parsed the same document in 34.7 ms one week and 76.9 ms the next, without a line of it changing. Only the comparison inside a single run means anything, which is exactly why all four editors are mounted in one page and measured in one pass. Milliseconds from two different runs are two different measurements wearing the same unit.
The keystroke row changed hands
It used to be the row Matra lost, and losing it was fair: Lexical was ahead in every run. Measured back to back against the same rivals in the same session, before and after:
| One keystroke | Before | After |
|---|---|---|
| 200 paragraphs | 0.246 | 0.127 |
| 2,000 paragraphs | 1.465 | 0.847 |
Three things were costing it, all three found by profiling rather than by reading:
- Every ancestor of an edit was rebuilt by cutting. A paragraph changes, and each ancestor up to the document was rebuilt around it — cut the run of children in two, append the replacement, append the rest, then add up every child's size to arrive at a total that differed from the old one by exactly one child. On two thousand blocks that is four walks of two thousand children per character. Swapping the one child that moved makes it one array copy and one subtraction.
- The diff reached into the DOM for blocks it had already decided to skip. It read
childNodes[i]before asking whether blockiwas inside the edit at all, and for 1999 of 2000 blocks threw the answer away. Ask first, index second. - Every sixty-fourth keystroke threw the rendered document away. The position map absorbs each edit rather than rewriting itself, and capped its backlog at sixty-four — past which the whole document was rebuilt from scratch. It was the backlog that had gone stale, not the DOM. That one was a correctness bug in a performance bug's clothing: a rebuild also dropped the state of every mounted node view.
Away from the browser's layout cost, in Node against happy-dom, the same three changes take a keystroke on two thousand paragraphs from 0.464 ms to 0.077 ms — and the cost stops tracking the length of the document: 0.052 ms at twenty blocks against 0.077 ms at two thousand.
The "No editor" column
The same paragraphs, built by hand into a contenteditable div, with no editor in
the page at all. Putting a document on screen is mostly the browser making elements and laying
them out, and nothing can go faster than that. Two thirds of the 200-paragraph mount row and half
of the 2,000-paragraph one is that floor, which is worth knowing before reading a 0.3 ms difference
as a result.
Every mount is also checked rather than trusted: the harness mounts once more outside the timing and looks for the last paragraph's text on screen. A reconciler that returns before its DOM exists is the cheapest possible way to win this row, and an earlier version of the harness reported Slate's mount as a number when nothing had been drawn at all.
The mount row was wrong, and the harness was why
For a while this page said Lexical put an editor on screen faster than Matra. It did not. The harness ran each editor's teardown inside the timed function, and the layout read that makes a render measurement real came after it — so an editor whose teardown detaches its DOM had already taken its document off screen before the browser was asked to lay anything out. Lexical's teardown does that. Matra's does not: it drops listeners and leaves the document where it is.
So Matra paid for laying out two thousand paragraphs and Lexical did not, on a row where the layout is most of the number. Teardown now runs after the clock stops, every editor pays for its own layout, and both mount rows changed hands. It is the same class of mistake the harness already refused to make for Slate, made one level up — which is the argument for a floor row and a verification pass rather than for trusting a number because it came out of a timer.
What is missing, and why
Slate is measured on mount only. Its keystroke goes through a React render that has not
happened by the time the timer stops, and the harness checks whether the text on screen
changed during the measurement — when it did not, it reports NOT MEASURED
instead of a number. An earlier version of the harness cheerfully reported Slate at 0.02 ms per
keystroke, which was the model update with nothing drawn behind it.
Each editor is driven through its own idiomatic API. That is the only fair way to do it and also the caveat worth stating: Lexical and Slate carry the rich-text behaviour their own quick-starts prescribe, which is not the same feature set as either starter kit.
Bundle size
One app importing that editor and its rich-text kit, bundled and minified with esbuild, then gzipped. Same method for all four.
| Gzipped | What is in it | |
|---|---|---|
| Matra | 31.1 kB | core and starter kit |
| Tiptap | 119.8 kB | with ProseMirror |
| Lexical | 94.0 kB | with @lexical/rich-text |
| Slate | 63.0 kB | mostly React · 29.0 kB without it |
Run it yourself
The harness is in the repository, under bench/browser.
# from a scratch directory
npm i @tiptap/core @tiptap/starter-kit @tiptap/pm \
lexical @lexical/rich-text @lexical/html \
slate slate-react slate-dom slate-history react react-dom esbuild
cp -r matra/packages/core/dist matra-dist
cp matra/bench/browser/{bench.src.js,index.html} .
npx esbuild bench.src.js --bundle --format=esm --outfile=bench.js \
--define:process.env.NODE_ENV='"production"'
python3 -m http.server 8899 Reload two or three times and take the median of the runs as well. If you get different numbers, they are your numbers · that is rather the point of shipping the harness.