next-yak 9.7 ships static folding and the composition-flattening perf runtime on by default: the styled API holds a flat SSR cost from one wrapper to six where 9.6.0 rose 7.8×, both
next-yak paths land at or below hand-written
vanilla across SSR, payload and scaling, and the css-prop fold is the fastest shipped path.
The styled API's library slice used to scale straight-line with nesting; the perf runtime now collapses the chain at construction time and holds it flat. On the composition ladder (the Examples compose-1, compose-3 and compose-6 shown below) next-yak-9.7 keeps a ~0.138 ms SSR library slice at every depth, where the 9.6.0 baseline climbs 0.460 to 1.38 to 3.65 ms. In throughput that is 0.671 ms/1k at six levels against the baseline's 5.26, a 7.8× gap, and the interaction follows: the 9.6.0 baseline re-renders a six-level subtree in 24.2 ms against the perf runtime's 7.40 ms. On flat JSX (the Example realistic-button shown below) the perf runtime barely moves the needle, because there is no chain to collapse.
Disabling static folding (foldStatic:false) isolates what each mechanism buys. On the flat button (the Example realistic-button shown below) folding rewrites <DenseButton> to a plain <button className> and deletes the styling component, so turning it off costs about 2× SSR (0.962 vs 0.479 ms/1k) as the Yak wrapper returns with a 0.786 ms library slice. On the Example compose-6 shown below folding off costs only ~1.3×, because there the perf runtime, not the fold, does the work. Folding applies when the host is a known intrinsic tag or a class-toggle variant; it stops at styled(Component) seams, which is why the Example tabs shown below keeps a residual wrapper on its styled(Tabs) composition.
next-yak's fastest shipped path and ties
vanillaWhere the styled fold hands each composition level its own class down a chain, the css-prop fold concatenates the whole chain into a single className on the plain element. On the Examples tabs, compose-3 and compose-6 shown below its SSR CPU matches or beats hand-written
vanilla (0.507 vs 0.508 ms/1k at three levels), and it ships the smallest payload in the run, 2.39 kB gzipped on tabs, one class regardless of depth. For hot composed trees it is the path to recommend today; the styled API reaches the same shape only through the perf runtime, which leaves one residual wrapper on composed seams.
The libraries that name a class from the styles at render time pay for it every render. On the Example realistic-button shown below,
emotion's murmur2 hash is 4.27 ms of its 7.53 ms render, the single most expensive styling function in the run, and generateAndInjectStyles is most of
styled-components' slice. The cost repeats during hydration, when these libraries inject their sheet and force reflow: on the naive dynamic case (the Example dyn-translate shown below)
goober injects 1000 distinct classes and its client mount blows out to 1270 ms. Every extracted lane (
next-yak,
StyleX,
vanilla) ships zero styling runtime and hydrates as pure JS. That cost lands at render; the codegen tools pay theirs at build instead, where
StyleX and
Panda cold-build in 343-548 ms against 69-121 ms for everything else, with
next-yak's transform adding only ~40 ms over the runtime libraries.
next-yak, a discipline everywhere else
next-yak compiles a dynamic value into a static class plus a CSS custom property automatically. On the Example dyn-translate shown below, where each element carries a distinct value and nobody has hand-optimized, the hash libraries regenerate a class per element and their cost explodes (
goober renders 5.83 ms/1k, 9.7× the css-prop fold). The fair comparison, the Example dyn-fair shown below, gives each library its hand-written best practice, a static class with the value on an inline style, and the field converges toward
vanilla. That parity has a price the chart cannot show: the developer performs the split by hand, per component, on every dynamic value, forever, to reach what
next-yak's css-var compile ships once as a feature. The naive pattern is what you get when nobody pays that discipline.
The Example btn-variant shown below isolates how each library expresses variants.
Panda's precompiled cva recipe joins the fast cluster at 0.853 ms/1k, while its inline css() fn re-serializes every variant per element (13.3 ms of component self-time) and is the slowest lane in the case at 13.5 ms/1k, a 16× SSR-throughput swing inside one library.
next-yak expresses variants as $prop class toggles that inline to a string concat and fold away, so it lands at the static-class floor with
StyleX and
vanilla.
next-yak 9.7.0 (styled API)Perf runtime and static folding are on by default; the next lever is teaching the styled fold to chain through styled(styled(…)) so composed trees reach the css-prop fold's one-class floor.
styled-componentsgenerateAndInjectStyles re-hashes and injects a class every render (most of its SSR slice on realistic-button); hash the static rule once and ride dynamic values on a CSS variable, as its own .attrs path already does.
Emotionmurmur2 re-hashes the whole serialized string each render (4.27 of 7.53 ms on realistic-button); memoize the hash on the serialized-string identity for static or lightly-dynamic components.
GooberThe s function re-serializes and re-hashes the CSS string per render, and injects a class per distinct value (client mount 1270 ms on dyn-translate); key the compile-and-hash cache on the tagged-template identity.
StyleXZero styling runtime on flat JSX; the only render-time function left is styleq's atomic merge under composition. The @layer build also trims a little payload on rule-heavy pages over the :not() specificity build.
Panda (css fn)Steer variants to the cva recipe over the inline css() fn, which re-serializes per element (13.3 ms on btn-variant); fold static css({literal}) call sites to the resolved class at build time.
tailwind-mergeget rebuilds and re-looks-up the joined-class key every render (64-70% of its slice); cache on argument identity, or hoist a fully-static merge to a constant.
cnfastNear-free on repeated class lists; only high-cardinality dynamic classes cost via the cache miss. Split the invariant prefix from the volatile token so the prefix stays a hit.AI-generated summary by Claude Opus, from the measured data.
A whole shop page400 product tilesn = 400low cardinality
A real product listing: responsive grid, a sale badge (color by discount), a wishlist toggle, a truncated title, a dynamic rating bar, optional struck-through price and an out-of-stock add-to-cart — plus (hover:hover)-guarded hover, :focus-visible rings, WCAG ::before tap targets, a @container query per tile, reduced-motion handling and a11y semantics. Tailwind fires ~8 cn() per tile.
On a full shop page the css-prop fold (next-yak-css-9.7) leads SSR, payload and scaling at or below hand-written
vanilla, while the runtime hashers pay per tile:
emotion renders the page 7.4× slower and injects a sheet that forces reflow on every client phase.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
cnfast
Emotion
Goober
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
next-yak css-prop 9.6.0
next-yak css-prop 9.7.0
next-yak css-prop 9.7.0 foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
tailwind-merge
vanilla (hand-written ceiling)
StyleXA real componentthe DenseButtonn = 1,000low cardinality
A real-project button with pseudo-states (:hover/:focus-visible/:active/:disabled), a 992px responsive flip, a ::before WCAG target-size, composed style fragments and an icon child — rendered 1,000×. Not a toy 4-class button: this is what real buttons cost.
On a flat, real button
StyleX and the two
next-yak 9.7 paths tie the fastest cluster at ~0.44-0.53 ms/1k SSR; folding erases the Yak wrapper entirely, and
emotion's murmur2 hash is the single most expensive styling function in the run.
realistic-button is the depth-1 anchor of the composition ladder (compose-1, compose-3, compose-6 below). Here folding, not the perf runtime, is the whole win: it deletes the styling component so the styled path ties the extracted floor, and switching folding off doubles the SSR cost. Under composition the split reverses, and the perf runtime carries the load while folding leaves a residual wrapper.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
cnfast
Emotion
Goober
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
next-yak css-prop 9.6.0
next-yak css-prop 9.7.0
next-yak css-prop 9.7.0 foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
tailwind-merge
vanilla (hand-written ceiling)
StyleXA real Tabs component150 groupsn = 150low cardinality
A real design-system Tabs: responsive typography, the full active/hover/focus-visible/disabled state matrix, an animated active underline via CSS anchor positioning (with a per-tab ::after fallback gated on @supports), a ::before WCAG tap target, hidden-scrollbar overflow and a composed FullWidthTabs wrapper. Tailwind needs a ~40-token list per tab; next-yak compiles it all at build time.
On a real composed tab group the css-prop fold merges the whole chain into one className and beats
vanilla on SSR CPU, while the styled perf runtime keeps a residual Yak on its styled(Component) seams and sits mid-pack.
tabs is the single-file twin of multifile-composition below. Here FullWidthTabs is declared and used in one module, so its styled(Tabs) use site can fold; in the split version the primitives are imported and the styled perf runtime's SSR figure drifts from 1.45 to 1.48 ms/1k, while the css-prop fold, which has no styled(...) wrapper to lose across the boundary, holds (0.842 to 0.915) and ships an identical 2.39 kB.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
cnfast
Emotion
Goober
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
next-yak css-prop 9.6.0
next-yak css-prop 9.7.0
next-yak css-prop 9.7.0 foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
tailwind-merge
vanilla (hand-written ceiling)
StyleXMulti-file compositionimported styled primitivesn = 150low cardinality
The EXACT Tabs workload — identical DOM, identical CSS — but the styled primitives are moved to an imported parts.tsx module, the way a design system ships components. Per-module compile-time optimizations such as next-yak's JSX folding cannot see across the module boundary, while runtime libraries do not care about file layout. Any gap between this case and tabs is the cost of that boundary.
Splitting the styled primitives across modules costs the styled perf runtime little; the css-prop fold has no styled(...) wrapper to lose across the boundary and still ties
vanilla on payload and SSR.
multifile-composition is the split-module twin of tabs above. Importing the styled primitives nudges the styled perf runtime from 1.45 to 1.48 ms/1k SSR; the css-prop fold concatenates classes without a styled(...) wrapper to resolve across the boundary, so it barely moves (0.842 to 0.915) and its payload is identical at 2.39 kB.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
cnfast
Emotion
Goober
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
next-yak css-prop 9.6.0
next-yak css-prop 9.7.0
next-yak css-prop 9.7.0 foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
tailwind-merge
vanilla (hand-written ceiling)
StyleXDynamic valuetranslateX (the naive way)n = 1,000high cardinality
1,000 elements each with a unique translateX. Baking the value into the class name produces a brand-new class string every render that a merger can't cache, and styled-components emits a CSS rule per value — while next-yak/Panda turn it into a CSS variable, so their per-instance work stays constant.
With a distinct translateX per element and nobody hand-optimizing,
next-yak's automatic css-var split holds flat while the runtime hashers regenerate a class per element:
goober renders 9.7× slower and its client mount blows out to 1270 ms.
dyn-translate is the naive end of the dynamic-value trio with dyn-fair and dyn-inline below. It is what you get when nobody hand-optimizes: the hashers regenerate a class per value and blow out (
goober 5.83 vs 0.601 ms/1k for the css-var split). dyn-fair gives each library its hand-written best practice and the field converges; dyn-inline is the pure inline-style control.
next-yak's css-var split ships that optimization as a feature, so it already sits at the dyn-fair floor here with no manual work.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
cnfast
Emotion
Goober
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
next-yak css-prop 9.6.0
next-yak css-prop 9.7.0
next-yak css-prop 9.7.0 foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
tailwind-merge
vanilla (hand-written ceiling)
StyleXDynamic valuetranslateX (each lane's best practice)n = 1,000high cardinality
The same 1,000-unique-translateX workload as the naive case, but idiomatic: lanes without native dynamic-value support pass the value as an inline style over a static class (their documented answer to high-cardinality dynamic values), while next-yak keeps its CSS-variable path and StyleX its dynamic style function. Compare with dyn-translate to see what the naive pattern costs each ecosystem.
Given each library its hand-written best practice, a static class with the value on an inline style, the field converges toward
vanilla;
next-yak reaches the same shape as a compiler feature, everyone else by writing the split by hand on every dynamic value.
dyn-fair is the hand-optimized middle of the dynamic trio (dyn-translate above is the naive version, dyn-inline below the pure inline-style control). Every library reaches near-
vanilla here only because the developer manually splits the static class from the dynamic inline value, replicating by hand what
next-yak's css-var compile does automatically. The parity is real, and so is its price: the same optimization, written per component, on every dynamic value, forever. dyn-translate shows what the same code costs when nobody pays that discipline.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
cnfast
Emotion
Goober
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
next-yak css-prop 9.6.0
next-yak css-prop 9.7.0
next-yak css-prop 9.7.0 foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
tailwind-merge
vanilla (hand-written ceiling)
StyleXDynamic valuetranslateX (inline-style control)n = 1,000high cardinality
Every lane — including next-yak — uses a static class plus a plain inline style for the unique translateX. Paired with dyn-fair this isolates what next-yak's CSS-variable indirection itself costs.
The inline-style control isolates
next-yak's wrapper tax: with the value written straight to style and folding on, next-yak-9.7 ties
vanilla (0.629 vs 0.615 ms/1k), and the 9.6.0 baseline without the perf runtime is 2.2× slower.
dyn-inline closes the dynamic trio (dyn-translate the naive version, dyn-fair the hand-optimized one, both above). It carries only the
next-yak family and
vanilla because it is the reference floor for a value that never needs a class at all. Folded, next-yak-9.7 ties
vanilla; this is where the perf runtime's worth over 9.6.0 is starkest (0.629 vs 1.36 ms/1k) with no styling to hide behind.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
vanilla (hand-written ceiling)Variant / state buttonn = 1,000low cardinality
A button rendered 1,000× cycling ~12 distinct class strings (variant × active × fullWidth). With so few repeated strings almost every cn() is a cache hit (nearly free), while wrapper-component libraries still run their machinery per instance — the case where a cached merger is hard to beat.
Variant expression swings a library 16×:
panda's inline css() re-serializes every variant per element (13.5 ms/1k, the case's slowest) while its precompiled recipe joins the fast cluster;
next-yak's $prop class toggles fold to a string concat at the static floor.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
cnfast
Emotion
Goober
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
next-yak css-prop 9.6.0
next-yak css-prop 9.7.0
next-yak css-prop 9.7.0 foldStatic: false
Panda (css fn)
Panda (style props)
Panda (recipe)
styled-components
StyleX without CSS layers (:not() specificity hack)
tailwind-merge
vanilla (hand-written ceiling)
StyleXComposition1 level (control)n = 1,000low cardinality
The compose-3 button family at depth 1: one styled component carrying only the base styles, no wrapper chain. Brackets compose-3 from below (compose-6 brackets it from above) to chart how per-element cost grows with composition depth — the boundary that defeats next-yak's JSX folding.
At one composition level the styled perf runtime leads the field (0.458 ms/1k); the gap to the 9.6.0 baseline (1.18) and to
panda's inline css() (5.24) is the story the deeper ladder amplifies.
compose-1, compose-3 and compose-6 are the composition ladder. At one level the styled perf runtime leads; the 9.6.0 baseline's library slice already scales with depth (0.460 ms here, 1.38 at three, 3.65 at six), which the perf runtime holds flat (~0.138 ms at every depth). The css-prop fold ties
vanilla throughout.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
cnfast
Emotion
Goober
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
next-yak css-prop 9.6.0
next-yak css-prop 9.7.0
next-yak css-prop 9.7.0 foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
tailwind-merge
vanilla (hand-written ceiling)
StyleXComposed components (3 levels)n = 1,000low cardinality
A Button wrapped by two more components, each adding styles and threading className down. next-yak flattens the chain at build time (depth ≈ free); the Tailwind lanes pay one merge per level and styled-components/Emotion run a wrapper component at each.
At three levels the styled perf runtime holds its library slice flat at 0.139 ms while the 9.6.0 baseline's climbs to 1.38 ms; the css-prop fold merges all three levels into one class and ties
vanilla.
The middle of the composition ladder (compose-1, compose-6 its siblings). The perf runtime's library slice is flat across all three depths (~0.138-0.139 ms), where the 9.6.0 baseline's climbs 0.460 to 1.38 to 3.65 ms; the css-prop fold ties
vanilla at every depth by merging the chain into one class.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
cnfast
Emotion
Goober
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
next-yak css-prop 9.6.0
next-yak css-prop 9.7.0
next-yak css-prop 9.7.0 foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
tailwind-merge
vanilla (hand-written ceiling)
StyleXComposition6 levelsn = 1,000low cardinality
The compose-3 button family at depth 6: the same base button wrapped five times, each level adding one small border-left/padding-left declaration. Brackets compose-3 from above (compose-1 brackets it from below) to chart how per-element cost grows with composition depth — the boundary that defeats next-yak's JSX folding.
At six levels the split is total: the styled perf runtime holds 0.671 ms/1k where the 9.6.0 baseline hits 5.26 (7.8×), and the css-prop fold ships one 2.66 kB class that ties
vanilla while
panda's inline css() reaches 18.5 ms/1k.
The deep end of the composition ladder (compose-1, compose-3 above). Six levels is where the perf runtime earns its default: 0.671 ms/1k against the 9.6.0 baseline's 5.26 (7.8×), while its library slice stays flat at 0.138 ms. The css-prop fold's one concatenated class holds
vanilla's render cost and its 2.66 kB payload at every depth.
Source · generated HTML · generated CSS · rendered preview
SSR render throughput — renders / sec · higher is betteriHow many times per second this lane renders the whole workload to an HTML string in Node (renderToString), timing the production render only — any build-time CSS collection (a Tailwind JIT, a Panda sheet slice) is excluded. Higher is better.
SSR throughput under load — requests / sec · higher is betteriRequests/sec the lane sustains under concurrent HTTP load (autocannon) serving the SSR render end-to-end — a more realistic server measure than the in-process microbench. Higher is better.
Where the SSR render time goes — Node CPU profile · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps (recorded with web-performance-debugger 0.15.1): react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.
Client hydration — repeated timing + Chrome-profiled span anatomyiTime for React to hydrate the server HTML in the browser — attach event handlers and build the fiber tree over the existing DOM (it does not re-create markup). The first chart is the existing repeated end-to-end timing; the profiled chart then splits one instrumented commit into JS, style, layout, paint, GC, browser work and idle (recorded with web-performance-debugger 0.15.1). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Interaction re-render — repeated timing + Chrome-profiled span anatomyiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. The profile separates active work from the frame-alignment idle that used to dominate this number. This is where runtime CSS-in-JS libraries re-run their per-element styling on every update; build-time lanes (next-yak / Panda / Tailwind / vanilla) do almost none. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Cold mount — repeated timing + Chrome-profiled span anatomyiStarting from a blank root (no SSR markup), a "click" renders the whole workload from scratch (createRoot().render()), then we wait for the first paint. Unlike hydration — which attaches to existing server HTML — this is a cold client mount, so the first paint includes each runtimelibrary's first style injection into the document. The profiled span shows how much of the commit is JS, style, layout, paint, GC, browser work and idle. Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 0.15.1); segments reconcile exactly to span wall. Rank uses active time (wall minus idle). Repeated timing median is shown when available; slice anatomy is retained for the first iteration only.
Browser render-work on a cold mount — style-recalc / layout / paint · Chrome + FirefoxiWhere the browser's rendering time goes on a cold mount (not JS — the engine's own style-recalc, layout and paint), profiled with web-performance-debugger in two engines. This is where runtime CSS-in-JS pays a tax build-time lanes don't: it injects a style rule per instance, so the engine recalculates styles once per instance — Chrome's authoritative signal is that style-recalc count (the badge; e.g. 50 instances → ~50 recalcs vs 1 for extracted CSS). Firefox (Gecko) reports sampled style/layout time; a zero sampled slice is not proof of no work, so its exact counts are retained as diagnostics but the chart never treats zero as absence. Bars are ms; compare within an engine. Lower is better. Generated via pnpm setup:wpd + pnpm gen:wpd.
Cold mount of 50 instances. Chrome's trustworthy signal is the style-recalc count (badge); Firefox reports sampled Gecko style/layout ms but no main-thread paint. A zero sampled slice is not proof that no work occurred; Chrome's exact count badges are the reliable presence/absence signal.
Page bytes shipped — JS + CSS + HTML, gzipped · lower is betteriGzipped bytes the browser downloads for this page: the client JS runtime the lane ships (over the bare React floor), the CSS, and the SSR HTML. Lower is better.
Scaling — SSR render time (ms) vs instance countiSSR render time as the workload grows from a handful to thousands of instances — shows how each lane's per-element cost compounds. A flatter line scales better.
cnfast
Emotion
Goober
next-yak 9.6.0
next-yak 9.7.0 (styled API)
next-yak 9.7.0 (styled API) foldStatic: false
next-yak css-prop 9.6.0
next-yak css-prop 9.7.0
next-yak css-prop 9.7.0 foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
tailwind-merge
vanilla (hand-written ceiling)
StyleXBuild time — full client build · lower is betteriWall time for a lane's whole production client build — the vite bundle that ships to the browser (react + react-dom + the styling runtime + every workload's components), the same build measured for page bytes. cold clears that lane's build output, vite's on-disk caches and Panda's generatedstyled-system first, so it includes the cache-miss regen; warm is the same build run again with nothing cleared. Median of 3. This is build-time developer experience, machine-dependent — not user-facing runtime. Opt-in via pnpm gen:samples --measure=buildtime.
How this was measured
- microbench — an in-process Node loop that renders each workload to an HTML string (
renderToString) and counts instance renders per second. - autocannon — an HTTP load generator that measures requests per second against each lane's SSR server end to end.
- web-performance-debugger — records CPU and render profiles in Chrome, Firefox and Node and attributes the time to libraries and functions through source maps.
Source, raw data and methodology: github.com/jantimon/css-in-js-bench. Run it locally: clone the repo, pnpm install, then pnpm report renders this report from the committed samples — pnpm gen re-measures everything on your own machine.