The same 11 scenarios run through every renderer, from one set of fixtures (scenarios/). Every timing is wall time on the main thread for one pass, and each renderer defines its pass differently. Compare a renderer with itself across scenarios first, and renderers with each other only where the column says the same thing:
Renderer
One pass is
React · Chromiumreact-chromium
the DOM update flushed synchronously and the layout read back (main-thread work, no frame wait)
Solid · Chromiumsolid-chromium
the signal writes applied synchronously (no scheduler) and the layout read back (as React: main-thread work, no frame wait)
Web component · Chromiumelement-chromium
one <exponential-surface> element: the property writes applied synchronously by the Solid painter underneath and the layout read back (as React and Solid)
Rust corecore
one layout pass with a fixed fake measure (no text shaping, no painting)
gpui · headlessgpui
the core pass plus gpui's own layout, prepaint and paint of a full frame (cosmic-text)
SwiftUI · macOSswift-macos
the model's pass: the core through the facade, TextKit measurement, nodes and visuals read back (no SwiftUI body)
SwiftUI · iPhone Simulatorswift-ios-simulator
the model's pass on the Simulator (as macOS)
SwiftUI · iPhoneswift-ios-device
the model's pass on an attached iPhone
Compose · JVM (Robolectric)compose-jvm
the model's pass: the core through JNA, Robolectric's native text measure, nodes and visuals read back (no composition)
Compose · Android (emulator or device)compose-android
the model's pass on the device (as the JVM, with the real text stack)
Counts (nodes, measure calls, facade calls, bytes) come from counters that live behind a debug flag in each renderer and cost nothing in a release build; memory is the process footprint after the scenario. A renderer that cannot measure an optional metric leaves it out.
Generated from src/scenarios.ts into one JSON file each (the surface, the data or the spec to generate it, the viewport, the theme, the steps, the metrics), drift-gated.
The kitchen sink (every component of the core catalog once) painted by a renderer that has touched nothing yet: the embedded catalog and theme JSON parsed, the tree reduced, every macro expanded, data bound, text measured, laid out and painted. The cost an agent's first surface pays.
coldStartMs (ms): From the renderer's first touch in a fresh process (the first surface created: the catalog, macros and themes parse here) to the kitchen sink's first painted frame.
firstPaintMs (ms): From the surface's content and data being handed to the renderer to its first painted frame: reduce + bind + measure + lay out + paint (a renderer without a window: the model's first pass). Generated data is built before the clock starts and handed over inside it.
nodes (count): Placed nodes after the first paint (the reduced tree, template instances included).
memoryMb* (mb): The process's resident footprint after the scenario (RSS / phys_footprint; in Chromium the JS heap retained after a forced GC), in MB.
allocations* (count): Heap allocations during the cold start (a counting allocator; the Rust core only).
The 205-node card grid (the core's bench tree): after the first paint, the surface alternates 390 and 900 px, so every pass re-resolves the grid's breakpoint, wraps the pills and re-measures nothing the width memo already answered. The engine's own time per pass.
warmPassMs (ms): Median of 200 passes alternating the width 900 ↔ 390 px (one pass = the width change applied, laid out and painted).
upcallsPerPass* (count): Measure upcalls (batched rounds) the core made per warm pass, max over the passes.
measureCallsPerPass* (count): Text measurements the host performed per warm pass, max over the passes.
fixtures/bench-list.json, the shared list bench: a windowed List over 100,000 generated rows. First paint = bind + window + paint; a scroll step moves one viewport and re-windows; the jump lands on row 50,000. Where data binding and per-row text measurement cost.
firstPaintMs (ms): From the surface's content and data being handed to the renderer to its first painted frame: reduce + bind + measure + lay out + paint (a renderer without a window: the model's first pass). Generated data is built before the clock starts and handed over inside it.
scrollStepMs (ms): Mean of 100 scroll steps of one viewport each (the scroll applied, the window re-bound, laid out and painted).
scrollToIndexMs (ms): scrollToIndex(50000, start): the jump applied, the window re-bound, laid out and painted.
renderedItems (count): Items alive after the jump (≤ the window + 2 × overscan + the pinned header).
measureCallsPerStep* (count): Text measurements the host performed per scroll step (mean).
ffiCallsPerStep* (count): Facade calls (host → core and the measure upcalls) per scroll step (mean; Swift and Compose, behind the debug counters).
ffiBytesPerStep* (bytes): Bytes of JSON and records crossing the facade per scroll step (mean; Swift and Compose, behind the debug counters).
memoryMb* (mb): The process's resident footprint after the scenario (RSS / phys_footprint; in Chromium the JS heap retained after a forced GC), in MB.
An agent streaming 20 messages a second for 10 s: 200 messages alternating an updateComponents that appends one transcript card and an updateDataModel that moves a status line and a progress bar. Each message is applied, laid out and painted before the next. Where a renderer rebuilds too much per message.
applyMs (ms): Mean cost of one message: applied, laid out and painted.
p95ApplyMs (ms): The 95th percentile of that cost (the late messages, when the transcript is long).
totalMs (ms): All 200 messages back to back (must stay far under the 10 s they would arrive in).
nodes (count): Placed nodes after the first paint (the reduced tree, template instances included).
ffiBytesTotal* (bytes): Bytes crossing the facade over the whole stream (Swift and Compose, behind the debug counters).
The kitchen sink painted, then the theme cycled neutral → playful → exponential (every recipe re-resolves, every node restyles) and the mode flipped light ↔ dark (colours only). The cost of a runtime theme change on a full surface.
themeSwitchMs (ms): Median of 30 theme switches (the theme set, restyled, laid out and painted).
modeSwitchMs (ms): Median of 20 mode flips (the mode set, restyled, laid out and painted).
restyled* (count): Nodes the core restyled on one theme switch (the whole tree is the honest answer).
The kitchen sink resized between a phone and a desktop width, 100 times: the responsive section changes columns, the sidebar collapses and expands, the Drawer's side flips; text re-wraps at a new width. The cost of a window resize or a rotation.
resizeMs (ms): Median of 100 width changes 900 ↔ 390 px (the width applied, measured, laid out and painted).
upcallsPerResize* (count): Measure upcalls (batched rounds) per resize, max over the passes.
A chain 48 levels deep, the catalog's maxDepth: every level a padded column with a label. 95 nodes, but every pass walks the full depth and the recursive passes (reduce, build, taffy) run at their limit.
firstPaintMs (ms): From the surface's content and data being handed to the renderer to its first painted frame: reduce + bind + measure + lay out + paint (a renderer without a window: the model's first pass). Generated data is built before the clock starts and handed over inside it.
warmPassMs (ms): Median of 100 passes alternating the width 900 ↔ 390 px.
depth (count): The deepest placed node's level (the root = 1): must be 48.
A plain column (not a windowed List) with a template over 10,000 items, the catalog's maxTemplateItems: every item is instantiated and bound at once. Then the array is replaced by 10,000 new items. Pure template binding cost, no windowing to hide it.
firstPaintMs (ms): From the surface's content and data being handed to the renderer to its first painted frame: reduce + bind + measure + lay out + paint (a renderer without a window: the model's first pass). Generated data is built before the clock starts and handed over inside it.
rebindMs (ms): The array replaced by 10,000 new items: re-bound, laid out and painted.
nodes (count): Placed nodes after the first paint (the reduced tree, template instances included).
A surface with a Dialog whose open state is bound to the data model: opened and closed 50 times through the binding. The layer manager, the focus trap and the overlay's own layout, per toggle.
openMs (ms): Median of 50 opens (the binding set true, the layer placed, laid out and painted).
closeMs (ms): Median of 50 closes (the binding set false, the layer gone, laid out and painted).
A data Table over 1,000 bound rows with typed columns (a badge, a right-aligned number): first paint, 20 scroll steps of one viewport, then the sort changed through an updateComponents. The Table's row binding and windowing against the List's.
firstPaintMs (ms): From the surface's content and data being handed to the renderer to its first painted frame: reduce + bind + measure + lay out + paint (a renderer without a window: the model's first pass). Generated data is built before the clock starts and handed over inside it.
scrollStepMs (ms): Mean of 20 scroll steps of one viewport each.
sortMs (ms): The sort changed by an updateComponents: applied, rows re-ordered, re-bound, laid out and painted.
renderedRows (count): Rows alive after the scroll steps (a windowed Table keeps it near the viewport's).
eApps §15 (VAPP-202): the local records query (eui-core host::query, its TS twin @exponential_app/eui/host/query) over 25,000 rows, the team plan's quota: five query shapes, each parsed once, 5 warm-up runs, then 60 timed; p95 = the 58th of the 60 sorted. The budget is p95 ≤ 16 ms on a MID PHONE, so a desktop CPU must hold about a third of it. No surface: only the core and one web runner (the TS twin in Chromium) run it; Swift and Compose reach the core over the FFI.
monthViewP50Ms (ms): Month view, mine, sum: the median of 60 runs.
monthViewP95Ms (ms): Month view, mine, sum: p95 of 60 runs (the §15 budget's number).
filterSortP50Ms (ms): Filter + sort + limit 500: the median of 60 runs.
filterSortP95Ms (ms): Filter + sort + limit 500: p95 of 60 runs (the §15 budget's number).
groupBucketP50Ms (ms): Group author × month bucket, sum + count: the median of 60 runs.
groupBucketP95Ms (ms): Group author × month bucket, sum + count: p95 of 60 runs (the §15 budget's number).
groupRunningP50Ms (ms): Group project, running sum: the median of 60 runs.
groupRunningP95Ms (ms): Group project, running sum: p95 of 60 runs (the §15 budget's number).
betweenInP50Ms (ms): Between + in, limit 500: the median of 60 runs.
betweenInP95Ms (ms): Between + in, limit 500: p95 of 60 runs (the §15 budget's number).
matched (count): Σ count over the five queries (the same rows on every runner).
* optional: a renderer without the counter leaves it out.
One block per machine (results/): every renderer that ran there, column by column, metric rows per scenario. Milliseconds unless the row says otherwise; lower is better everywhere. Numbers depend on the machine, so the machine is part of the record.
13th-gen-intel-core-i9-13900k-linux
13th Gen Intel(R) Core(TM) i9-13900K, 32 cores, 63 GB, linux 6.17.0-42-generic.
Compose · JVM (Robolectric) on Robolectric API 34, amd64, 32 cpus (2026-10-23, b8ea2c89e)
Compose · Android (emulator or device) on Google sdk_gphone64_x86_64 (API 34) emulator (2026-10-23, b8ea2c89e)
Kitchen sink cold start
Compose · JVM (Robolectric)
Compose · Android (emulator or device)
coldStartMs
667
676
firstPaintMs
26.2
140
nodes
604
604
memoryMb (MB)
91.4
46.3
Card grid, warm width change
Compose · JVM (Robolectric)
Compose · Android (emulator or device)
warmPassMs
0.32
0.58
upcallsPerPass
1
1
measureCallsPerPass
29
29
100,000-row list
Compose · JVM (Robolectric)
Compose · Android (emulator or device)
firstPaintMs
136
1,886
scrollStepMs
2.73
11.2
scrollToIndexMs
3.03
16.5
renderedItems
30
30
measureCallsPerStep
78
78
ffiCallsPerStep
82
82
ffiBytesPerStep
46.6 kB
46.6 kB
memoryMb (MB)
82.4
115.0
Streaming agent
Compose · JVM (Robolectric)
Compose · Android (emulator or device)
applyMs
2.04
4.81
p95ApplyMs
4.94
9.51
totalMs
407
961
nodes
607
607
ffiBytesTotal
5.1 MB
5.1 MB
Theme and mode switch
Compose · JVM (Robolectric)
Compose · Android (emulator or device)
themeSwitchMs
16.5
57.6
modeSwitchMs
3.88
7.58
restyled
609
609
Resize 390 ↔ 900
Compose · JVM (Robolectric)
Compose · Android (emulator or device)
resizeMs
6.00
15.0
upcallsPerResize
1
1
Depth-48 nesting
Compose · JVM (Robolectric)
Compose · Android (emulator or device)
firstPaintMs
2.33
13.5
warmPassMs
0.10
0.36
depth
48
48
10,000 template items
Compose · JVM (Robolectric)
Compose · Android (emulator or device)
firstPaintMs
275
2,359
rebindMs
286
2,318
nodes
10,001
10,001
Overlay open and close
Compose · JVM (Robolectric)
Compose · Android (emulator or device)
openMs
0.36
1.22
closeMs
0.16
0.94
Table with 1,000 rows
Compose · JVM (Robolectric)
Compose · Android (emulator or device)
firstPaintMs
15.8
82.9
scrollStepMs
6.60
19.7
sortMs
9.82
28.4
renderedRows
30
30
apple-m4-pro-darwin
Apple M4 Pro, 14 cores, 24 GB, darwin 27.2.0.
React · Chromium (2026-10-23, 86dcf72610d)
Solid · Chromium (2026-10-23, aecf8204e1a)
Web component · Chromium (2026-10-23, aecf8204e1a)
Rust core (2026-10-31, 5b1261f467a)
SwiftUI · macOS on Mac16,7 (Apple M4 Pro, macOS Version 27.0 (Build 26A5421a)) (2026-10-23, e45e72775a2)
SwiftUI · iPhone Simulator on iPhone 17 Pro Simulator (iPhone18,1, iOS 27.0) (2026-10-23, e45e72775a2)
Compose · JVM (Robolectric) on Robolectric API 34, aarch64, 14 cpus (2026-10-23, 86dcf72610d)
Compose · Android (emulator or device) on Google sdk_gphone64_arm64 (API 36) emulator (2026-10-23, 02024b0361d)
Where a renderer loses the core’s speed, with the number that showed it and the suspected cause. The optimisation that fixes one lowers its budget in the same change.
Renderer
Scenario · metric
Value
Suspected cause
Solid · Chromium
template-10k · rebindMs
350 ms (apple-m4-pro-darwin)
2.7× React's 130.7 ms since React's re-render work (first paint 316 vs 143 ms, 2.2×): each of the 10,000 items builds a bound node with its prop proxies (mergeProps/splitProps) and its memos, all disposed and rebuilt when every key changes; React reconciles one tree. The element is the same painter (344 ms).
Solid · Chromium
table-1k · sortMs
4.90 ms (apple-m4-pro-darwin)
2.6× React's 1.9 ms since React's windowed-sort work: a sort re-keys the 36 windowed rows and their cell parts are re-bound one owner each; React reorders them in one reconcile.
Solid · Chromium
list-100k · scrollStepMs
3.67 ms (apple-m4-pro-darwin)
1.8× React's 2.07 ms: a step of one viewport creates ~30 fresh rows and Solid's per-node setup (prop proxies, ~10 memos per bound node) costs about twice React's. The same cost is what is left of a jump (scrollToIndex 6.2 vs 4.1 ms; the jump's own call is at React's level).
One command runs every runner the machine can (React, Solid and the web component in headless Chromium, the Rust core, gpui on Linux, SwiftUI on macOS and the iPhone Simulator, Compose on the JVM, and an attached iPhone or Android device when there is one), merges the fragments into results/<machine>.json and prints the ratchet’s verdicts:
Terminal window
bunrun--filter@exponential_app/eui-benchbench# everything this machine can run