Where a style gets resolved decides nearly everything: the lanes that finish at build time run 2 to 8× the runtime libraries, and the one thing that reliably switches the compiler off is a module boundary between a styled component and the JSX that uses it.
next-yak's compiler, unless what you export is styles rather than componentsThe Example multifile-shop shown below is the Example product-grid re-laid-out across separate modules, byte-identical in DOM and CSS and verified pixel-identical.
next-yak falls from 185,000 to 105,000 renders/s and lands beside its own foldStatic: false lane at 103,000, because fold_jsx_usages rewrites a declaration and its use site only when both live in one module. The css prop is untouched, 231,000 against 223,000, since it folds where it is written and inlines an imported fragment at build time. The Example multifile-composition shows which boundary is free: move a component and its JSX together and folding still sees both.
Across the Examples compose-1, compose-3 and compose-6 shown below,
next-yak costs 2.24M, 1.40M and 1.48M renders/s. Six levels of wrapping compile to one component carrying six class names in a single string, so everything after the first wrapper is free. Hand-written
vanilla pays once per level, 1.92M to 1.37M to 919,000, and
panda pays hardest, 1.12M to 310,000. The Example button-variants shows the ceiling of the same mechanism: three imported style values fold into className="yTV3SxX" and the wrapper component disappears from the output.
next-yak APIs are not interchangeable, and they fail in opposite placesThe css prop folds across a module boundary and the styled API does not, worth 2.12× on the Example multifile-shop shown below. Under composition the ordering reverses: the styled API concatenates a chain into one component while the css prop merges at every level, so on the Example compose-6 the styled lane runs 1.48M renders/s against 600,000. On the Example button-variants-nested the css prop has no cell at all, because a style fragment cannot arrive as a component prop and the case does not compile for it. Choosing between the two is a bet on which pattern a codebase uses most.
On the Example product-grid shown below,
styled-components spends 5.66 ms and
goober 5.93 ms of server CPU serializing rules per instance, against 0.000 ms for the lanes that resolve at build time. The same work returns on the client, where both need around 240 ms to hydrate the page against 56 ms for the leaders, because the sheet must be rebuilt before React can attach. It arrives a third time on interaction: one wishlist toggle costs
emotion 46.2 ms against 8.5 ms.
next-yak and a discipline everywhere elseThe Example dyn-translate shown below measures the naive pattern, the value baked into the class name, which is what a codebase produces when nobody optimizes:
goober renders at 166,000 renders/s and takes 1.27 s to hydrate. The Example dyn-fair gives every lane its own best practice, and for a library without native support that best practice is hand-written, splitting the static class from an inline style per component, for every dynamic value, indefinitely. It works, lifting
goober to 945,000 and
panda to 1.00M.
next-yak ships that separation as a compiler feature and pays for it: the Example dyn-inline puts every lane on a plain inline style, where
next-yak renders 1.51M against 724,000 through its CSS custom property.
On the Example tabs shown below, next-yak-css ships 2,707 gz B and
cnfast 22.0 kB for the identical page. The split is not the stylesheet, which stays within a few hundred bytes across lanes; it is the JS and the markup.
emotion and
styled-components each carry about 13 kB of library, while the utility lanes write their class lists into the HTML. The Example product-grid holds the same ordering at 7,475 gz B against 27,718.
next-yak (styled API)Folding stops at a module boundary: imported styled components stay runtime wrappers, 185k → 105k renders/s on multifile-shop.
next-yak (css prop)Folds across modules, but merges once per composition level: 1.91M → 600k renders/s from depth 1 to 6, and again on hydration.
BambooNo styling runtime, but readable class names reach the markup: tabs spends 2.67 of 2.95 ms inside React writing them.
Panda (css fn)css() resolves per render where a call site is not hoisted. Recipes avoid it, 1.30M vs 768k on btn-variant; fold static call sites too.
StyleXNothing on the render path for flat JSX; styleq's per-element merge is what remains, 2.01M → 799k renders/s from depth 1 to 6.
styled-componentsRe-hashes and injects per render: 5.66 ms of product-grid CPU, 240 ms to hydrate. Hash the static rule once, ride values on a variable.
EmotionRe-hashes the serialized string every render, 7.79 ms of product-grid CPU and 46.2 ms per interaction; memoize on string identity.
GooberInjects a class per distinct value, so unique values are a cliff: dyn-translate hydrates in 1.27 s. Key the cache on template identity.
tailwind-mergeThe merge rebuilds its joined-class key every render, six times over at depth 6 (419k renders/s). Cache on argument identity.
cnfastNear-free on repeated class lists, 1.66M renders/s on btn-variant; only high-cardinality values cost, through the cache miss.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.
The heaviest page in the suite sorts lanes by how much of the styling library survives the build: folding leaves
next-yak 0.548 ms of lib self-time per render, while
Emotion spends 7.79 ms of its 13.9 ms there.
The Example multifile-shop shown below renders this exact page from separate modules instead of one, with the DOM, the CSS and the 400 products byte-identical and verified pixel-identical. The lanes that resolve styles at render do not move.
next-yak falls from 185,000 to 105,000 renders/s, because its fold cannot cross a module boundary.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)A whole shop pagethe same tiles, split across filesn = 400low cardinality
The EXACT product-grid workload — identical DOM, identical CSS, identical 400 tiles — laid out the way real code ships: the shared fragments in one module, the primitives split by role across several more, and every use site in index.tsx. A compiler can only replace a styled component with a plain tag when the declaration and the use site sit in the same module, so here it never can, and the shared fragments have to cross a module boundary to reach the components that use them. Runtime libraries do not care about file layout. The gap to product-grid is what the boundary costs.
Splitting the same page across modules costs
next-yak's styled API its entire build-time fold and lands it on the lane that never folds, while the css prop resolves imported fragments at build time and holds its position.
This is the Example product-grid shown above with nothing changed but the file layout: same DOM, same CSS, same 400 products, verified pixel-identical, which is what makes the module boundary the only variable. Read it against the Example multifile-composition, which moves the styled primitives and their JSX use sites together into one imported module and therefore keeps folding. Moving a whole component across a file is free; shipping primitives for another module to use is not.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)A 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.
A real component with a full pseudo-state matrix and a 992px flip rewards resolving that matrix during the build: four compile-time lanes finish above hand-written
vanilla, and
next-yak's own no-fold lane costs 1.97×.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)Button variantscomposed as style valuesn = 1,000low cardinality
A base button, a ghost override and a ghost-primary override, each exported from its own module as a style value and merged onto ONE element by the page. Every level overrides the last (background, border-colour, colour), so this measures conflict resolution, not concatenation: tailwind-merge re-parses the whole list, StyleX and the atomic compilers resolve it at build time, and the styled lanes serialise it once per class. Read against button-variants-nested, which ships the identical ladder as a JSX component API.
Three imported style values merged onto one element fold into a single class:
next-yak emits className="yTV3SxX" and drops the wrapper component entirely, running 1.55× its own no-fold lane.
The Example button-variants-nested shown below is this exact ladder with one change: each module exports a JSX component instead of a style value. Same declarations, same three modules, same rendered element. Values fold across the module boundary and components do not, which drops
next-yak from 2.23M to 1.28M renders/s and costs even hand-written
vanilla 24%.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)Button variantscomposed as JSX componentsn = 1,000low cardinality
The IDENTICAL ladder as button-variants — same declarations, same three modules, same rendered element — but each module exports a component instead of a style value: GhostPrimaryButton wraps GhostButton wraps Button. next-yak collapses the chain at construction, so three levels stay one React element; the runtime and utility lanes render three components and re-merge at every level. The gap to button-variants is what the component API costs, with the file layout held constant. The next-yak css-prop lanes have no cell here: the css prop resolves only top-scope values, so a style fragment cannot reach a component through a prop — that API composes fragments at build time or not at all.
Exporting the same three modules as JSX components instead of style values costs every lane, hand-written
vanilla included, and costs
next-yak its fold outright: a styled component cannot be folded across a module boundary.
This is the Example button-variants shown above with each module exporting a JSX component instead of a style value, so the pair prices the API shape rather than the file layout — both are split three ways. The same asymmetry runs through the Example multifile-shop: exported values fold across a boundary, exported components do not. The css-prop lanes have no cell here at all, because the css prop cannot take a style fragment as a component prop and the case does not compile for them, which is a limitation worth knowing before choosing that API.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)A 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.
A full design-system Tabs is where a dynamic prop closure blocks folding: styled(TabInternal) keeps its runtime call, and
next-yak's styled lane runs at half its own css-prop sibling.
The Example multifile-composition shown below is this exact workload with the styled primitives moved into an imported parts.tsx, and nothing meaningful moves:
next-yak runs 80,800 here against 85,900 there, a difference inside the run-to-run spread, because that module keeps the primitives and their JSX use sites together, which is all folding requires. Contrast the Example multifile-shop, where primitives ship for another module's JSX to use and
next-yak gives up 43% of its throughput.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)Multi-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.
Moving a component into its own module is free: the primitives and the JSX that uses them travel together, folding still sees both, and
next-yak lands within the run-to-run spread of the single-file case.
This is the Example tabs shown above with the styled primitives moved into an imported parts.tsx, and nothing measurable changes. Read it against the Example multifile-shop, which asks the other half of the question: there the primitives ship for a different module's JSX to use, fold_jsx_usages never sees a declaration and its use site together, and
next-yak gives up 43% of its throughput. Moving a whole component across a file is free; shipping primitives for someone else to use is not.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)Dynamic 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.
Baking a unique value into the class name mints 1,000 one-off classes:
goober needs 1.27 s to hydrate the page and 149 ms at the largest size, while the lanes that keep the value out of the selector never notice.
The Examples dyn-fair and dyn-inline shown below take this workload two other ways. In dyn-fair every lane uses its own best practice, which for a library with no native dynamic-value support means a hand-written split of static class from inline style; that lifts
goober from 166,000 to 945,000 renders/s and
panda from 222,000 to 1.00M.
next-yak ships that separation as a compiler feature, so nobody writes it; elsewhere the same result is developer discipline applied to every dynamic value, on every component, indefinitely. The naive pattern measured here is what a codebase gets when nobody pays that cost.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)Dynamic 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.
With every lane on its own best practice the field converges near 1.6M renders/s, and
next-yak's automatic CSS-variable path costs 2.08× against the inline style the other lanes write by hand.
The trio reads as one argument. The Example dyn-translate shown above is the naive pattern, what a codebase produces when nobody optimizes:
goober at 166,000 renders/s and a 1.27 s hydration. This case is every lane at its best, and for a library without native support that best is hand-written, the developer splitting static class from inline style per component, for every dynamic value, indefinitely.
next-yak ships that separation as a compiler feature instead. The Example dyn-inline shown below removes the last variable by putting every lane,
next-yak included, on a plain inline style, and
next-yak renders 1.51M there against 724,000 here, which prices its CSS-variable path at 2.08×.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)Dynamic 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.
With every lane on a plain inline style,
next-yak included, only the element-level fold is left to measure:
next-yak and hand-written
vanilla finish within 4%, and
next-yak's own no-fold lane costs 1.41×.
The control for the Examples dyn-translate and dyn-fair shown above. Holding the technique constant across every lane isolates one number:
next-yak renders 1.51M here against 724,000 in dyn-fair, so its CSS custom-property path costs 2.08× against the inline style other lanes write by hand. That is the honest price of the feature, and it stands against what the feature removes — the naive pattern in dyn-translate, where
goober falls to 166,000 renders/s and hydration takes 1.27 s because nobody wrote the split.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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 (styled API)
next-yak (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.
A thousand buttons cycling twelve class strings put eight lanes within 20% of each other, because almost every lookup is a cache hit; the split is between lanes that cache and lanes that recompute regardless.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
Panda (recipe)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)Composition1 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 depth one there is no chain to collapse, so the case prices each library's floor cost per element:
next-yak compiles to a bare tagged element and leads, while the runtime lanes already give up 2 to 5× before any wrapping begins.
Read with the Examples compose-3 and compose-6 shown below, which wrap this same button two and five more times.
next-yak runs the three depths at 2.24M, 1.40M and 1.48M renders/s: the drop from one to three buys back a __yak_button component that depth one does not need, and depth six is free because six levels concatenate into that same single component.
vanilla pays once per level instead, 1.92M to 1.37M to 919,000.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)Composed 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.
next-yak concatenates the three-level chain into one __yak_button at build time and leads the field, while the css prop keeps a merge at every level and finishes below hand-written
vanilla.
Bracketed by the Examples compose-1 and compose-6.
next-yak runs the three depths at 2.24M, 1.40M and 1.48M renders/s, so the cost lands entirely on the first wrapper and everything after it is free.
vanilla pays once per level, 1.92M to 1.37M to 919,000, and next-yak-css pays once per level too, 1.91M to 983,000 to 600,000.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)Composition6 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.
Six levels cost
next-yak nothing over three, because the whole chain compiles to one __yak_button holding six classes in a single string, while every lane that merges at render pays six times.
The deep end of the trio, with the Examples compose-1 and compose-3.
next-yak costs 2.24M, 1.40M and 1.48M renders/s across the three depths, so everything after the first wrapper is free and the chain concatenates into one component whatever its depth. Every other lane pays per level, most steeply
panda at 1.12M, 540,000 and 310,000.
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 1.1.0): 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 1.1.0). Lower is better.
Chrome-profiled first-span anatomy (web-performance-debugger 1.1.0); 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 1.1.0); 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 1.1.0); 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.
Bamboo
cnfast
Emotion
Goober
next-yak (styled API)
next-yak (css prop)
next-yak (css prop) foldStatic: false
next-yak (styled API) foldStatic: false
Panda (css fn)
Panda (style props)
styled-components
StyleX without CSS layers (:not() specificity hack)
StyleX
tailwind-merge
vanilla (hand-written ceiling)Build 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.