Guide · By Danylo Pravda · Updated
React performance lessons from 517 examples
Beamline's speed bench renders every example of every component, 190 parts and 517 examples, in a frame-exact rig at 120 Hz, and counts what React and the browser did: commits, component renders, hooks, DOM changes, style and layout passes. Counts are exact and repeat run to run; milliseconds are reported too, but they move with the machine. On its first night it found the problems below. Each one is a pattern that shows up in most React component libraries, so the numbers are worth more than the story.
How it measures
- A virtual clock. The browser draws a frame only when the rig asks for one, every 8.3 ms of virtual time, so a slow machine produces the same frames as a fast one, just later. That is what makes counts exact.
- Counts gate, time informs. Every example has recorded ceilings (hooks exact, the other counts within a couple of per cent), and a change that goes over one fails the check unless the ceiling is raised with a reason. Time is compared with paired runs and an interval, never two medians: on a busy machine, unpaired medians once called a deliberately slowed build 21 % faster.
- Looks and motion are guarded. Every comparison also checks that pixels and motion did not change, so a speed fix cannot quietly drop an animation.
1. Every instance reading the DOM for itself
Found: across all 517 examples, Beamline's shared hooks took 22.8 % of all JavaScript time, close to React itself (26 %) and far above every component file together (8.7 %). One hook, which reads each part's resolved appearance, was 7.8 % on its own, in 506 of 517 examples. Each instance read computed styles in a layout effect, so each one forced its own style pass, and stored the result in state, so every Button rendered twice on mount.
Fix: read once per commit for everyone. No host element in state, a starting value from the last read in the same scope, every consumer's read batched after the commit, and one synchronous render only for those whose values changed.
Measured: Button examples went from 7 commits to 4 and from 209 component renders to 88 (−58 %); the data grid from 1,499 renders to 1,202 (−20 %); every chart, select, combobox and tab set lost one to three commits. A Next.js check of the same build showed no hydration warning.
The general lesson: getComputedStyle or offsetWidth in a layout effect is cheap once and expensive times a
hundred. Batch the reads for all instances, then write.
2. An effect with no dependency list
Found: the chart frame's label measurer had a layout effect with no dependency array, so it re-read computed styles and re-subscribed to font loading on every render, and charts render on every frame of their entry animation.
Fix: read once per look, not once per render.
Measured: a chart's entry went from 129 commits to 66; the line chart from 154 to 78; the analytics dashboard from 132 to 70.
The general lesson: a missing dependency array is invisible in review and multiplies with every render you did not think about. Animations are where they hurt.
3. One component per cell
Found: the activity heatmap's loading state, one placeholder component per cell, was the heaviest mount of all 517 examples: 2,677 components and 14,019 hooks.
Fix: one SkeletonGroup for many placeholder shapes, under one visibility and motion check.
Measured: 2,677 renders became 18, 14,019 hooks became 69, and the mount's CPU time fell from 1,172 ms to 444 ms.
4. The theme in React state
Found: switching the theme on the data grid's page re-rendered thousands of components, though CSS already applies the change: the theme lived in React state at the top of the page.
Fix: the appearance scope now has narrow channels (defaults, motion, density, elements, changes), so a part re-renders only for what it reads, and the page keeps its elements while only the appearance changes.
Measured: one switch went from 2,871 renders in 4 commits to 307 in 2, and a page switched to light is pixel-identical to the same page loaded in light.
5. Totals recomputed on every render
Found: scrolling the data grid through 5,000 rows cost about 90 ms of main-thread time per 400 px step, against a frame budget of 8.3 ms. A trace put half of the grid's own time in the footer totals: count, unique, sum and average over all 5,000 rows, recomputed in every render, three renders per step.
Fix: memoise the totals on the row set.
Measured: scrolling used 18.7 % less main-thread CPU (95 % interval −39.0 % to −8.6 %), with sort, search and select-all unchanged and no pixel or motion change. It cost 33 bytes gzipped and one hook per grid, both recorded as the reason the ceilings moved.
6. Comparing text the long way
Found: a diff of a 400-line file took 72.7 million instructions.
Fix: compare lines as integers and skip the lines both texts start with; the same walk, the same result (3,000 random pairs gave output identical to the old code).
Measured: 22.1 million instructions per diff (−70 %).
What was already fine
- Off screen, almost everything stops: 475 of 515 examples do no React or DOM work once scrolled away; most of the other 40 are live demos (feeds, streams) that are meant to keep moving.
- Number formatting is not worth optimising today: formatting a market number costs about 7,000 instructions, and with about 150 numbers per order book update that is roughly 0.3 ms.
- No document-wide
:has(): every one of the 12:has()selectors in the built CSS is anchored to a component's own class, so none of them makes every DOM change slower.
What it means if you use Beamline
These fixes shipped in the releases of 8 October 2026 (changelog), and the bench now runs before a change is accepted: if a part starts rendering more, mounting more hooks or changing how it looks or moves, the check fails before it reaches a release. The parts above are live in the component library, and the rules every part follows are in Conventions.