CSS-in-JS benchmarks

One set of React components, built 13 different ways and measured head-to-head on identical workloads — so the numbers compare by construction, not by claim.

13 styling techniques6 workloadsproduction React · median of repeated runs
Show
Technologies 13 / 13 shown
·
Baseline
next-yak
Panda
Runtime CSS-in-JS
Atomic / className
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.

Source · generated HTML · generated CSS · rendered preview

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.

vanilla (hand-written ceiling)232,187 r/s ±12%
next-yak (css prop)228,653 r/s ±11%
StyleX208,741 r/s ±8%
next-yak193,416 r/s ±12%
cnfast167,160 r/s ±16%
Panda (css fn)128,890 r/s ±4%
tailwind-merge105,755 r/s ±11%
next-yak 9.5.073,028 r/s ±7%
styled-components54,871 r/s ±2%
Goober49,781 r/s ±2%
Emotion31,431 r/s ±5%
Panda (style props)6,640 r/s ±2%

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.

vanilla (hand-written ceiling)262 req/s
next-yak (css prop)258 req/s
next-yak234 req/s
StyleX193 req/s
next-yak 9.5.0130 req/s
cnfast117 req/s
tailwind-merge112 req/s
Panda (css fn)105 req/s
styled-components101 req/s
Goober100 req/s
Emotion72 req/s
Panda (style props)16 req/s

Where the SSR render time goes — CPU self-time · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps: react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.

react-dom (the shared floor)styling library runtimeyour componentnode / gc / unattributed
vanilla (hand-written ceiling)1.67 ms
next-yak (css prop)1.74 ms
StyleX1.86 ms
next-yak2.07 ms
cnfast2.42 ms
Panda (css fn)3.02 ms
tailwind-merge3.36 ms
next-yak 9.5.05.40 ms
styled-components7.38 ms
Goober7.76 ms
Emotion12.78 ms
Panda (style props)61.34 ms

Where the client hydration time goes — CPU self-time · median msiTime 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). Measured as the single hydration commit, split by CPU self-time into react-dom, the styling library's client runtime, and your component. other is browser-native / GC work V8 can't attribute to a package. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
Panda (css fn)12.10 ms
vanilla (hand-written ceiling)12.30 ms
next-yak (css prop)12.40 ms
StyleX13.00 ms
next-yak13.30 ms
cnfast16.10 ms
tailwind-merge16.30 ms
next-yak 9.5.019.10 ms
Emotion33.70 ms
styled-components60.20 ms
Goober60.50 ms
Panda (style props)76.20 ms

Where the interaction time goes — in-place re-render · median msiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. Split by CPU self-time per package. 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. other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
styled-components7.00 ms
next-yak 9.5.07.40 ms
cnfast7.80 ms
next-yak (css prop)7.80 ms
tailwind-merge7.80 ms
next-yak7.90 ms
vanilla (hand-written ceiling)7.90 ms
Panda (css fn)8.00 ms
StyleX8.10 ms
Goober8.30 ms
Emotion12.60 ms
Panda (style props)46.70 ms

Where the cold-mount time goes — blank screen → first render · median msiStarting 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. Split by CPU self-time into react-dom, thestyling library, and your component; other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
next-yak (css prop)14.80 ms
vanilla (hand-written ceiling)34.60 ms
Panda (css fn)35.60 ms
StyleX36.10 ms
next-yak36.50 ms
cnfast39.30 ms
tailwind-merge39.60 ms
next-yak 9.5.044.30 ms
Goober56.90 ms
styled-components57.20 ms
Emotion66.70 ms
Panda (style props)104.30 ms

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.

JSCSSHTML
next-yak (css prop)7,175 B(0.0 / 0.9 / 6.1 kB)
vanilla (hand-written ceiling)7,790 B(0.0 / 1.5 / 6.1 kB)
next-yak 9.5.08,122 B(1.0 / 0.9 / 6.1 kB)
next-yak8,216 B(1.0 / 0.9 / 6.1 kB)
Goober10,937 B(3.3 / 1.2 / 6.2 kB)
StyleX12,830 B(2.3 / 1.4 / 8.8 kB)
Emotion20,135 B(12.4 / 1.2 / 6.1 kB)
Panda (css fn)20,966 B(3.4 / 1.5 / 15.6 kB)
styled-components21,009 B(12.8 / 1.4 / 6.3 kB)
Panda (style props)24,918 B(7.5 / 1.5 / 15.3 kB)
tailwind-merge26,588 B(10.2 / 1.7 / 14.0 kB)
cnfast27,559 B(11.2 / 1.7 / 14.0 kB)

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.

0.01533074606131005001,0002,0004,000
cnfastEmotionGoobernext-yaknext-yak (css prop)next-yak 9.5.0Panda (css fn)Panda (style props)styled-componentsStyleXtailwind-mergevanilla (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.

Source · generated HTML · generated CSS · rendered preview

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.

next-yak (css prop)915,261 r/s ±3%
next-yak915,053 r/s ±3%
StyleX850,551 r/s ±2%
vanilla (hand-written ceiling)828,129 r/s ±4%
cnfast541,333 r/s ±3%
next-yak 9.5.0422,008 r/s ±6%
tailwind-merge327,515 r/s ±0%
Panda (css fn)321,651 r/s ±0%
styled-components209,781 r/s ±2%
Goober196,396 r/s ±2%
Emotion146,232 r/s ±2%
Panda (style props)20,215 r/s ±2%

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.

next-yak (css prop)627 req/s
next-yak612 req/s
vanilla (hand-written ceiling)560 req/s
StyleX438 req/s
next-yak 9.5.0340 req/s
cnfast278 req/s
tailwind-merge213 req/s
styled-components183 req/s
Goober176 req/s
Panda (css fn)165 req/s
Emotion137 req/s
Panda (style props)20 req/s

Where the SSR render time goes — CPU self-time · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps: react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.

react-dom (the shared floor)styling library runtimeyour componentnode / gc / unattributed
next-yak1.08 ms
next-yak (css prop)1.08 ms
StyleX1.10 ms
vanilla (hand-written ceiling)1.26 ms
cnfast1.70 ms
next-yak 9.5.02.41 ms
tailwind-merge2.91 ms
Panda (css fn)3.37 ms
styled-components4.88 ms
Goober5.09 ms
Emotion6.96 ms
Panda (style props)49.89 ms

Where the client hydration time goes — CPU self-time · median msiTime 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). Measured as the single hydration commit, split by CPU self-time into react-dom, the styling library's client runtime, and your component. other is browser-native / GC work V8 can't attribute to a package. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
vanilla (hand-written ceiling)11.40 ms
next-yak11.50 ms
StyleX11.50 ms
Panda (css fn)11.60 ms
next-yak (css prop)11.70 ms
next-yak 9.5.014.60 ms
cnfast16.10 ms
tailwind-merge16.60 ms
Emotion26.20 ms
Goober36.80 ms
styled-components38.40 ms
Panda (style props)63.40 ms

Where the interaction time goes — in-place re-render · median msiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. Split by CPU self-time per package. 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. other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
Emotion7.30 ms
Goober7.80 ms
next-yak (css prop)7.80 ms
next-yak 9.5.07.80 ms
styled-components7.80 ms
tailwind-merge7.80 ms
Panda (css fn)7.80 ms
cnfast7.90 ms
next-yak7.90 ms
vanilla (hand-written ceiling)7.90 ms
StyleX7.90 ms
Panda (style props)38.90 ms

Where the cold-mount time goes — blank screen → first render · median msiStarting 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. Split by CPU self-time into react-dom, thestyling library, and your component; other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
vanilla (hand-written ceiling)13.90 ms
next-yak30.30 ms
StyleX30.30 ms
next-yak (css prop)30.40 ms
Panda (css fn)30.40 ms
next-yak 9.5.033.30 ms
cnfast34.70 ms
tailwind-merge35.20 ms
Goober42.40 ms
styled-components44.60 ms
Emotion52.20 ms
Panda (style props)85.30 ms

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.

JSCSSHTML
next-yak (css prop)3,996 B(0.0 / 0.5 / 3.4 kB)
vanilla (hand-written ceiling)4,444 B(0.0 / 0.9 / 3.5 kB)
next-yak 9.5.04,799 B(1.0 / 0.5 / 3.2 kB)
next-yak4,889 B(1.0 / 0.5 / 3.2 kB)
Goober7,424 B(3.3 / 0.6 / 3.4 kB)
StyleX8,886 B(2.3 / 1.0 / 5.4 kB)
Panda (css fn)16,008 B(3.4 / 1.0 / 11.3 kB)
Emotion16,620 B(12.4 / 0.6 / 3.2 kB)
styled-components17,103 B(12.8 / 0.6 / 3.3 kB)
Panda (style props)20,204 B(7.5 / 1.0 / 11.3 kB)
tailwind-merge21,214 B(10.2 / 1.0 / 9.5 kB)
cnfast22,185 B(11.2 / 1.0 / 9.5 kB)

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.

0.0501011512011005001,0002,0004,000
cnfastEmotionGoobernext-yaknext-yak (css prop)next-yak 9.5.0Panda (css fn)Panda (style props)styled-componentsStyleXtailwind-mergevanilla (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.

Source · generated HTML · generated CSS · rendered preview

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.

vanilla (hand-written ceiling)159,574 r/s ±2%
next-yak (css prop)156,986 r/s ±4%
StyleX120,712 r/s ±11%
next-yak81,844 r/s ±2%
cnfast57,233 r/s ±2%
next-yak 9.5.056,250 r/s ±4%
Panda (css fn)52,612 r/s ±1%
tailwind-merge41,652 r/s ±1%
Goober35,555 r/s ±3%
styled-components33,698 r/s ±2%
Emotion22,358 r/s ±1%
Panda (style props)3,292 r/s ±3%

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.

vanilla (hand-written ceiling)741 req/s
next-yak (css prop)706 req/s
StyleX503 req/s
next-yak440 req/s
next-yak 9.5.0318 req/s
cnfast247 req/s
Goober223 req/s
Panda (css fn)199 req/s
tailwind-merge198 req/s
styled-components188 req/s
Emotion143 req/s
Panda (style props)21 req/s

Where the SSR render time goes — CPU self-time · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps: react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.

react-dom (the shared floor)styling library runtimeyour componentnode / gc / unattributed
vanilla (hand-written ceiling)0.92 ms
next-yak (css prop)0.93 ms
StyleX1.01 ms
next-yak1.76 ms
cnfast2.09 ms
next-yak 9.5.02.65 ms
Panda (css fn)2.89 ms
tailwind-merge3.18 ms
Goober4.04 ms
styled-components4.68 ms
Emotion6.74 ms
Panda (style props)46.66 ms

Where the client hydration time goes — CPU self-time · median msiTime 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). Measured as the single hydration commit, split by CPU self-time into react-dom, the styling library's client runtime, and your component. other is browser-native / GC work V8 can't attribute to a package. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
next-yak (css prop)9.00 ms
vanilla (hand-written ceiling)9.00 ms
StyleX9.10 ms
Panda (css fn)9.80 ms
next-yak11.40 ms
next-yak 9.5.012.50 ms
cnfast13.60 ms
tailwind-merge14.00 ms
Emotion23.90 ms
Goober45.20 ms
styled-components47.70 ms
Panda (style props)60.00 ms

Where the interaction time goes — in-place re-render · median msiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. Split by CPU self-time per package. 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. other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
Emotion7.10 ms
next-yak (css prop)7.70 ms
tailwind-merge7.70 ms
Goober7.80 ms
next-yak7.80 ms
styled-components7.80 ms
vanilla (hand-written ceiling)7.80 ms
Panda (css fn)7.90 ms
StyleX7.90 ms
next-yak 9.5.08.10 ms
cnfast8.90 ms
Panda (style props)34.80 ms

Where the cold-mount time goes — blank screen → first render · median msiStarting 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. Split by CPU self-time into react-dom, thestyling library, and your component; other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
tailwind-merge16.70 ms
vanilla (hand-written ceiling)24.80 ms
next-yak (css prop)25.00 ms
StyleX25.60 ms
Panda (css fn)26.50 ms
next-yak27.70 ms
next-yak 9.5.028.80 ms
cnfast29.70 ms
Goober50.90 ms
styled-components53.60 ms
Emotion61.40 ms
Panda (style props)78.20 ms

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.

JSCSSHTML
next-yak (css prop)2,397 B(0.0 / 0.6 / 1.7 kB)
vanilla (hand-written ceiling)2,690 B(0.0 / 1.0 / 1.6 kB)
next-yak 9.5.03,340 B(1.0 / 0.6 / 1.7 kB)
next-yak3,434 B(1.0 / 0.6 / 1.7 kB)
Goober5,832 B(3.3 / 0.7 / 1.7 kB)
StyleX7,905 B(2.3 / 0.9 / 4.5 kB)
Panda (css fn)14,881 B(3.4 / 1.2 / 9.9 kB)
Emotion15,187 B(12.4 / 0.8 / 1.6 kB)
styled-components15,767 B(12.8 / 0.8 / 1.8 kB)
Panda (style props)19,627 B(7.5 / 1.2 / 10.5 kB)
tailwind-merge20,884 B(10.2 / 1.3 / 8.9 kB)
cnfast21,855 B(11.2 / 1.3 / 8.9 kB)

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.

0.031462794112551005001,0002,0004,000
cnfastEmotionGoobernext-yaknext-yak (css prop)next-yak 9.5.0Panda (css fn)Panda (style props)styled-componentsStyleXtailwind-mergevanilla (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.

Source · generated HTML · generated CSS · rendered preview

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.

next-yak1,532,274 r/s ±17%
StyleX1,446,569 r/s ±15%
next-yak (css prop)1,399,580 r/s ±13%
vanilla (hand-written ceiling)1,371,272 r/s ±14%
cnfast1,081,618 r/s ±37%
Panda (recipe)1,070,999 r/s ±11%
tailwind-merge963,159 r/s ±9%
next-yak 9.5.0598,802 r/s ±9%
styled-components471,207 r/s ±26%
Goober438,460 r/s ±3%
Emotion318,797 r/s ±23%
Panda (style props)84,052 r/s ±15%
Panda (css fn)66,884 r/s ±17%

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.

next-yak1,604 req/s
vanilla (hand-written ceiling)1,439 req/s
next-yak (css prop)1,430 req/s
StyleX1,248 req/s
cnfast1,010 req/s
tailwind-merge851 req/s
Panda (recipe)765 req/s
next-yak 9.5.0678 req/s
styled-components531 req/s
Goober486 req/s
Emotion356 req/s
Panda (style props)86 req/s
Panda (css fn)74 req/s

Where the SSR render time goes — CPU self-time · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps: react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.

react-dom (the shared floor)styling library runtimeyour componentnode / gc / unattributed
next-yak0.47 ms
StyleX0.53 ms
next-yak (css prop)0.54 ms
vanilla (hand-written ceiling)0.57 ms
cnfast0.59 ms
Panda (recipe)0.77 ms
tailwind-merge0.79 ms
next-yak 9.5.01.30 ms
styled-components1.72 ms
Goober1.86 ms
Emotion2.71 ms
Panda (style props)10.72 ms
Panda (css fn)12.59 ms

Where the client hydration time goes — CPU self-time · median msiTime 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). Measured as the single hydration commit, split by CPU self-time into react-dom, the styling library's client runtime, and your component. other is browser-native / GC work V8 can't attribute to a package. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
next-yak8.10 ms
vanilla (hand-written ceiling)9.10 ms
next-yak (css prop)9.30 ms
StyleX9.70 ms
Panda (recipe)10.10 ms
next-yak 9.5.011.70 ms
cnfast13.30 ms
tailwind-merge13.50 ms
Emotion17.10 ms
Goober20.70 ms
styled-components21.30 ms
Panda (css fn)23.90 ms
Panda (style props)25.50 ms

Where the interaction time goes — in-place re-render · median msiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. Split by CPU self-time per package. 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. other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
Emotion7.70 ms
next-yak 9.5.07.70 ms
Goober7.80 ms
next-yak (css prop)7.80 ms
styled-components7.80 ms
next-yak7.80 ms
cnfast7.90 ms
vanilla (hand-written ceiling)7.90 ms
Panda (recipe)8.10 ms
StyleX8.30 ms
tailwind-merge8.30 ms
Panda (style props)10.80 ms
Panda (css fn)11.30 ms

Where the cold-mount time goes — blank screen → first render · median msiStarting 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. Split by CPU self-time into react-dom, thestyling library, and your component; other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
next-yak19.60 ms
vanilla (hand-written ceiling)20.30 ms
next-yak (css prop)20.50 ms
StyleX20.60 ms
Panda (recipe)21.50 ms
next-yak 9.5.023.30 ms
cnfast24.60 ms
tailwind-merge24.90 ms
Goober24.90 ms
styled-components25.70 ms
Emotion29.40 ms
Panda (css fn)36.20 ms
Panda (style props)37.50 ms

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.

JSCSSHTML
next-yak (css prop)3,432 B(0.0 / 0.2 / 3.1 kB)
vanilla (hand-written ceiling)3,789 B(0.0 / 0.5 / 3.2 kB)
next-yak 9.5.04,370 B(1.0 / 0.2 / 3.1 kB)
next-yak4,469 B(1.0 / 0.2 / 3.1 kB)
Panda (recipe)6,089 B(1.0 / 0.3 / 4.6 kB)
StyleX6,742 B(2.3 / 0.3 / 4.0 kB)
Goober6,782 B(3.3 / 0.3 / 3.1 kB)
Panda (css fn)8,539 B(3.4 / 0.3 / 4.6 kB)
Panda (style props)12,736 B(7.5 / 0.3 / 4.6 kB)
tailwind-merge14,963 B(10.2 / 0.4 / 4.0 kB)
cnfast15,934 B(11.2 / 0.4 / 4.0 kB)
Emotion16,157 B(12.4 / 0.3 / 3.1 kB)
styled-components16,688 B(12.8 / 0.3 / 3.2 kB)

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.

0.0142842551005001,0002,0004,000
cnfastEmotionGoobernext-yaknext-yak (css prop)next-yak 9.5.0Panda (css fn)Panda (style props)Panda (recipe)styled-componentsStyleXtailwind-mergevanilla (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.

Source · generated HTML · generated CSS · rendered preview

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.

vanilla (hand-written ceiling)1,759,402 r/s ±7%
next-yak1,182,907 r/s ±6%
cnfast1,029,822 r/s ±5%
StyleX994,119 r/s ±5%
styled-components741,199 r/s ±6%
tailwind-merge711,301 r/s ±3%
Goober551,496 r/s ±8%
Emotion412,343 r/s ±2%
next-yak 9.5.0356,189 r/s ±9%
Panda (css fn)97,095 r/s ±10%

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.

vanilla (hand-written ceiling)1,576 req/s
next-yak1,205 req/s
StyleX896 req/s
cnfast834 req/s
styled-components699 req/s
tailwind-merge595 req/s
Goober515 req/s
Emotion426 req/s
next-yak 9.5.0398 req/s
Panda (css fn)90 req/s

Where the SSR render time goes — CPU self-time · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps: react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.

react-dom (the shared floor)styling library runtimeyour componentnode / gc / unattributed
vanilla (hand-written ceiling)0.53 ms
next-yak0.65 ms
cnfast0.77 ms
StyleX0.85 ms
styled-components1.20 ms
tailwind-merge1.25 ms
Goober1.68 ms
Emotion2.24 ms
next-yak 9.5.02.33 ms
Panda (css fn)10.17 ms

Where the client hydration time goes — CPU self-time · median msiTime 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). Measured as the single hydration commit, split by CPU self-time into react-dom, the styling library's client runtime, and your component. other is browser-native / GC work V8 can't attribute to a package. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
vanilla (hand-written ceiling)8.90 ms
next-yak9.20 ms
StyleX10.60 ms
next-yak 9.5.011.90 ms
cnfast13.50 ms
tailwind-merge13.90 ms
Emotion15.50 ms
Panda (css fn)21.40 ms
styled-components22.30 ms
Goober22.80 ms

Where the interaction time goes — in-place re-render · median msiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. Split by CPU self-time per package. 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. other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
Emotion7.50 ms
tailwind-merge7.50 ms
next-yak 9.5.07.80 ms
StyleX7.80 ms
Goober7.90 ms
vanilla (hand-written ceiling)7.90 ms
cnfast7.90 ms
next-yak7.90 ms
styled-components8.00 ms
Panda (css fn)9.00 ms

Where the cold-mount time goes — blank screen → first render · median msiStarting 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. Split by CPU self-time into react-dom, thestyling library, and your component; other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
vanilla (hand-written ceiling)19.50 ms
next-yak20.80 ms
StyleX21.90 ms
next-yak 9.5.023.50 ms
cnfast24.70 ms
tailwind-merge25.30 ms
styled-components27.60 ms
Goober27.70 ms
Emotion30.60 ms
Panda (css fn)33.30 ms

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.

JSCSSHTML
vanilla (hand-written ceiling)3,044 B(0.0 / 0.4 / 2.6 kB)
next-yak 9.5.03,775 B(1.0 / 0.2 / 2.6 kB)
next-yak3,867 B(1.0 / 0.2 / 2.6 kB)
StyleX5,479 B(2.3 / 0.2 / 2.8 kB)
Goober6,197 B(3.3 / 0.2 / 2.6 kB)
Panda (css fn)7,051 B(3.4 / 0.3 / 3.2 kB)
tailwind-merge13,913 B(10.2 / 0.3 / 3.1 kB)
cnfast14,884 B(11.2 / 0.3 / 3.1 kB)
Emotion15,393 B(12.4 / 0.2 / 2.4 kB)
styled-components16,194 B(12.8 / 0.3 / 2.7 kB)

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.

0.0102030401005001,0002,0004,000
cnfastEmotionGoobernext-yaknext-yak 9.5.0Panda (css fn)styled-componentsStyleXtailwind-mergevanilla (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.

Source · generated HTML · generated CSS · rendered preview

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.

vanilla (hand-written ceiling)1,561,890 r/s ±3%
next-yak (css prop)1,314,349 r/s ±5%
StyleX1,125,124 r/s ±18%
next-yak772,623 r/s ±4%
next-yak 9.5.0760,987 r/s ±2%
cnfast500,521 r/s ±9%
tailwind-merge425,577 r/s ±8%
Emotion327,811 r/s ±4%
Panda (css fn)302,119 r/s ±2%
styled-components205,437 r/s ±8%
Panda (style props)185,268 r/s ±7%
Goober165,991 r/s ±7%

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.

next-yak (css prop)1,345 req/s
vanilla (hand-written ceiling)1,258 req/s
StyleX888 req/s
next-yak726 req/s
next-yak 9.5.0681 req/s
cnfast462 req/s
tailwind-merge454 req/s
Emotion335 req/s
Panda (css fn)246 req/s
styled-components224 req/s
Panda (style props)176 req/s
Goober158 req/s

Where the SSR render time goes — CPU self-time · median ms / renderiThe median server renderToString(), split by CPU self-time from a sampled V8 profile mapped through source maps: react-dom (the floor every lane shares), the styling library's runtime, and your component. other is GC / unattributed native work.

react-dom (the shared floor)styling library runtimeyour componentnode / gc / unattributed
next-yak (css prop)0.56 ms
vanilla (hand-written ceiling)0.62 ms
StyleX0.85 ms
next-yak1.18 ms
next-yak 9.5.01.28 ms
cnfast1.90 ms
tailwind-merge1.99 ms
Emotion2.79 ms
Panda (css fn)3.58 ms
styled-components4.23 ms
Panda (style props)5.31 ms
Goober5.77 ms

Where the client hydration time goes — CPU self-time · median msiTime 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). Measured as the single hydration commit, split by CPU self-time into react-dom, the styling library's client runtime, and your component. other is browser-native / GC work V8 can't attribute to a package. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
next-yak (css prop)9.00 ms
vanilla (hand-written ceiling)9.00 ms
StyleX10.20 ms
next-yak10.60 ms
next-yak 9.5.011.10 ms
Panda (css fn)14.50 ms
tailwind-merge15.60 ms
cnfast16.10 ms
Panda (style props)19.10 ms
Emotion22.80 ms
styled-components27.30 ms
Goober321.10 ms

Where the interaction time goes — in-place re-render · median msiA state change triggers a synchronous re-render (flushSync) of the whole mounted workload, then we wait for the next paint — click→paint latency. Split by CPU self-time per package. 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. other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
Panda (style props)7.50 ms
Emotion7.70 ms
Goober7.80 ms
next-yak7.80 ms
next-yak (css prop)7.80 ms
tailwind-merge7.80 ms
next-yak 9.5.07.90 ms
Panda (css fn)7.90 ms
cnfast7.90 ms
StyleX7.90 ms
styled-components8.00 ms
vanilla (hand-written ceiling)8.00 ms

Where the cold-mount time goes — blank screen → first render · median msiStarting 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. Split by CPU self-time into react-dom, thestyling library, and your component; other is browser-native / GC. Lower is better.

react-dom (the shared floor)styling library runtimeyour componentbrowser-native / gc
next-yak (css prop)17.80 ms
vanilla (hand-written ceiling)18.70 ms
StyleX19.10 ms
next-yak19.70 ms
next-yak 9.5.020.20 ms
Panda (css fn)23.00 ms
tailwind-merge24.10 ms
cnfast24.50 ms
Panda (style props)28.20 ms
styled-components32.60 ms
Emotion34.30 ms
Goober327.10 ms

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.

JSCSSHTML
next-yak (css prop)5,268 B(0.0 / 0.1 / 5.0 kB)
vanilla (hand-written ceiling)5,502 B(0.0 / 0.3 / 5.0 kB)
next-yak 9.5.06,215 B(1.0 / 0.1 / 5.0 kB)
next-yak6,310 B(1.0 / 0.1 / 5.0 kB)
StyleX8,014 B(2.3 / 0.1 / 5.4 kB)
Panda (css fn)9,168 B(3.4 / 0.1 / 5.4 kB)
Panda (style props)13,365 B(7.5 / 0.1 / 5.4 kB)
tailwind-merge20,848 B(10.2 / 5.1 / 5.0 kB)
Goober20,981 B(3.3 / 8.9 / 8.3 kB)
cnfast21,819 B(11.2 / 5.1 / 5.0 kB)
styled-components35,147 B(12.8 / 13.5 / 8.0 kB)
Emotion39,034 B(12.4 / 17.9 / 7.8 kB)

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.

0.040801201601005001,0002,0004,000
cnfastEmotionGoobernext-yaknext-yak (css prop)next-yak 9.5.0Panda (css fn)Panda (style props)styled-componentsStyleXtailwind-mergevanilla (hand-written ceiling)