Your React app ships a JavaScript runtime that parses, generates, and injects every single CSS rule on every page load. That is the hidden cost of CSS-in-JS, and it is exactly what GitHub decided was no longer acceptable at scale. If your SaaS product relies on styled-components, Emotion, or a similar library, this article explains what a CSS-in-js performance migration actually entails, when it is worth doing, and how to pull it off without burning weeks of developer time.
Why CSS-in-JS Becomes a Liability at Scale
CSS-in-JS libraries like styled-components and Emotion solved a real problem: colocating styles with components eliminates dead CSS, prevents naming collisions, and gives developers dynamic theming out of the box. For a small marketing site or an early-stage MVP, the tradeoff is entirely reasonable.
The cost surfaces when your application grows. Consider a SaaS dashboard with 300 components. Every component that imports a CSS-in-JS library carries that library's runtime into your JavaScript bundle. The browser must download, parse, and execute that JavaScript before a single style is applied. On a typical mid-size SaaS app, that runtime alone can add 40-80 KB (gzipped) to your bundle, before a single component style is computed.
The Hydration Tax
The problem compounds during server-side rendering (SSR). With CSS-in-JS, the server generates HTML and a corresponding <style> tag. The client then re-hydrates those styles during React's hydration phase. If the styles generated on the server do not perfectly match those generated on the client (a mismatch caused by timezone differences, random values, or conditional rendering), the browser repaints entire sections, a visual flash that degrades perceived performance.
Static CSS files do not have this problem. The browser loads them once, caches them, and applies them immediately during HTML parsing, no JavaScript execution required.
A Concrete Example: Before and After a Migration
To illustrate the impact, here is a hypothetical SaaS dashboard component written with styled-components and its equivalent using plain CSS modules:
Before, CSS-in-JS with styled-components:
// DashboardCard.tsx, styled-components
import styled from 'styled-components';
const Card = styled.div<{ $variant: 'default' | 'highlight' }>`
background: ${props => props.$variant === 'highlight' ? '#FFF3E0' : '#FFFFFF'};
border: 1px solid #E0E0E0;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
`;
const Title = styled.h3`
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: #212121;
`;
const Value = styled.span`
font-size: 32px;
font-weight: 700;
color: #1565C0;
`;
export function DashboardCard({
title,
value,
variant = 'default'
}: {
title: string;
value: string;
variant?: 'default' | 'highlight';
}) {
return (
<Card $variant={variant}>
<Title>{title}</Title>
<Value>{value}</Value>
</Card>
);
}
After, CSS Modules:
// DashboardCard.tsx, CSS Modules
import styles from './DashboardCard.module.css';
export function DashboardCard({
title,
value,
variant = 'default'
}: {
title: string;
value: string;
variant?: 'default' | 'highlight';
}) {
return (
<div className={`${styles.card} ${variant === 'highlight' ? styles.highlight : ''}`}>
<h3 className={styles.title}>{title}</h3>
<span className={styles.value}>{value}</span>
</div>
);
}
/* DashboardCard.module.css */
.card {
background: #FFFFFF;
border: 1px solid #E0E0E0;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.card.highlight {
background: #FFF3E0;
}
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: #212121;
}
.value {
font-size: 32px;
font-weight: 700;
color: #1565C0;
}
The functional result is identical. But the bundle implications are not. In this hypothetical scenario:
- styled-components runtime: ~12 KB gzipped, added once to the JS bundle, plus ~1.2 KB of generated runtime style code per component.
- CSS Modules: 0 KB added to the JS bundle. The
.module.cssfile is extracted into a static CSS file at build time. CSS loading happens in parallel with JavaScript and does not block the main thread.
For a dashboard with 300 similar components, that runtime style code compounds. The CSS-in-JS version generates roughly 360 KB of JavaScript just for style computation at runtime. The CSS Modules version produces a static stylesheet that the browser can cache independently.
GitHub's Migration: The Signal, Not the Recipe
GitHub announced its full migration away from CSS-in-JS on github.com. While the detailed numbers are in their engineering blog post, the core thesis is clear: at the scale of one of the world's most visited websites, the per-request cost of computing CSS in JavaScript was no longer justifiable compared to shipping static CSS files.
This is not an argument that CSS-in-JS is universally bad. It is an argument that the performance characteristics of CSS-in-JS degrade with scale, while static CSS improves. Static CSS files benefit from browser caching, parallel loading, and zero main-thread cost during page transitions, advantages that compound with every additional page view your users generate.
Why This Matters for SaaS Specifically
SaaS applications have a usage pattern that makes the CSS-in-JS cost particularly painful:
- High session depth. Users navigate dozens of views per session. Each view triggers JavaScript-driven style computation with CSS-in-JS. Static CSS is loaded once and cached.
- Complex UIs with hundreds of components. More components means more runtime style objects to create and manage in memory.
- Server-side rendering is common. SaaS dashboards increasingly use SSR or static generation for first-paint speed. CSS-in-JS hydration mismatches undermine those gains.
- Mobile and low-end device users. JavaScript execution is the primary bottleneck on slower devices. Offloading CSS parsing to the browser's native CSS engine, which runs on a separate thread in modern browsers, frees the main thread for interactivity.
Decision Framework: Should You Migrate?
Not every project needs a CSS-in-JS performance migration. Use this framework to decide:
If your SaaS product falls into the right column on three or more rows, a migration likely pays for itself in reduced bounce rates and improved user experience.
Step-by-Step Migration Playbook
Here is how to execute a CSS-in-JS performance migration without breaking your product:
Step 1: Audit Your CSS-in-JS Usage
Run a dependency analysis to find every file that imports your CSS-in-JS library. In a typical React project:
# Find all styled-components imports
grep -r "from 'styled-components'" src/ --include="*.tsx" --include="*.ts" | wc -l
# Find all Emotion imports
grep -r "from '@emotion/styled'" src/ --include="*.tsx" --include="*.ts" | wc -l
Document the count, the number of dynamic style props (those using JavaScript variables), and the percentage of styles that are truly dynamic versus static.
Step 2: Categorize Components
Sort components into three buckets:
- Static styles (no dynamic props): These are the easiest to migrate. They become plain CSS modules with zero runtime cost.
- Conditional styles (variant-based, like the
$variantprop above): These map to CSS class toggling. Still straightforward. - Truly dynamic styles (user-defined colors, computed dimensions): These are the hard cases. You have three options: CSS custom properties, inline styles for the dynamic fragment only, or a lightweight CSS-in-JS library like Vanilla Extract that compiles to static CSS.
Step 3: Migrate Leaf Components First
Start with components at the bottom of the component tree, buttons, inputs, cards, badges. These are used everywhere but rarely have dynamic styling needs. Converting them delivers the widest impact with the lowest risk.
Step 4: Extract Layout Components
Move to layout components, grids, sidebars, containers. These are typically pure static CSS. By this point, a significant portion of your runtime CSS-in-JS overhead is eliminated.
Step 5: Handle the Remainder Strategically
For components that genuinely need runtime style computation, evaluate whether a hybrid approach works: static CSS for the base styles, with inline styles or CSS custom properties for the dynamic fragments only. This lets you keep the benefits of both approaches without maintaining two full systems.
Step 6: Remove the Runtime
Once no component imports the CSS-in-JS library, remove it from your package.json and verify your bundle size drops. This is the moment the migration pays off, the entire runtime disappears from every page load.
Best Practices for a Clean Migration
-
Measure before and after. Use
webpack-bundle-analyzeror your bundler's built-in analysis to record your current JS bundle size, the size of the CSS-in-JS runtime, and your Lighthouse performance scores. You need these numbers to justify the migration effort to stakeholders. -
Migrate route by route, not all at once. CSS-in-JS and static CSS can coexist during a gradual migration. Ship one route's conversion per sprint, verify it in production, and move on. This avoids the "big bang" risk that kills migration projects.
-
Automate style consistency checks. Add visual regression tests (with tools like Percy or Playwright's screenshot comparison) to catch subtle differences between CSS-in-JS-generated styles and your new static CSS. A 1-pixel padding difference in a dashboard card is the kind of bug that erodes user trust silently.
-
Watch out for specificity wars. CSS-in-JS generates unique class names that avoid specificity conflicts. When you switch to CSS modules or utility classes, you may encounter existing global styles fighting your new rules. Audit your global CSS early.
-
Budget for the effort realistically. A hypothetical SaaS dashboard with 300 components might take two developers four to six weeks to fully migrate, depending on how many components use dynamic styling. That investment is recovered over time through lower infrastructure costs and better user retention, but only if the product has enough traffic for performance improvements to move business metrics. If you are evaluating whether this kind of optimization fits your current roadmap, working with a team that has done it before can compress the timeline significantly. ProjectMakers handles frontend architecture migrations as part of our custom software development work, so your team can stay focused on features.
The Bigger Picture: Technical Debt in Frontend Architecture
CSS-in-JS is one example of a pattern that repeats across frontend development: a library that solves real problems at small scale creates compounding costs at large scale. The same logic applies to state management libraries, form libraries, and data-fetching layers. Every runtime dependency you add is a decision with a cost curve that changes as your application grows.
GitHub's decision to migrate away from CSS-in-JS is not a signal that the technology is dead. It is a signal that mature products optimize for different constraints than new projects do. The right question is not "Is CSS-in-JS good or bad?" but "At what point does the runtime cost outweigh the developer experience benefit for my specific product?"
For many SaaS applications, that crossover point arrives sooner than teams expect, typically when the component count exceeds 100, the user base reaches four figures of daily active users, and SSR enters the architecture for first-paint optimization.
If your team is evaluating frontend architecture choices for a new SaaS product, or considering a performance migration for an existing one, discussing the tradeoffs with an experienced development partner can help you avoid the detours that make these projects take twice as long as they should. The right architecture decision now saves you a migration project later.
