read-model

The Lifer and Laggard Way

"How do you deal with too much data in your React UX?" — an engineer asked, meaning read model without knowing the term. You DON'T!
That he wouldn't know. But we DO need to know where too much is!
This is the benchmark fixture that answers TOO MUCH:

where each one breaks.

01 The premise

A brilliant Wizard once called it "What should never be — and IS!" Four beliefs get treated as absolute fact inside most IT departments:

  1. React is the main (or only) framework for building user interfaces.
  2. "Too much data" will routinely be presented to any read-model component.
  3. Architecture has nothing to do with data size on presentation.
  4. React is a dawg, everyone knows it's bad, and nobody is willing to say it.

None of that holds at a competently run company. Miss-Size is a design failure, not a constraint to engineer around. But it's a genuinely fun engineering problem to torture-test anyway — so this fixture pushes four contenders to 300,000+ browser-rendered nodes and measures exactly where each one dies.

02 The contenders

DOM-less solutions (canvas, WASM-off-DOM) are deliberately left out — different animal, different fixture.

03 The dataset ladder

Every node count below is derived from the same formula:
divisions × (1 + groups + groups·teams + groups·teams·people)
— shape is a controlled variable: see :fixture in Kotlin.
Par is what Google's own JSON prettyprint viewer application can do presenting the same data.
CEILING, SHALLOW, and DEEP hold the same total node count (~100k) and vary only branching shape for comparison: balanced, many-tiny-teams, and few-huge-teams, to isolate whether performance depends on the size of a structural edit rather than the size of the tree.

Dataset Nodes Elements Shape
smoke 1,469 — sanity check
bench 18,148 92,157 baseline — the craziest app you'll find in production
stress 50,882 — size ladder
ceiling 102,973 — balanced branching — shape control
shallow 100,113 — many tiny teams — scattered edits
deep 99,983 — few huge teams — one giant edit
edge 222,560 — size ladder
load 320,073 1,608,438 ludicrous, yet Chrome somehow survives it
boom (fail) 1,048,749 5,262,894 self-destruction, by design

04 Where the DOM actually breaks

Size is the derivative of your design — not its driver.

For large data business cases: dedicated tooling, not the presentation layer.


04a Stuff for us to understand!

MEMORY is the real limit when it comes to browser applications!
The 4GB per-container Chrome factory limit causes crashes beyond the load dataset size.
To torture properly I removed A) crash detection and prevention, B) any memory limits on the container — which makes the containers realistically go to 8-12GB while still somewhat usable. Chrome by its own architecture doesn't do well addressing more than about 13GB of active content. When forced, at about 20GB a whole new slew of bad behaviors arises, making the browser uncontrollable.

Pure TypeScript survives a million: (every leaf element painted)

Pure TS at the boom dataset: 1,048,749 nodes, 5,262,894 elements, complete
1,048,749 nodes → 5,262,894 real DOM elements. Reveal complete in 29,266.4ms.

React survives 1M nodes: (React and Compose dismount instead of hiding)

React at the boom dataset: 1,048,749 nodes, measured Elements reads 96,174 due to dismounting collapsed teams
Same 1,048,749 nodes, reveal complete in 36,931.3ms — Elements reads 96,174, same as Teams, because React dismounts collapsed teams rather than toggling a CSS class. It also uses TWO DOMs actively diffing: same data and different accounting.

Kobweb and Compose HTML crash at boom with around 400k successfully revealed nodes

The plan was to catch Kobweb and Compose HTML crashing around 400k. They do. Both hit Chrome's default crash page — no in-app error, nothing recoverable, just the tab going down — after revealing roughly 400,400 of the 1,029,600 boom rows. Pure TS and React are the only two that complete the full dataset.

Activity Monitor during the Kobweb boom run, captured right before it crashes: Chrome renderer at 577% CPU, 8.11GB real memory
Kobweb, captured right before it crashes at boom — Chrome Helper (Renderer) at 577% CPU, 8.11GB real memory at 400,400 rows.

Results: 1,048,749 nodes

Full boom dataset comparison across Par, Pure TS, React, Kobweb, and Compose HTML
Par / Pure TS / React / Kobweb Composer / Compose HTML, side by side, at boom.

04b So, is React a dawg?

js-framework-benchmark, Chrome 152 — react-hooks-v19.2.0 vs vanillajs, both keyed:

Operation vanillajs react-hooks-v19.2.0 Slowdown
create 1,000 rows 20.6ms (1.03×) 23.7ms (1.19×) 1.15×
replace all rows 21.9ms (1.02×) 30.0ms (1.40×) 1.37×
partial update 9.5ms (1.02×) 11.2ms (1.20×) 1.18×
select row 2.5ms (1.14×) 5.8ms (2.64×) 2.32×
swap 2 rows 10.9ms (1.03×) 89.9ms (8.25×) 8.25×
remove row 9.7ms (1.02×) 11.1ms (1.22×) 1.14×
create 10,000 rows 213.6ms (1.01×) 308.7ms (1.48×) 1.45×
append 1,000 rows 23.5ms (1.01×) 29.2ms (1.26×) 1.24×
clear rows 8.7ms (1.09×) 17.8ms (2.20×) 2.05×
weighted geometric mean 1.04 1.58 ~1.52×

Worst case is swap rows at 8.25× — non-adjacent reconciliation cost. Across the whole suite React runs a ~52% overall slowdown (weighted geometric mean 1.58 vs vanilla's 1.04).

PICNIC Error!

We didn't see that 50%+ slowdown ourselves. Our React run finishes the full LUDICROUS boom dataset only ~14% slower in total than Pure TS (45,996.7ms vs 40,340.5ms), and ~26% slower on the Reveal stage alone (36,931.3ms vs 29,266.4ms) — nowhere near krausest's worst case. Sound implementation — know WHAT you are doing and WHY — no virtual-DOM misuse, no gratuitous re-renders cuz we can — COMPETENCE matters more than the framework does. Is a lotta swap a problem? — what would a competent coder do instead?

Agreed!

The CHAIR is Dawg! Not React.

FYI, that's usually the case in engineering!

P.S. I recently watched a senior architect sort right in the page 🤔 — get my point?!

05 Design

Nine Gradle modules, one shared Fixture contract, one measurement loop driving all four frontends identically.

Module structure

Diagram 1: Gradle module dependency graph



        

The Fixture contract

Diagram 2: The shared Fixture interface and its implementers



        

Measurement flow

Diagram 3: One rung, fetch to report


    

06 BUGS!!: the SlotTable Google Android bottleneck

When I first benched Compose HTML I fell off my chair! Then while CPU profiling Compose HTML at load size I found one Android function that belongs in Engineering Darwin Award Hall of Fame — a gap-buffer Array.fill copy inside SlotTable — eating 4,259ms of a 7,302ms non-idle trace, ~58% of total self time. Every structural edit (fold/unfold) was paying an O(n) array-shift tax. (WTH!!)

The fix: androidx.compose.runtime.ComposeRuntimeFlags.isLinkBufferComposerEnabled = true, set once before the first composition — swaps the gap buffer for a linked-list-backed slot table. Applied to both the :compose and :kobweb fixtures.

Read ComposeRuntimeFlags.kt itself and check the copyright dates: the linked-list linkbuffer implementation is dated 2024, the slower default gapbuffer one was still being touched in 2025 — and the flag ships disabled by default, tracked against Google's own open internal bug b/485957718. The fix has existed for a year; it's just off.

Before

4,259.35ms — 58% of trace — one function.

After

12.76ms — 0.2% — same function, same trace shape, same dataset.

Post-fix, the trace is dominated by (idle) (57.4%) and (program) (20.0%) — actual attributable JS work, spread across boxed-long arithmetic, DOM plumbing, and the linkbuffer machinery itself, never exceeds 1.1% for any single function.

Compose HTML benchmark results after the linkbuffer fix
Compose HTML, post-fix — the run that used to stall now completes cleanly.

07 Results

Final benchmark comparison table across all four frontends
Side-by-side comparison — Pure TS, React, Kobweb, Compose HTML.
Kobweb before the linkbuffer fix
Kobweb, before the fix.
A separate Android-specific rendering bug found along the way
An unrelated Android rendering bug, found and worked around en route.

08 Run it yourself


            git clone https://github.com/Mimis-Gildi/read-model
            cd read-model
            ./gradlew run
        

Property setting launchBrowserOnRun=true will also launch your browser.

Or an environment variable will do it LAUNCH_BROWSER= ./gradlew run

This repo ships no agentic setup — mine is tuned to how I work, and yours should be tuned to how you work. Add your own CLAUDE.md and adjust .claude/settings.json if you want an agent in here.

NOTE: gradle-node has horrible cache bugs; when in trouble ./gradlew -stop to kill a broken daemon.