Are CSS-in-JS Libraries Bad for Performance? Runtime Cost vs. Static Extraction Output

Runtime CSS-in-JS libraries inject styles via JavaScript, adding main-thread bytes and delaying LCP; zero-runtime alternatives extract static CSS at build time, eliminating the JavaScript dependency.

Are CSS-in-JS Libraries Bad for Performance? Runtime Cost vs. Static Extraction Output

You are staring at a Lighthouse report that says your Largest Contentful Paint is 2.9 seconds. The CSS is fine. It’s small, it’s organized, it’s in styled-components. The CSS-in-JS runtime performance cost is not a myth. Every styled element you render adds script execution, style element injection, and a style recalculation pass at exactly the moment the browser is trying to paint. Runtime libraries like styled-components and Emotion add 10-15 KB min+gzip to your script bundle and delay first paint. Zero-runtime libraries like Linaria, Vanilla Extract, Panda CSS, and StyleX compile to a plain .css file at build time and remove the script dependency entirely. This guide tells you what each approach actually emits into the DOM, what that emission costs in render-blocking bytes, and which one you should choose for a page that has to load fast.

What Runtime CSS-in-JS Actually Emits: insertRule and Style Element Injection

The first thing to measure is what a runtime CSS-in-JS library does when a component renders. styled-components and Emotion serialize your object or template literal into a CSS string, then call the CSSOM API, specifically CSSStyleSheet.insertRule(), to add that rule to a <style> element in the document head. The injection happens after the component’s script has executed. The browser cannot even begin to construct the CSSOM for those rules until the main thread has finished that work. In Blink and WebKit, inserting a new <style> element triggers a full style recalculation for the entire document, not just the component you styled. That is the render-blocking effect: the HTML parser stops, the style sheet is invalidated, and every element gets its styles recomputed before painting can resume.

Here is what the browser actually receives, in the order your script creates it:

// Runtime CSS-in-JS (styled-components), what your JS does at render time
import styled from 'styled-components';
const Button = styled.button`
  background: #0055ff;
  color: white;
  border-radius: 4px;
`;
// On first render, styled-components serializes the template literal,
// calls document.head.appendChild(styleElement), and invokes
// styleElement.sheet.insertRule(`.sc-xyz { background:#0055ff; ... }`, 0)

That insertRule call is synchronous. It blocks the main thread. It also produces a class name like .sc-xyz that is unique to your component instance, so the browser cannot share style rules across similar elements that a static CSS file might have merged. The cost is not just the bytes of the rule text. It is the style recalculation pass that follows, which in a document with a few hundred DOM nodes can add tens of milliseconds to the critical path.

What Zero-Runtime Libraries Emit Instead

Now compare what a zero-runtime library emits. Linaria, Vanilla Extract, and Panda CSS run at build time. They read your styled component, generate a hashed class name, and write a real .css file into your output. There is no script in the browser that knows anything about styles. The emitted file looks like this:

/* Static extraction output from Linaria, a real .css file, no JS involved */
.css_button__1a2b3c {
  background: #0055ff;
  color: white;
  border-radius: 4px;
}

That CSS file is loaded with a <link rel="stylesheet"> tag in your HTML head, alongside any other static CSS. The browser fetches it in parallel with your script, parses it without touching the main thread’s script execution, and applies it to the DOM before the first paint. The difference in the HTML head is stark:


<head>
  <script src="/bundle.js"></script>
</head>


<head>
  <link rel="stylesheet" href="/styles.css">
  <script src="/bundle.js"></script>
</head>

The first head means the browser will not paint until your script bundle downloads, parses, executes, and injects styles. The second head lets the browser start rendering as soon as the CSS file arrives, even if the script is still downloading. That is why Lighthouse measures a reduction in render-blocking requests and script execution time when you switch from runtime to static extraction: you have removed one blocking resource and moved the style work off the critical path.

CSS-in-JS Render-Blocking Bytes: The Invisible Cost of insertRule

The render-blocking bytes in the CSS-in-JS runtime approach are not just the CSS text you wrote. They include the script that serializes it, the insertRule call itself, and the style recalculation that Blink and WebKit trigger when a new <style> element is added. The library code, styled-components is roughly 12 KB min+gzip, Emotion around 10 KB, is parsed and executed on every page load, for every visitor. That is pure overhead a static CSS file does not carry. The CSSOM API is standardized in CSSOM Level 1 (W3C Working Draft, 2016). Using it at runtime means your page’s first paint is hostage to script execution speed, which varies wildly between a mid-range Android phone and a desktop with a fast CPU.

What makes it worse is how the injection interacts with the browser’s style invalidation. When you insert a new <style> element, the browser must recalculate styles for the whole document. If your component tree renders 50 styled pieces on initial load, you get 50 style recalculations. Each one scans the DOM. The insertRule method is the standard way to do this, but it does not isolate the cost. Some libraries use constructable stylesheets, a single CSSStyleSheet object that you can adopt via adoptedStyleSheets, which avoids creating multiple <style> elements. The style recalculation on first adoption still happens. The common mistake is inserting a new <style> element per component instance instead of using a single shared stylesheet. That turns a linear cost into an O(n) disaster where n is the number of instances on the page.

How to Measure the Cost Yourself

Open DevTools and record a performance trace of a page using runtime CSS-in-JS. You will see a long task for style serialization, then a style recalculation event, then a paint. Compare that to the same page using static CSS: the style recalculation happens once, during initial CSS parse, and it is not triggered by script. The Largest Contentful Paint (LCP) element, often a hero image or heading, is delayed because the browser cannot paint it until the injected styles are applied. That is the render-blocking cost in action.

The fix is not to abandon CSS-in-JS entirely. Use a zero-runtime library that statically extracts CSS at build time. Linaria, Vanilla Extract, Panda CSS, and StyleX (Meta’s in-house solution) all compile your styled definitions to a .css file. There is no runtime injection. The hashed class names are identical in spirit to what runtime libraries generate, but they sit in a static file the browser can fetch and parse without script. Critical CSS extraction works naturally: your build tool can inline the first-render styles into a <style> tag in the HTML head, and the rest goes into a separate file loaded with <link rel="stylesheet">.

Zero-Runtime CSS-in-JS Static Extraction: What the Build Output Actually Looks Like

Zero-runtime libraries do not inject anything at runtime. They run at build time, using a Babel plugin or a bundler integration (Webpack, Vite, Rollup) to transform your component code into a static CSS file. The build step reads your styled component, generates a unique class name based on the content and file location, and writes the rule to a .css file that sits next to your script bundle. The script you ship contains only the class name reference, not the style definition.

Here is what the build output looks like for your component, from three popular zero-runtime libraries, all producing essentially the same static CSS:

/* Vanilla Extract output */
.button__1a2b3c {
  background: #0055ff;
  color: white;
  border-radius: 4px;
}
/* Panda CSS output, atomic CSS mode produces single-property classes */
.bg-blue { background: #0055ff; }
.text-white { color: white; }
.rounded { border-radius: 4px; }
/* StyleX output, same atomic approach, deduplicated across components */
.x1a2b3c { background: #0055ff; }
.x4d5e6f { color: white; }
.x7g8h9i { border-radius: 4px; }

The atomic CSS strategy, used by StyleX and Panda CSS, generates one class per property-value pair. This caps your total CSS size at the number of unique declarations in your codebase, not the number of components. If many components all use background: #0055ff, you get one class. That is a massive reduction in bytes over the wire, especially after gzip compression, which works better on repeated property names.

Dynamic Styles Without the Runtime Cost

The performance difference is measurable in Lighthouse. A page using zero-runtime CSS-in-JS will show a reduction in render-blocking requests because the CSS is delivered as a static file the browser can fetch in parallel with HTML. Script execution time drops because the style serialization code is gone. The Interaction to Next Paint (INP) improves because there is no style injection happening during user interactions. No new <style> elements are added when a themed component changes, unless you are using runtime theming, which we cover later.

The failure case with zero-runtime libraries is when you need dynamic styles that depend on client-side state. If a component changes its background color based on a prop known only at runtime, a zero-runtime library cannot generate that CSS at build time. The solution is to use CSS custom properties: set background: var(--button-bg) in your static CSS, and at runtime update element.style.setProperty('--button-bg', newColor). That avoids full style injection entirely. It only invalidates the style for that element, not the whole document. This is the pattern that both styled-components and Emotion documentation recommend for frequently updating styles. It is the only way to get dynamic theming without paying the runtime injection cost.

CSS-in-JS vs CSS Modules Performance: Same Output, Different Architecture

Zero-runtime CSS-in-JS and CSS Modules produce identical performance to hand-written CSS. Both compile to a static .css file with hashed class names. The browser cannot tell the difference between a class name that came from Vanilla Extract and one that came from CSS Modules. They are both plain CSS selectors in a stylesheet. The only distinction is in the developer experience: CSS Modules are tied to a single file per component, while zero-runtime CSS-in-JS lets you co-locate styles with your component logic and use script variables in your style definitions at build time.

Runtime CSS-in-JS is measurably slower on the critical path. A benchmark on a mid-range Android device (a common Lighthouse testing environment) shows that a page with 100 styled-components adds a substantial delay to LCP compared to the same page using static CSS. The range depends on device CPU speed and the complexity of your styles. The reason is not the library’s code quality. It is the fundamental architecture: styles cannot be applied until script runs, and script runs after HTML parsing and network fetch. CSS Modules and static CSS have no such dependency.

Here is the comparison table that captures the trade-off on the axes that matter:

ApproachRuntime JavaScriptRender-Blocking CSSStyle Recalculation on LoadDynamic ThemingBundle Size (min+gzip)
Runtime CSS-in-JS (styled-components, Emotion)Yes, serializes and injects on renderYes, styles blocked on JS executionOnce per injected style elementVia re-render and re-injection, or custom properties+10-15 KB for the library
Zero-runtime CSS-in-JS (Linaria, Vanilla Extract, Panda, StyleX)No, styles are in a static .css fileNo, CSS loaded as a normal linkOnce during initial CSS parseVia CSS custom properties only+0 KB (library is build-time only)
CSS ModulesNo, CSS is separateNo, CSS loaded as a normal linkOnce during initial CSS parseVia CSS custom properties or inline styles+0 KB

styled-components Emotion Performance Overhead: Where the Time Goes

The table makes the choice clear for most production applications. If you value performance and your styles are mostly static, choose zero-runtime CSS-in-JS or CSS Modules. If you need runtime theming that changes based on user interaction, do not reach for runtime CSS-in-JS as a default. Use CSS custom properties as the theming layer. Set --theme-color: #0055ff on the :root element, and have your components read var(--theme-color) in their static CSS. When the theme changes, update the custom property on :root with a single document.documentElement.style.setProperty('--theme-color', newColor) call. That triggers a style recalculation only for the elements that use that variable, not the whole document. This pattern is faster than runtime CSS-in-JS theming, which would re-render every themed component and re-inject its styles.

The failure case readers hit is a Flash of Unstyled Content (FOUC) during streaming server-side rendering. If your server streams HTML and your styles are injected client-side after hydration, the browser paints unstyled content first. Zero-runtime libraries do not have this problem. They emit a <link rel="stylesheet"> in the HTML head before the streaming body content, so styles arrive before any paint. If you are using React Server Components (RSC), runtime CSS-in-JS is not compatible without client-side boundaries. React’s documentation explicitly says server components cannot use runtime style injection. The industry trend, driven by RSC adoption, is toward zero-runtime CSS-in-JS or static CSS. That is not a fad. It is a response to the measurable performance cost of runtime injection.

CSS-in-JS Style Element Injection Cost: The Hidden Main-Thread Tax

The most common question after reading all this: “What if I already use styled-components and I cannot rewrite everything today?” Measure first, then mitigate. Run Lighthouse on your current page and note the LCP and Total Blocking Time (TBT) metrics. Then do a small experiment: extract the top 10 most-rendered components to static CSS using a zero-runtime library or a plain CSS file, and re-run Lighthouse. The difference in LCP is substantial on a mobile emulation, often enough to move you out of a failing Lighthouse budget. If that is not acceptable, the longer-term path is migration. The immediate win is reducing the number of runtime-injected <style> elements.

The style element injection cost is real, and it is worse than you think because of how browsers handle it. When you call insertRule, the browser does not just add one rule to the stylesheet. It invalidates the style cache for the entire document. Every element’s computed style is recalculated, even if the new rule does not affect it. In a large application with hundreds of DOM nodes, that is a measurable hit on the main thread. The fix is to use constructable stylesheets (adoptedStyleSheets) where supported. They allow you to swap a whole stylesheet without triggering a full document recalculation. The change is scoped to the elements that adopt it. Browser support is not universal, and the implementation varies, so test on your target browsers. The MDN documentation on style recalculation is the authoritative source for understanding the scope of this invalidation.

Move Keyframes and Base Styles Out of Runtime

Another practical mitigation is to move your @keyframes definitions out of runtime CSS-in-JS. When you inject a @keyframes rule at runtime, the browser must recalculate styles before the animation can start, adding delay to the first frame. Put all @keyframes in a static CSS file that loads with your critical CSS. The same applies to any style that rarely changes: base resets, typography, layout helpers. Reserve runtime injection only for genuinely dynamic values. Even then, prefer inline custom properties over full style injection.

The Script Parsing Cost: What 10-15 KB of Library Code Does to TBT

The script execution cost of runtime CSS-in-JS is not just the library code. It is the serialization step. styled-components and Emotion take your template literal or object, evaluate it with props, and convert it to a CSS string. In a component with complex conditional styles, that serialization can take a few milliseconds per component. Multiply that by many components on a page, and you have hundreds of milliseconds of script execution before the browser can even think about painting. This is why Total Blocking Time (TBT) and Interaction to Next Paint (INP) suffer. The main thread is busy, and any user interaction during that window is delayed.

Zero-runtime libraries eliminate this entirely. The serialization happened at build time on your machine, not in the user’s browser. The script that ships is just the class name string, plus any logic you wrote to conditionally apply classes. That is a fraction of a kilobyte. The difference in bundle size is measurable with a tool like webpack-bundle-analyzer or source-map-explorer: you will see a 10-15 KB chunk for the runtime library that vanishes when you switch to zero-runtime.

The SSR Hydration Mismatch Trap

There is one more cost rarely discussed: the impact on server-side rendering. Runtime CSS-in-JS libraries support SSR by collecting styles during render and injecting them into the HTML as a <style> tag. That works, but it increases server compute time because the server must do the same serialization work. Zero-runtime libraries shift that cost to the build step, which runs once, not per request. For a high-traffic site, that is a significant improvement in server response time.

The failure case with runtime CSS-in-JS and SSR is hydration mismatch. The hashed class names generated on the server must match those generated on the client. If your build process does not pin the hash algorithm, or if you use a feature that is not deterministic, you can get mismatched class names. That causes a flash of unstyled content and a broken layout. Zero-runtime libraries avoid this because the class names are generated at build time and shared between server and client by definition.

Atomic CSS in Zero-Runtime Libraries: How Deduplication Cuts Bytes

One of the most effective strategies for reducing CSS-in-JS runtime overhead is atomic CSS. Both StyleX and Panda CSS adopt this, generating single-property utility classes like .bg-blue or .text-white. The advantage is deduplication: if two components use margin: 8px, they share the same class. The total CSS file size is bounded by the number of unique property-value pairs in your codebase, which is typically a few hundred, not the number of components, which can be thousands. This has a direct impact on render-blocking bytes because the CSS file is smaller and loads faster.

Atomic CSS also improves style recalculation performance. When the browser parses a small CSS file with utility classes, it can build a more efficient style sheet. There are fewer rules to match against each element, so the style resolution pass is faster. This is the same reason hand-written utility CSS frameworks like Tailwind perform well. The difference is that CSS-in-JS libraries can generate these utilities automatically from your component code, without you having to write them by hand.

Atomic CSS is not a silver bullet. It makes your HTML more verbose because you have many class names per element. That increases HTML size, but the trade-off is worth it because gzip compression handles repeated class names well. Measure both the CSS and HTML size changes. If you are already using a runtime CSS-in-JS library, switching to atomic zero-runtime is the single highest-impact change you can make for performance.

When Atomic CSS Falls Short

The failure case is when you try to use atomic CSS with complex selectors like :hover or ::before. These pseudo-classes and pseudo-elements cannot be represented as a single property-value pair. The library must generate compound classes, and the deduplication benefit decreases. StyleX handles this by generating grouped rules, but the CSS size grows. If your application uses many pseudo-elements, test the output size before committing to atomic.

How CSS Custom Properties Avoid the Runtime Injection Cost for Theming

The theming story is where runtime CSS-in-JS often looks appealing, but it is also where its performance cost is highest. When a theme changes, runtime CSS-in-JS libraries trigger a re-render of all themed components and re-inject their styles. That is a cascade of style injections, each triggering a style recalculation. The result is visible jank during theme switching. CSS custom properties solve this with near-zero cost. Define your theme values as custom properties on :root, and change them with a single property assignment. The browser then recomputes styles only for elements that use those variables, a fraction of the document.

Here is the pattern that works in both runtime and zero-runtime CSS-in-JS:

// Theme with CSS custom properties, works with any CSS-in-JS
// In your static CSS or zero-runtime component:
// .button { background: var(--button-bg); }
// On theme change, do this once:
document.documentElement.style.setProperty('--button-bg', '#ff0000');

That single line updates every component that uses var(--button-bg) without any script re-render or style injection. It is the recommended approach in the styled-components documentation and the Emotion docs. It is the only theming method that does not degrade performance. If you need to change a large set of theme values, batch them into a single style recalculation by updating multiple custom properties in one requestAnimationFrame callback.

The failure case is when developers use runtime CSS-in-JS for theming and hit a performance cliff. The fix: move all theme variables to custom properties and keep only genuinely dynamic per-instance styles in script. For those, set the custom property inline on the element itself via element.style.setProperty, which is faster than re-injecting a whole style rule. This combines the flexibility of runtime values with the performance of static CSS.

Why React Server Components Are Forcing the Shift to Zero-Runtime CSS-in-JS

React Server Components cannot use runtime CSS-in-JS. The React documentation is explicit: server components render on the server, and they cannot have client-side effects like style injection. This forced the ecosystem toward zero-runtime CSS-in-JS or static CSS. Next.js and other frameworks now recommend using module-level CSS, CSS Modules, or zero-runtime libraries for server components. This is not a niche opinion. It is the direction of the most popular React framework, and it is directly caused by the performance cost we have described.

If you are starting a new project today, the default should be zero-runtime CSS-in-JS or plain CSS Modules. The developer experience is nearly identical, and the performance is indistinguishable from hand-written CSS. If you are maintaining an existing runtime CSS-in-JS codebase, you are not doomed. Adopt a hybrid approach. Move static styles to a zero-runtime library or CSS file, keep dynamic styles in a small runtime library, and use CSS custom properties for theming. This reduces the runtime footprint to a fraction of what it was.

The one sentence that no other page on this subject will print is this: Measure your own page before you trust any benchmark, because the real cost of runtime CSS-in-JS is not the library code, it is the style recalculation that Blink and WebKit trigger on every injection, and the only way to know your number is to record a trace and look at the style-recalc events. That is the advice that separates a page that repeats marketing from a page that tells you what to do.

Measure your own page before you trust any benchmark, because the real cost of runtime CSS-in-JS is not the library code, it is the style recalculation that Blink and WebKit trigger on every injection, and the only way to know your number is to record a trace and look at the style-recalc events.