Layout benchmark results
Comparable layout-performance numbers for Taffy, Blitz (Taffy + Stylo + Parley), Servo, and Chrome on identical pages.
Before interpreting the rankings, read the benchmark caveats: phase boundaries, equal-work validation, cache state, and sampling affect which numbers are comparable.
Servo's layout has no standalone entry point (it reflows a script-owned DOM), so the common unit is a page: the same HTML, the same viewport, timed from styled DOM to final boxes in each engine.
flex/
Flexbox trees from Taffy's benchmark suite (wide, deep, super-deep, absolutely positioned); no text.
grid/
CSS Grid trees from Taffy's benchmark suite (wide and deep grids); no text.
pangui/
The PanGUI flexbox templates from Taffy's benchmark crate; no text except where noted.
text/
Hand-built prose pages set in DejaVu Sans: shaping and line breaking as well as layout.
real/
Seven saved web pages frozen by real/freeze.py (scripts removed, fonts replaced, images as placeholders).
All suites
Every case in one table: pick a suite, a comparison table and the engines.
Reading the numbers
Rankings carry the equal-work and sampling caveats.
- Treat large differences on geometry-validated, text-free cases as useful leads for investigation, for the particular configurations measured.
- Do not rely on current whole-pipeline rankings or failed-verification rows.
- Prioritise matching timing boundaries, excluding unequal-work cases, and collecting a fresh, consistently warmed and interleaved dataset with dispersion shown before drawing headline browser-performance conclusions.
What each comparison measures
The span (and, in code, the metric key in results.json) each engine column is timed by; the results page labels columns by engine and variant only.
| engine | Layout only | Construction | Box construction + layout | Style | Whole pipeline (style to laid-out tree) | Relayout (root width 0.8x): layout | Relayout (root width 0.8x): whole pipeline | Relayout (1 leaf +1px): layout | Relayout (1 leaf +1px): whole pipeline | Relayout (10% of leaves +1px): layout | Relayout (10% of leaves +1px): whole pipeline | Relayout (all leaves +1px): layout | Relayout (all leaves +1px): whole pipeline | Relayout (append 10% more children): layout | Relayout (append 10% more children): whole pipeline |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Chrome | Layout (incl. shaping) layout | rebuildLayoutTree construct | rebuildLayoutTree+Layout construct+layout | recalcStyle style | UpdateLayoutTree+Layout style+layout | Layout relayout/root_width/layout | UpdateLayoutTree+Layout relayout/root_width/style+layout | Layout relayout/leaf_1/layout | UpdateLayoutTree+Layout relayout/leaf_1/style+layout | Layout relayout/leaf_10pct/layout | UpdateLayoutTree+Layout relayout/leaf_10pct/style+layout | Layout relayout/leaf_all/layout | UpdateLayoutTree+Layout relayout/leaf_all/style+layout | Layout relayout/append/layout | UpdateLayoutTree+Layout relayout/append/style+layout |
| WebKit | – | – | Layout (incl. shaping) layout | RecalculateStyles (incl. render-tree construction) style | RecalculateStyles+Layout style+layout | Layout relayout/root_width/layout | RecalculateStyles+Layout relayout/root_width/style+layout | Layout relayout/leaf_1/layout | RecalculateStyles+Layout relayout/leaf_1/style+layout | Layout relayout/leaf_10pct/layout | RecalculateStyles+Layout relayout/leaf_10pct/style+layout | Layout relayout/leaf_all/layout | RecalculateStyles+Layout relayout/leaf_all/style+layout | Layout relayout/append/layout | RecalculateStyles+Layout relayout/append/style+layout |
| Firefox | – | – | Reflow layout | Styles (incl. frame construction) style | Styles+Reflow style+layout | Reflow relayout/root_width/layout | Styles+Reflow relayout/root_width/style+layout | Reflow relayout/leaf_1/layout | Styles+Reflow relayout/leaf_1/style+layout | Reflow relayout/leaf_10pct/layout | Styles+Reflow relayout/leaf_10pct/style+layout | Reflow relayout/leaf_all/layout | Styles+Reflow relayout/leaf_all/style+layout | Reflow relayout/append/layout | Styles+Reflow relayout/append/style+layout |
| Ladybird | layout (incl. shaping) layout | tree build construct | tree build+layout construct+layout | update_style style | update_layout() total | layout relayout/root_width/layout | update_layout() relayout/root_width/total | layout relayout/leaf_1/layout | update_layout() relayout/leaf_1/total | layout relayout/leaf_10pct/layout | update_layout() relayout/leaf_10pct/total | layout relayout/leaf_all/layout | update_layout() relayout/leaf_all/total | layout relayout/append/layout | update_layout() relayout/append/total |
| Servo (1 layout thread) | Fragment Tree fragment | Box Tree box | Box+Fragment box+fragment | Styling style | restyle_and_build_trees restyle_and_build | – | restyle_and_build_trees relayout/root_width/restyle_and_build | – | restyle_and_build_trees relayout/leaf_1/restyle_and_build | – | restyle_and_build_trees relayout/leaf_10pct/restyle_and_build | – | restyle_and_build_trees relayout/leaf_all/restyle_and_build | – | restyle_and_build_trees relayout/append/restyle_and_build |
| Servo (3 layout threads) | Fragment Tree fragment | Box Tree box | Box+Fragment box+fragment | Styling style | restyle_and_build_trees restyle_and_build | – | restyle_and_build_trees relayout/root_width/restyle_and_build | – | restyle_and_build_trees relayout/leaf_1/restyle_and_build | – | restyle_and_build_trees relayout/leaf_10pct/restyle_and_build | – | restyle_and_build_trees relayout/leaf_all/restyle_and_build | – | restyle_and_build_trees relayout/append/restyle_and_build |
| Blitz | layout | construct (+pconstruct) construct | construct+layout construct+layout | style style | – | layout relayout/root_width/layout | resolve() relayout/root_width/resolve | layout relayout/leaf_1/layout | resolve() relayout/leaf_1/resolve | layout relayout/leaf_10pct/layout | resolve() relayout/leaf_10pct/resolve | layout relayout/leaf_all/layout | resolve() relayout/leaf_all/resolve | layout relayout/append/layout | resolve() relayout/append/resolve |
| Blitz (parallel style) | – | – | – | style style | resolve() resolve | – | – | – | – | – | – | – | – | – | – |
| Blitz (parallel construct) | – | construct construct | construct+layout construct+layout | – | – | – | – | – | – | – | – | – | – | – | – |
| Blitz (parallel style + construct) | layout | construct, parallel construct | construct+layout construct+layout | style style | resolve() resolve | layout relayout/root_width/layout | resolve() relayout/root_width/resolve | layout relayout/leaf_1/layout | resolve() relayout/leaf_1/resolve | layout relayout/leaf_10pct/layout | resolve() relayout/leaf_10pct/resolve | layout relayout/leaf_all/layout | resolve() relayout/leaf_all/resolve | layout relayout/append/layout | resolve() relayout/append/resolve |
| Blitz (#1034 build, 1 layout thread) | layout | – | – | – | – | – | – | – | – | – | – | – | – | – | – |
| Blitz (parallel style + construct + layout) | layout | – | construct+layout construct+layout | – | resolve() resolve | layout relayout/root_width/layout | resolve() relayout/root_width/resolve | layout relayout/leaf_1/layout | resolve() relayout/leaf_1/resolve | layout relayout/leaf_10pct/layout | resolve() relayout/leaf_10pct/resolve | layout relayout/leaf_all/layout | resolve() relayout/leaf_all/resolve | layout relayout/append/layout | resolve() relayout/append/resolve |
| Taffy | layout | – | – | – | – | compute_layout relayout/root_width/layout | – | compute_layout relayout/leaf_1/layout | – | compute_layout relayout/leaf_10pct/layout | – | compute_layout relayout/leaf_all/layout | – | compute_layout relayout/append/layout | – |
Versions and methodology
Measured on 2026-10-25 on an 8-vCPU Intel Xeon AWS VM (31 GB, Ubuntu 22.04.5, kernel 6.8.0-1060-aws), rustc 1.99.1, Python Playwright 1.64.0, in two windows: all engines interleaved, 15:33–16:58 UTC (bench --rounds 3 --iters 3, raw run out/runs/2026-10-25-full.json; its Servo rounds failed, the 3-iteration trace being over the Python protobuf parser's 2 GB message limit), then Servo(1), Servo(3) and Chrome interleaved, 17:44–18:22 UTC (out/runs/2026-10-25-servo-chrome.json). Chrome's per-case minima in the second window are those of the first within noise (new/old median 0.998, p10 0.97, p90 1.03 over the initial layout and the five relayout steps of all 43 cases), so the Servo columns are merged in as measured, unscaled. Every sample is the relayout protocol's (one load, the initial layout, then the five mutation steps), so the initial-layout numbers come from the same documents and processes as the relayout ones. No runner was pinned in this run (--cpus not given): servo1 differs from servo only by layout_threads=1, which sizes the style pool (styling, full box-tree build and layout) and, at 1, disables Servo's block-level layout cache; the relayout steps' incremental try_layout runs on rayon's global pool (8 threads) in servo and sequentially in servo1. Every number in out/ comes from this machine; children sessions that add engines or cases only verify equal work and run a one-round smoke test, the measurement is redone here with bench --merge.
Earlier epochs: the 2026-10-23 dataset (out/runs/2026-10-23-full.json, one 27-minute window, single-threaded runners pinned with --cpus 2, Ladybird with the previous one-process-per-iteration runner) with WebKit measured on 2026-10-24 (out/runs/2026-10-24-webkit-chrome.json) and folded in via run.py normalize; and before it the 2026-10-20 run (out/runs/2026-10-23-blitz-servo-chrome.json). Against the 2026-10-23 epoch the 2026-10-25 windows are a uniform 9–17 % faster for every engine (initial-layout minima of the same cases, new/old: Chrome 0.89, WebKit 0.88, Firefox 0.83, Blitz 0.89, Taffy 0.92, Ladybird 0.86, p10–p90 within ±5 % of each median), so the absolute times fell but the cross-engine ratios did not.
| engine | version | how it is driven and timed |
|---|---|---|
| Chrome | Chromium 156.0.8078.4 (Playwright chromium_headless_shell-1248, headless shell, --no-sandbox) | New page per iteration, 1280×800, file:// reveal page, load + wait for the reveal; CDP Tracing with categories devtools.timeline, disabled-by-default-devtools.timeline, blink, blink_style; longest X event of each name: Document::recalcStyle (style), Document::rebuildLayoutTree (LayoutObject construction), UpdateLayoutTree (both), Layout (fragment-tree layout). totalObjects from the Layout event is the equal-work count. Not pinned (multi-process). |
| WebKit | WebKit 27.2 (Playwright webkit-2370, the WPE MiniBrowser bundle Playwright 1.64.0 installs; browser.version = 27.2, user agent AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.2 Safari/605.1.15; the bundle names no upstream WebKit revision) | New page per iteration at the case's viewport, corpus served over localhost HTTP with Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp (the reveal page is otherwise unchanged; WebKit swaps the page into a fresh web process on the navigation, so every sample is a cold process), load + wait for the reveal. Driven directly over the binary's --inspector-pipe protocol (engines_webkit.py). Phase times come from a Page.setBootstrapScript that wraps HTMLElement.prototype.offsetHeight: on the reveal script's cb.offsetHeight it takes performance.now(), forces style with getComputedStyle(cb).display (style: style resolution and render-tree construction of the revealed tree), then the original getter (layout: the box layout, including line layout and shaping). Cross-origin isolation turns performance.now() from 1 ms to 20 µs resolution (Performance::allowHighPrecisionTime). The Web Inspector Timeline domain would give the same intervals as RecalculateStyles/Layout records (agreement within 0.3 ms when both are on) but recording emits an InvalidateLayout record with a captured JS stack for every renderer that gets dirtied (one per box, 101k on pangui/fit_nesting), which makes the style phase 2–2.5× slower, so it is only turned on by verify to count layouts (n_layouts; one Layout of the revealed tree per sample, preceded only by the ≤1 ms layout of the hidden page on cases whose reveal awaits document.fonts). Not pinned (multi-process). |
| Firefox | Firefox 157.0 (Playwright firefox-1555) | One browser launch per round for all of the round's cases; per case a discarded warm-up tab (?warmup) then a fresh tab per iteration (?i=N, the profile is filtered by page URL), all tabs of the round in one content process (a blank file:// page is kept open: each Playwright new_page() is a new context and the file content process exits with its last tab, so otherwise every sample would run in a fresh cold process, 10–20% slower on text-heavy pages): a Firefox process's first page load does a second full reflow ~200 ms after the first with nothing restyled (105 ms on Wikipedia, 64 ms on rustdoc; absent on a second load of the same page) that Chrome, Servo and Ladybird have no counterpart to, and its first reflow runs 5–15 % slower on cold font/text caches; in a warm process a case's own first load is still 10–100% slower on text/real pages (rustdoc, paragraphs: cold shaped-word/font caches), so the warm-up is per case. Gecko profiler on from startup (MOZ_PROFILER_STARTUP=1, features nostacksampling,nomarkerstacks, filter GeckoMain), profile written by MOZ_PROFILER_SHUTDOWN; the content process whose page is the case is read for the Styles interval marker (style, includes frame construction), Reflow start/end pairs (layout; the longest one is reported, the sum is within 1% of it) and DisplayList. Default layout.css.stylo-threads (5 style workers here), unpinned. |
| Ladybird | 07fac502 (2026-10-20) + patches/ladybird-phase-timing.patch, Release build, Qt 6.10.3 | One WebDriver --headless browser per round for all of the round's cases; for the real pages a discarded warm-up window (?warmup; the first load of a page in a fresh browser is 14–18 % slower in layout on real/wikipedia/real/rustdoc, within 2 % on the synthetic pages), then a fresh window per iteration (?i=N) at the case's viewport, file:// reveal page, navigation + wait for the reveal. Every window is a new WebContent process (default site isolation), so each sample is a cold content process's first layout; the UI process's font service is warm. Phase times from the patch's LADYBIRD_PHASE stderr lines, attributed to the window by pid + URL, the round with reason HTMLElementOffsetHeight (the reveal script's forced layout): style (update_style()), construct (layout tree build), layout (layout passes incl. shaping), total. Protocol change from the 2026-10-23 run (one --headless=layout-tree process per iteration, cold font service): layout of real/wikipedia/real/rustdoc is 13–16 % lower (min), other stages and the synthetic cases within 3 %. Not pinned (multi-process). |
| Servo | servoshell 0.7.0-1403b364d (built 2026-10-25 from servo/servo 1403b364d, ./mach build --release --features tracing-perfetto; Stylo 633f43ad) | One headless process per round for all of the round's cases (-z --webdriver, started on about:blank, which stays open so the process never has zero windows), driven over WebDriver by engines_servo.py (stdlib urllib): per case a discarded warm-up window (?warmup), then per iteration New Window → Set Window Rect to the case's viewport (really resizes the headless webview) → navigate to ?i=N → Execute Script polled until #cb is shown → Close Window. Page served over localhost HTTP (its fetch rejects file:// fonts); SERVO_TRACING=layout::layout_impl=trace,layout::flow::root=trace (phase spans only; per-item spans would add >10×), the round's servo.pftrace decoded by servo-trace (Rust, prost with a hand-written schema of the fields read; 0.6 s for a 370 MB trace against 8 s for the Python perfetto classes it falls back to when not built) packet by packet from the protobuf with the perfetto package's generated classes (its TraceProcessor costs ~1 s per trace; a round's trace runs to GBs, as every layout query the relayout script makes — getClientRects/getComputedStyle on each element, getBoundingClientRect per leaf — is a handle_reflow span of its own, so only the phase spans are kept). A window's reflows are the handle_reflow spans whose webview_id field (servo#49259) is the window's WebDriver handle, and its sample is the restyle_and_build_trees among them before the observed reveal that built the tree (the earlier, display:none reflows construct nothing): Styling, Box Tree Construction, Fragment Tree Construction, Stacking Context Tree Construction, Display List Construction, handle_reflow, summed by time containment in the reflow (in a warm process with layout_threads > 1 the box and fragment passes run on a StyleThread#N rayon worker, nested under nothing). Protocol change from the stored run (one -z -x process per iteration): a warm process. servo1 (--pref layout_threads=1, pinned to two CPUs) is 5–35 % faster per stage than a cold process (style 15–47 %, box 7–37 %, fragment 0–35 %, restyle_and_build 6–34 % on the seven validation cases) with the same spread; within the warm process a case's own first load is slower still (style ≤ 1.17×, box ≤ 1.40× on flex/wide/10000, ≤ 1.08× on the real pages), hence the per-case warm-up. servo (default 3 layout threads, Stylo pool shared by style and box construction, unpinned) does not gain: its mins on the text/real pages came out 7–26 % higher than cold processes with a wider spread (bimodal samples, all phases slow together, the layout threads themselves consuming the extra CPU time; not pinned down, see the PR write-up). |
| Blitz | DioxusLabs/blitz main 1c5265a8 (2026-10-23; blitz-dom with Taffy main 802e497b, Stylo 0.22 at DioxusLabs/stylo d6502e00, Parley 0.12.0, harfrust 0.12.0, skrifa 0.44.0); the *-pl engines: blitz#1034 rebased on that main (fac9b358, with taffy#1230 rebased on Taffy main, a2b0f009) | blitz-runner (this repo): fresh HtmlDocument per iteration from the plain corpus/*.html, incremental=false, one resolve(); phase times from Blitz's own log-phase-times timer via a vendored debug_timer printing ns: style, construct (+ pconstruct, the deferred Parley layout builds = shaping), layout (Taffy + Parley line breaking), paint_tree, resolve. Built with blitz-dom's opt-in floats and writing-mode features (as apps/browser does for floats; neither is in blitz-dom's defaults). Global allocator mimalloc (blitz-runner/src/main.rs): with glibc malloc the layout phase of pangui/wide_no_wrap_simple_many is 233 ms instead of 147 ms (per-node LayoutData boxes and Taffy's item vectors, re-faulted every iteration because the heap top is trimmed when the previous document is dropped). blitz = sequential style, pinned; blitz-par = StyleThreading::Parallel; blitz-pcon / blitz-par-pcon = second build with --features parallel-construct (deferred construction on the rayon pool). Equal work: hash of every element's final box, --boxes dump. |
| Taffy | DioxusLabs/taffy 4e59968 (taffy_benchmarks for the tree shapes and PanGUI templates) | gen bench: the same tree built directly in a TaffyTree, all nodes dirtied, compute_layout timed with Instant; pinned. --wrapped lays it out as the page does (abspos child of a viewport-sized box, ~10% more work). Blitz's Taffy is main 802e497b (this one plus everything merged since, taffy#1343 included). |
Common protocol: bench --rounds 3 --iters 3, engines interleaved within a round (order reversed every other round), min over all 9 samples per engine and case (medians via --agg median); the first layout of a freshly loaded page is measured, never a relayout. Only single-threaded runners are pinned (--cpus); parallel ones use all 8 vCPUs. Caveats: VM wall-clock noise is 1–3% between rounds when the machine is idle and much more when it is not (check uptime); Servo's remaining tracing overhead is ~1.5 ms per page; Chrome and Firefox numbers include no process start or parse time, only the named events; Taffy direct is not wrapped in the page's containing block unless --wrapped is used.
Engines and phases
How each engine is run and which spans are compared (from the README)
| engine | how it is run | phases reported |
|---|---|---|
taffy | gen bench: the Taffy tree itself, all nodes marked dirty, compute_layout | layout |
gen bench --wrapped: same tree as an absolutely positioned child of a viewport-sized box, mirroring the page's #cb (one layout pass from a definite containing block, as in the browsers; ~10% more work than the bare root) | layout | |
blitz | blitz-runner: fresh HtmlDocument, incremental=false, one resolve() per iteration, phase times from Blitz's log-phase-times timer (vendored to print ns); construct includes Blitz's pconstruct phase (deferred tasks, where the Parley text layouts are built, i.e. text is shaped) | style, construct, layout, paint_tree, resolve |
blitz-par | same with StyleThreading::Parallel | same |
blitz-pcon / blitz-par-pcon | blitz-runner built with --features parallel-construct (see Running for the build commands): the deferred construction tasks (pconstruct, Parley layout builds) run on the rayon pool; with sequential / parallel style | same |
blitz-pcon-pl1 / blitz-par-pcon-pl | third build of the same src/main.rs against the parallel-layout prototype blitz#1034 (blitz-runner-pl/Cargo.toml: #1034 merged onto the Blitz revision of the other builds, with taffy#1230 merged onto their Taffy; features parallel-layout + parallel-construct): Taffy hands Blitz batches of independent sibling layouts, which run on the rayon pool when their estimated weight is above a threshold. pl1 = BLITZ_PARALLEL_LAYOUT_THREADS=1 (a dedicated one-thread pool, i.e. the feature's batching/bookkeeping without parallelism) and pinned like blitz, the baseline for the feature's cost; par-pcon-pl = everything parallel | same |
servo1 / servo | servoshell built with --features tracing-perfetto, --pref layout_threads=1 / 3 (default), one --webdriver process per round for all of the round's cases (a discarded warm-up window per case, then a fresh window per iteration, see Versions and methodology), spans read from the round's servo.pftrace | Styling, Box Tree Construction, Fragment Tree Construction, restyle_and_build_trees, Stacking Context Tree Construction, Display List Construction, handle_reflow |
chrome | Playwright chromium-headless-shell, CDP Tracing with devtools.timeline + blink_style | Document::recalcStyle (style), Document::rebuildLayoutTree (LayoutObject construction), UpdateLayoutTree (= both), Layout |
webkit | Playwright WebKit (WPE MiniBrowser), driven directly over its --inspector-pipe protocol by engines_webkit.py (Playwright exposes no raw session for WebKit); new page per iteration, corpus served over localhost HTTP with COOP/COEP headers (a cross-origin-isolated page gets a 20 µs performance.now() instead of 1 ms); phases timed by a bootstrap script's performance.now() around the reveal's forced style update (getComputedStyle) and forced layout (offsetHeight); the Inspector Timeline domain is used by verify only, to count layouts (recording it doubles the style time, see Versions and methodology) | style (style resolution, includes render-tree construction), layout (includes text shaping / line layout), style+layout |
firefox | Playwright Firefox, one process per round for all of the round's cases (launch retried on the occasional startup segfault), per case a discarded warm-up tab then a fresh tab per iteration, all in one content process kept alive by a blank file:// page (a process's first load gets an extra full reflow and cold font caches; a cold process is 10–20% slower on text-heavy pages, and a case's first load in a warm process is still 10–100% slower on text/real pages, hence the warm-up per case), Gecko profiler started at launch (MOZ_PROFILER_STARTUP, markers only) and written at shutdown (MOZ_PROFILER_SHUTDOWN); default layout.css.stylo-threads (6 on this VM) | Styles (style traversal + frame construction), Reflow, DoFlushPendingNotifications (the flush containing both), DisplayList |
ladybird | Ladybird driven over its WebDriver server (headless), one browser per round and a fresh window (= WebContent process) per iteration, built with patches/ladybird-phase-timing.patch which prints LADYBIRD_UPDATE pid=<pid> url=<url> reason=<reason> and LADYBIRD_PHASE pid=<pid> style=<ns> construct=<ns> layout=<ns> total=<ns> full_layouts=<n> to stderr for every Document::update_layout() round (see Ladybird) | style (Document::update_style()), construct (layout tree build, LayoutRoundJob::build), layout (lay_out_root / lay_out_boundary, the layout pass including the LayoutState commit), total (the whole round) |
Phase mapping used in the report:
- Layout only: Taffy
compute_layout≈ Blitzlayout≈ Servo Fragment Tree Construction; ChromeLayoutand Ladybirdlayoutare listed too but include text shaping (see below), so on text-bearing cases they are only comparable with each other. - Box construction + layout: Blitz
construct + layout≈ Servo Box Tree + Fragment Tree ≈ ChromerebuildLayoutTree + Layout(Chrome buildsLayoutObjects insideUpdateLayoutTree, notLayout) ≈ Ladybirdconstruct + layout≈ FirefoxReflow≈ WebKitlayout(WebKit, like Firefox, builds its render tree while resolving style, solayoutis the whole box pass). Firefox's frame construction happens inside theStylesflush with no marker of its own, soReflowunder-counts this table by the frame-construction time (small: it is a tree walk with no shaping) andStylesover-counts the Style table by the same amount. - Text shaping sits in different phases: Blitz shapes when it builds the Parley inline layout in
construct(build_inline_layout_into, run as the deferredpconstructtasks), Servo in Box Tree Construction (inline/shaping_queue.rs::shape_batch→font.shape_textwhile building each inline formatting context), whereas Chrome (NGInlineNode::PrepareLayout→ HarfBuzz insideLayout), WebKit (inline layout measures runs throughFontCascade::width, which shapes), Firefox (nsTextFrame::Reflow→EnsureTextRun→gfxFont::ShapeText) and Ladybird (inline_level_iterator.rs::shape_glyph_data→shape_textduring inline layout) shape lazily while laying out. Shaping dominates construction on text-heavy pages (Blitz Wikipedia: construct 60 ms vs layout 20 ms), so "Box construction + layout" is the like-for-like table for any case with text; "Layout only" is like-for-like only among Taffy, Blitz and Servo (thetext/cases have no Taffy tree). - Style: Blitz
style≈ ServoStyling≈ ChromerecalcStyle; FirefoxStylesand WebKitstyleare listed here too but, like Chrome'sUpdateLayoutTree, they include frame / render-tree construction; Ladybirdstyleisupdate_style(). - Whole pipeline (style to laid-out tree): Blitz
resolve()≈ Servorestyle_and_build_trees(Styling + Box Tree + Fragment Tree, plus stylist preparation and damage computation) ≈ ChromeUpdateLayoutTree + Layout≈ FirefoxStyles + Reflow≈ WebKitstyle + layout≈ Ladybirdupdate_layout(). None of these includes display-list or paint work: Blitz'sresolve()ends withbuild_paint_tree(the stacking-context / paint-order lists, ~0.3 ms on Wikipedia; scene generation inblitz_paint::paint_sceneis outside the timed interval), Servo's stacking-context tree is built by the enclosinghandle_reflow(recorded asreflow, not shown in the table; ~9 ms on Wikipedia), and the measured reflows build no display list.
out/servo-threads.md is a layout_threads = 1/3/4/6/8 sweep of Servo on seven cases (servoN is accepted as an engine id by bench for any N; only servo1/servo are in the main run).
Servo's restyle_and_build_trees and handle_reflow include work the children spans do not (stylist preparation, damage computation); the report shows them separately.
Blitz feature cost
blitz-dom's floats and writing-mode are opt-in and the runner enables both (apps/browser ships with floats). What they cost, as callgrind instructions in resolve_layout for one layout of the page (deterministic; the layout hashes are identical across the four builds on all 43 pages):
| page | neither | floats | writing-mode | both |
|---|---|---|---|---|
| flex/wide/10000 | 134.6M | 134.4M (0%) | 149.0M (+10.7%) | 149.6M (+11.2%) |
| grid/wide/100x100 | 231.9M | 230.4M (−0.6%) | 264.1M (+13.9%) | 251.8M (+8.6%) |
| text/paragraphs/1000 | 188.4M | 211.7M (+12.4%) | 189.0M (+0.3%) | 212.2M (+12.7%) |
writing-mode is paid on every style read (the frame-transposing getters in stylo_taffy), floats in block layout with inline content; so the Blitz columns measured with both are ~10% above what a build without them would show on these pages.
Firefox reports the longest Reflow and Styles marker of the page's content process. The reveal flush is one Styles + one Reflow; the page also records a few sub-0.1 ms reflows (the initial display:none document, a nested flush after the reveal), so verify prints the reflow count and the sum of all reflows relative to the longest. The Styles marker's elementsTraversed is the styled-element count (styled= in verify, nodes + html/body/#cb).
Caveats
These benchmarks are useful for diagnosing engine costs, but are not yet a clean cross-browser ranking. The strongest comparisons are the text-free cases with matching geometry. Several other tables compare different work, and the saved samples show substantial variability.
This review was made on 2026-10-20 against [f6d5695](https://github.com/nicoburns/browser-layout-bench/commit/f6d56957334a2aa95e169ab4f41b8b6ec4051d35), with the Ladybird and verification updates through [dbbdbce](https://github.com/nicoburns/browser-layout-bench/commit/dbbdbce) accounted for below. Numerical examples come from the saved samples, not new measurements. Revisit these caveats when the harness, engines, or results change.
The harness does several things well: deterministic inputs, explicit font loading, phase timing rather than process startup, raw samples, geometry checks, and the correction of Servo's excessive tracing overhead.
The six caveats in full (CAVEATS.md)
1. "Whole pipeline" endpoints are close but not identical
Every whole-pipeline column ends at the laid-out tree and excludes display-list and paint work: Chrome UpdateLayoutTree + Layout (before PrePaint/Paint), Firefox Styles + Reflow (before its display list), Ladybird update_layout() (committed layout tree), Servo restyle_and_build_trees (stylist flush, damage walk, Styling + Box Tree + Fragment Tree; the enclosing handle_reflow, recorded as reflow and not tabulated, adds the stacking-context tree, ~9 ms on Wikipedia — the measured layout-query reflows never build a display list), Blitz resolve(). What each still carries beyond style + construction + layout is the grey "rest" segment of the stacked bars: Blitz's build_paint_tree (stacking-context / paint-order lists, ~0.3 ms on Wikipedia) plus transforms and damage cleanup; Servo's stylist preparation and damage computation; Chrome's non-style, non-construction part of UpdateLayoutTree. Blitz's scene generation (paint_scene) is not inside the timed interval and Servo's display list is not built by the measured reflows, so no engine's figure includes drawing-command generation; the one asymmetry left is that Blitz's paint-order tree is inside its figure and Servo's stacking-context tree is outside its.
Sources: reported intervals, Blitz's resolve implementation, and Ladybird instrumentation.
2. Equal-work validation is incomplete
Known failures still receive timings and can win rows. The clearest exclusion is Servo's pangui/percentage_and_ratio: it renders empty. Its timing should be N/A, not eligible to win a row.
Other cases need qualification:
flex/absolute/10000has substantially different geometry.flex/wide/10000reports 3,505 box-size mismatches, a maximum difference of 735,072 px, yet 0% viewport pixel difference for Blitz versus Chrome. Whether this is a dump/correspondence bug or a layout difference, it is unresolved verification evidence, not proof of equal work.- Servo's text check sees only the first 800 pixels: "7/7 background bands equal" does not establish equivalence across 1,000 paragraphs.
- Ladybird's
flex/huge_nested/*root sizes differ substantially from Chrome's, and some text paragraphs wrap differently. Matching node counts alone does not establish matching layout work.
The original verification snapshot lacked Firefox even though it appeared in the timing tables. **That coverage gap was resolved by dbbdbce**, which adds Firefox and Ladybird to the committed verification table. This does not resolve the geometry and viewport-coverage limitations above.
Small rasterisation differences are not themselves disqualifying. Missing layout features, different wrapping, or skipped content are. Validation should cover offscreen geometry and text line counts, not just viewport pixels or root sizes. Exclude known unequal-work engine/case pairs from rankings, and investigate unexplained mismatches before treating them as equivalent.
Frozen real pages
The [real-pages work reviewed at 0f0d306](https://github.com/nicoburns/browser-layout-bench/blob/0f0d306/README.md) adds useful coverage, but is not equal-work evidence: it documents unsupported floats and other layout differences. For example, on the frozen Wikipedia page 460 of 866 sampled Blitz boxes differ in size from Chrome's by more than 1px. Knowing the cause explains the mismatch; it does not make the workloads equivalent.
These pages are simplified snapshots: scripts are removed, fonts are replaced, images become placeholders, and some CSS and controls are rewritten. They test the resulting frozen documents, not the original live sites.
3. Minimum-of-nine hides significant instability
For Blitz's text/paragraphs/1000 resolve time, the saved samples have:
| Statistic | Time |
|---|---|
| Minimum | 143.4 ms |
| Median | 265.8 ms |
| Maximum | 288.7 ms |
That is far beyond a 1–3% variation. A minimum can legitimately estimate best-case latency, but it is not typical performance, and ratios of independently selected minima can be unstable. The spread alone does not identify its cause.
The benchmark-development session assembled the dataset through several --merge runs: Chrome was rerun separately, and Firefox/parallel-construction variants were added later. Consequently, the final cross-engine comparisons do not all benefit from contemporaneous interleaving, even though each individual invocation supports it.
Report median plus spread, retain minimum as a secondary statistic, and rerun the final engine set together on an idle machine. Record the run configuration and provenance so later subset updates remain identifiable.
Sources: raw samples, aggregation and merge logic, and documented protocol.
4. Cache state and CPU allocation are not matched
- Taffy: reuses its tree, explicitly warms up twice, then dirties nodes.
- Blitz: fresh document, but several iterations within one process; mimalloc as the global allocator (with glibc, allocator work and page faults were ~37% of the
layoutphase on the 100k-node cases). - Chrome: fresh pages within one browser launch.
- Servo/Firefox: one process per round, a fresh window/tab per iteration after a discarded warm-up load of the case (Servo over WebDriver, Firefox via Playwright; all Firefox tabs in one content process).
- Ladybird: one
WebDriver --headlessbrowser per round, a fresh window (and WebContent process) per iteration, the UI process's font service warm, a warm-up load only for the real pages.
Excluding startup time does not eliminate differences in allocator, font, thread-pool, and other process-local caches. This is neither a consistently cold nor consistently warmed protocol. Choose and document a common lifecycle, or report cold and warmed cases separately.
CPU placement is a configuration choice: the 2026-10-23 dataset pinned the single-threaded runners (--cpus) while the current one runs everything unpinned on 8 vCPUs, so Servo(1) vs Servo(3) is a thread-count comparison and Blitz vs blitz-par a 1-thread vs pool comparison on the same cores. Servo's layout_threads sizes only the style pool used for styling and the full box-tree build and layout; the incremental try_layout pass of the relayout steps runs its par_iters on rayon's global pool (all 8 vCPUs here) in Servo(3), and layout_threads=1 also disables Servo's block-level layout cache (SequentialLayoutState), so Servo(1) relayout is a different code path, not just fewer threads. Those are valid configuration comparisons, but not controlled equal-resource comparisons. Separate single-threaded layout/style and default-parallelism results, with affinity and worker counts stated explicitly.
Sources: Taffy runner, Blitz runner, browser runners, and Ladybird runner.
5. Instrumentation and corpus scope limit the conclusions
The Servo tracing fix was important: the documented example fell from 31 ms to 2.3 ms after removing per-item spans. But tracing overhead has not been equivalently calibrated across all engines. Chrome still enables broad blink/timeline categories; Blitz's outer resolve() timer even includes printing its phase log. Small differences deserve particular caution. Calibrate instrumentation overhead rather than assuming it is equal or negligible.
Direct Taffy also uses a different revision and, by default, omits the browser's containing-block wrapper. It is an algorithm baseline, not a browser score or a clean measure of Blitz's integration overhead.
Finally, the synthetic corpus and frozen, simplified real pages measure specific initial-layout workloads, not general browsing, incremental layout, JavaScript, or time-to-paint.
Sources: Servo tracing filter, versions and methodology, Taffy's optional wrapper, and Blitz's outer timer.
6. Text shaping is in construction for Blitz/Servo but in layout for Chrome/WebKit/Firefox/Ladybird
Blitz shapes when it builds the Parley inline layout (construct, including the deferred pconstruct tasks) and Servo during Box Tree Construction; Chrome, WebKit, Firefox and Ladybird shape text lazily while laying it out, so their layout figures include shaping, line breaking and font fallback and their construction figures (where they exist) are a tree walk. The "Layout only" table therefore compares different work on any case with text: Blitz layout and Servo Fragment Tree are post-shaping line breaking and box placement, Chrome Layout and Ladybird layout are shaping plus that. The "Box construction + layout" table contains the same work for every engine and is where Firefox's Reflow and WebKit's layout are listed (their frame / render-tree construction happens inside the style flush, with no marker of its own, so that table under-counts them by the construction time and the Style table over-counts them by the same amount; it is a tree walk with no shaping, so the amount is small relative to layout on text-heavy pages). The flex/, grid/ and most pangui/ cases have no text, so there the two tables differ only by genuine construction cost and "Layout only" is comparable across all engines.
7. Relayout scenarios measure invalidation strategy, with warm caches
The relayout steps run after the first layout in the same document, in a fixed order, each on the reverted tree, so every step sees the allocator, font and layout caches left by the previous ones (identical across engines, but not a cold relayout, and the revert's layout is not measured). The numbers compare how much work each engine's invalidation lets through as much as how fast it lays out: Blitz currently restyles the whole subtree on any inline-style change (set_attribute → RestyleHint::restyle_subtree() + full damage), so its whole-pipeline relayout figures are dominated by style even when layout is small; Servo's repair path reports no box/fragment spans, so only restyle_and_build_trees is available. Small incremental relayouts (tens of µs in Chrome) sit at the trace clocks' resolution — prefer the median there. Only elements with a box take part (getClientRects() non-empty; Blitz: not display: none/contents, nor inside display: none), and leaves are the innermost block-level elements (display: inline is excluded, replaced or not, since width on a non-replaced inline changes no geometry and Blitz gives inline elements no node layout to mutate; so on the text cases the leaves are the paragraphs, not the spans in them); the root children cloned by append are the boxed ones, so on the real pages it clones top-level sections rather than list items and can cost as much as a full layout (rustdoc). The browsers' and Blitz's selections were compared on every text and real case and the generated smoke cases: same leaf/root-child counts and deepest leaves everywhere, except where Blitz styles the page differently — real/mdn, 566 vs 558 leaves (Blitz does not apply the page's .code-example:has(…) {display: none} and lays those divs out) and real/wikipedia, 2109 vs 2107 (Blitz gives <picture> no box, so the <a> and <img> around two of them become leaves instead). Taffy's direct trees have no inline or hidden nodes. Post-mutation geometry is not yet part of verify.
Corpus
How the pages are built (from the README)
gen builds the trees from Taffy's benchmark suite (wide / deep / super-deep flexbox, absolute positioning, wide / deep grids) and serialises them to HTML with inline styles, one <div> per Taffy node. Every leaf gets a distinct background colour so renders can be pixel-compared. There is no text, so text shaping and line breaking are excluded; these pages measure the layout algorithms and the surrounding style/box machinery only. The pangui/<template> cases are the PanGUI flexbox templates from Taffy's benchmark crate (gen/pangui/*.txt, taffy_benchmarks::pangui_template), translated to Taffy styles the same way as Taffy's pangui bench and serialised like the rest.
The text/ cases (paragraphs, flex_cards, inline_mixed, each at 100 and 1000 items) are hand-built HTML with deterministic prose (a fixed word list sampled with the seed), set in DejaVu Sans shipped in fonts/ (@font-face, four faces, loaded via a relative url(); licence in fonts/LICENSE-DejaVu.txt), with line-height: 20px and text-rendering: geometricPrecision (without it Chrome rounds each glyph advance to a whole pixel and breaks ~5% of lines differently). They have no Taffy tree, so Taffy direct is not measured for them; they compare Blitz (Parley) construct+layout against Servo Box+Fragment Tree and Chrome rebuildLayoutTree+Layout, i.e. shaping plus line breaking (see the phase table below for why shaping lands in the construction phases).
The root is absolutely positioned inside a #cb containing block sized to the Taffy available space (max-content becomes a 1,000,000px box). run.py gen also writes corpus/reveal/*.html for Servo and Chrome: the tree starts display:none and a trailing script reveals it and forces layout, so the whole tree is styled and laid out in a single reflow after parsing has finished. Pages with @font-face reveal the tree only after document.fonts has loaded every face, so that reflow shapes with the web font rather than a fallback. Servo loads the reveal pages over a localhost HTTP server started by run.py because its fetch rejects file:// fonts (CORS mode from an opaque origin); blitz-runner serves file:// URLs itself, synchronously, before the timed resolve().
real/: saved web pages
The real/<page> cases are seven real pages saved on 2026-10-20 and frozen by real/freeze.py (sources and licences in real/LICENSES.md; the saved originals live in real/raw/, ignored):
| case | page | elements under <body> |
|---|---|---|
real/wikipedia | en.wikipedia.org/wiki/Barack_Obama (Vector 2022 skin) | 21,889 |
real/mdn | developer.mozilla.org/en-US/docs/Web/CSS/display | 2,212 |
real/rustdoc | doc.rust-lang.org/std/vec/struct.Vec.html | 15,947 |
real/css-flexbox-1 | www.w3.org/TR/css-flexbox-1/ | 15,890 |
real/servo-org | servo.org | 226 |
real/dioxuslabs-com | dioxuslabs.com | 158 |
real/bootstrap-dashboard | getbootstrap.com/docs/5.3/examples/dashboard/ | 199 |
real/freeze.py fetch <page> saves a page with wget -p -k -E -H into real/raw/<page>/; real/freeze.py freeze <page> rewrites it into real/<page>/index.html plus the placeholder images, and real/manifest.tsv (name, directory, element count, viewports). run.py gen copies real/<page>/ into corpus/real/<page>/ and corpus/reveal/real/<page>/ and appends one manifest line per viewport (every page is currently 1280×800, as real/<page>; another width would be real/<page>@<w> via the optional 7th manifest column, which the other pages leave at the global 1280×800). Node count is the number of elements under <body>; there is no Taffy tree. The <body> itself is the #cb element (a wrapper <div> would change the containing block of fixed/absolute children and body's own margin and flex behaviour), so the reveal script restores its stylesheet display instead of forcing block, and the runners wait for display != none.
Freezing rules (all in real/freeze.py, never by hand-editing the saved HTML): scripts, inline handlers, <noscript>, <template>, <link>s (screen stylesheets are inlined first, print and dark-scheme sheets dropped), <meta http-equiv> and comments go; <img>, <picture>, <video>, <iframe>, <canvas>, <object>, <embed> and inline <svg> become <img> placeholders of the same natural size (flat grey PNGs under real/<page>/ph/, width/height attributes kept or taken from the saved file, id/class/style kept so CSS sizing resolves the same way); form controls become span.lb-input (UA control sizes differ); closed <details> keep only their <summary>. In CSS, @import, @font-face and every url() go (no background images), font-family/font are rewritten to "LB Sans" or "LB Mono" (the DejaVu faces in fonts/, via ../../fonts/), and text-rendering, scrollbar-gutter (Chrome reserved a 15px gutter on MDN, Firefox and Blitz none), transform/translate/rotate/scale (dioxuslabs.com's hidden search modal is scale: 1.1; it does not affect layout but skews getBoundingClientRect() and the screenshot) and animation declarations are stripped. The page gets html{text-rendering:geometricPrecision;font-family:"LB Sans"} and pre,code,kbd,samp,tt{font-family:"LB Mono"} at the lowest specificity so that elements left in the UA default font do not fall back to system fonts (Blitz is built without any and rendered the flexbox spec's unstyled paragraphs 0px tall). No page issues a non-file:// request (checked with Playwright request logging); file names with ? are renamed by wget's -E/-k handling and nothing is referenced with %3F.
Equal-work check (./run.py verify --engines chrome,blitz,firefox1,servo1,ladybird --filter real/, 2026-10-20; page height = document.documentElement.scrollHeight, Blitz: the <html> box's scrollable overflow, Ladybird: getBoundingClientRect() like Chrome; Servo reports neither boxes nor a root):
| case | nodes | px differing: Blitz / Firefox / Servo / Ladybird vs Chrome | Blitz vs Chrome boxes (div/p/h3) | page height Chrome / Firefox / Blitz / Ladybird |
|---|---|---|---|---|
real/wikipedia | 21,889 | 9.0% / 7.1% / 10.5% / 13.1% | 460/866 sizes, 753 positions | 85,068 / 85,261 / 83,201 / 800 (body) |
real/mdn | 2,212 | 4.6% / 3.7% / 5.6% / 4.2% | 18/176, 163 | 19,760 / 19,786 / 19,976 / 19,760 |
real/rustdoc | 15,947 | 2.3% / 2.2% / 4.4% / 2.3% | 56/1792, 1770 | 155,326 / 155,359 / 155,421 / 155,398 |
real/css-flexbox-1 | 15,890 | 2.8% / 2.0% / 7.5% / 6.7% | 28/471, 438 | 90,168 / 90,277 / 90,308 / 90,120 |
real/servo-org | 226 | 22.4% / 1.4% / 2.1% / 1.6% | 31/84, 79 | 5,445 / 5,449 / 5,967 / 5,450 |
real/dioxuslabs-com | 158 | 7.8% / 1.2% / 3.3% / 2.7% | 2/48, 6 | 1,232 / 1,232 / 1,232 / 1,232 |
real/bootstrap-dashboard | 199 | 2.9% / 1.3% / 1.9% / 1.8% | 4/12, 1 | 1,136 / 1,136 / 1,154 / 1,136 |
WebKit (./run.py verify --engines chrome,webkit --filter real/, 2026-10-24; details and the two saturated flex cases in out/webkit.md): px differing vs Chrome 6.0% / 3.6% / 1.8% / 1.6% / 1.2% / 1.0% / 1.4% in the table's order; boxes 208/866 sizes (594 positions), 4/176 (23), 0/1792 (0), 3/471 (428), 0/84 (0), 0/48 (0), 2/12 (2); page heights 84,997 / 19,788 / 155,326 / 90,170 / 5,444 / 1,232 / 1,136. WebKit uses the same 1/64 px int32 LayoutUnit as Blink and clamps flex/wide/10000 and flex/absolute/10000 at the same 33,554,432 px (8,440 / 5,800 boxes clamped vs Chrome's 8,416 / 5,729), so those two cases are not equal work for it either.
Chrome and Firefox agree on every page to within 0.3% of the page height (the pixel difference is glyph rasterisation plus a few 1px line-height roundings). Known differences, each with the CSS feature responsible (Blitz unless stated; "positions" differ wherever an earlier height does):
float: Blitz is built with its opt-infloatsfeature (without it floats are laid out in flow and Wikipedia's infobox becomes a full-width block: 21.9 % of pixels, page 87,076 px). With floats the page is 83,201 px (2.2 % shorter than Chrome) and 460 of the 866 boxes still differ, mostly text wrapped beside the infobox and the.hlist/navbox tables below it; not isolated to a feature yet. Servo floats the infobox but wraps the text beside it narrower than Chrome/Firefox (its 10.5 % pixel difference; it gives no boxes to quantify).- Inline-level elements (
.mw-heading h3{display:inline},.marriage-display-inline,.hlistitems, rustdoc's inline<code>) have no box in Blitz's--boxesdump (they live in the text layout) and are reported as 0×0; they count as size differences without being layout differences. - Replaced elements with a cyclic percentage
max-widthand amax-height(servo.org:img{max-width:100%;display:block}with.section-feature img{max-height:6rem}and.navbar-brand img{max-height:4rem}): Blitz uses the intrinsic width (401–1024px) as the image's min-content contribution, so theflex: 0 1 25%feature tiles cannot shrink to Chrome's 301px and the navbar brand is 1024px instead of 560px (31/84 boxes, page 5,967 vs 5,445). MDN's logo wrapper (.navigation__logo, around the 83×24 placeholder of an inline SVG) is 99px wide in Blitz and 16px in Chrome; this one is not isolated to a feature yet. display: table/table-column/table-row/table-cellon<div>s (the flexbox spec's#overview-examplefigure): the column and row boxes are 0×0 in Blitz and the figure is 30px shorter (28/471 boxes, +201px over the page from a few such figures).- Inline-block
<code>in prose (rustdoc's.docblock p code{display:inline-block}withwhite-space:pre-wrap): some paragraphs wrap to one more line in Blitz (24 vs 48px), and the🔬in.stab.unstablefalls back to no emoji font (511 vs 528px wide); 56/1792 boxes, page height 155,421 vs 155,326 (without thefloatsfeature the page was 6 % taller, 164,853). - Table rows: Blitz rounds layout to whole pixels, so each of the Bootstrap dashboard's 17 table rows is 1px taller (
.table-responsive544 vs 526px, page +18px). Wikipedia's toolbar tabs are 35px instead of 32px for the same reason (.vector-menu-content, ±3px). - Ladybird (
07fac502): 1.6–6.7 % of pixels differ on every page but Wikipedia (13.1 %) and its body heights match Chrome's page heights within 0.1 % (the flexbox spec and servo.org body is 16 px narrower: Ladybird reserves a scrollbar gutter). Wikipedia'shtml,body{height:100%}gives an 800 px body whose content overflows, so the dump reports no page height there; thediv/p/h3box counts are lower than Chrome's (812 vs 866) because inlinedivs/h3s have no block box in the dump, as with Blitz, and are compared as missing. - The dioxuslabs.com and Bootstrap pixel differences beyond the boxes above are the placeholder images, which Blitz paints without the pages'
border-radius/object-fitclipping, and anti-aliasing.
The real pages are not pixel-equal and are not meant to be; the check that matters before timing them is the heights and box counts above, which pin each remaining difference to a feature.