Benchmark snapshots are captured in benchmarks/chrome.json and benchmarks/safari.json. The tables below reflect the snapshot taken on 2026-03-28.
layout() is the resize hot path — call it on every container width change. prepare() is the one-time cost paid when text first appears. Optimizing resize performance means keeping layout() fast; prepare() budget is spent once per text block.
Top-level batch
Numbers over the shared 500-text batch corpus.
| Browser | prepare() | layout() | DOM batch | DOM interleaved |
|---|
| Chrome | 18.85ms | 0.09ms | 4.05ms | 43.50ms |
| Safari | 18.00ms | 0.12ms | 87.00ms | 149.00ms |
The DOM columns show how much equivalent DOM measurement costs for comparison: batch reads are cheaper than interleaved reads, but both are far slower than layout().
Rich line APIs
Shared corpus
| Browser | layoutWithLines() | walkLineRanges() | layoutNextLine() |
|---|
| Chrome | 0.05ms | 0.03ms | 0.07ms |
| Safari | 0.05ms | 0.03ms | 0.07ms |
Numbers under heavy load using the Arabic long-form corpus.
| Browser | layoutWithLines() | walkLineRanges() | layoutNextLine() |
|---|
| Chrome | 4.99ms | 2.17ms | 6.39ms |
| Safari | 4.66ms | 2.37ms | 5.53ms |
Chrome baseline. Each row is a single long-form text prepared and laid out at 300px width. prepare() is split into its two internal phases — analyze() for segmentation and glue work, measure() for canvas width measurement — so you can see which script is expensive for which reason.
| Corpus | analyze() | measure() | prepare() | layout() | segs (analyze→prepared) | lines @ 300px |
|---|
| Japanese prose (story 2) | 1.90ms | 4.00ms | 6.10ms | 0.02ms | 1,773→2,667 | 193 |
| Japanese prose | 4.10ms | 8.30ms | 12.60ms | 0.03ms | 3,606→5,044 | 380 |
| Korean prose | 2.40ms | 8.40ms | 11.40ms | 0.04ms | 5,282→9,679 | 428 |
| Chinese prose | 6.10ms | 13.10ms | 19.20ms | 0.05ms | 5,433→7,949 | 626 |
| Chinese prose (story 2) | 3.70ms | 8.10ms | 11.80ms | 0.03ms | 3,271→4,745 | 375 |
| Thai prose | 8.10ms | 5.40ms | 13.50ms | 0.05ms | 10,281→10,281 | 1,024 |
| Myanmar prose | 0.60ms | 0.70ms | 1.30ms | <0.01ms | 797→797 | 81 |
| Myanmar prose (story 2) | 0.30ms | 0.60ms | 0.90ms | <0.01ms | 498→498 | 54 |
| Urdu prose | 2.20ms | 3.30ms | 5.50ms | 0.03ms | 6,051→6,051 | 351 |
| Khmer prose | 5.80ms | 4.50ms | 10.40ms | 0.05ms | 11,109→11,109 | 591 |
| Hindi prose | 4.30ms | 6.60ms | 11.10ms | 0.04ms | 9,958→9,958 | 653 |
| Arabic prose | 29.10ms | 34.80ms | 63.50ms | 0.17ms | 37,603→37,603 | 2,643 |
The segs column shows segment count going from the analysis phase into the prepared handle. Scripts like CJK expand their segment count because words are split into individual graphemes for per-character line breaking. Scripts like Thai, Myanmar, Urdu, Khmer, Hindi, and Arabic do not expand because they do not require that splitting.
Notes
- Chrome is the main maintained performance baseline. Safari snapshots are included but are noisier and warm up less predictably.
- The checked-in JSON snapshots are cold checker runs. Ad hoc page-driven numbers, especially in Safari, can differ significantly after warmup.
- Refresh
benchmarks/chrome.json and benchmarks/safari.json when a change affects the text engine hot path (src/analysis.ts, src/measurement.ts, src/line-break.ts, src/layout.ts, src/bidi.ts, or pages/benchmark.ts).