Shipping More CSS Made GitHub 55% Faster: The CSS-in-JS Migration Playbook
GitHub spent three years deleting styled-components from github.com and ended up shipping more CSS than it started with. Server-side rendering got 55% faster. C...
GitHub spent three years deleting styled-components from github.com and ended up shipping more CSS than it started with. Server-side rendering got 55% faster. Component initialization got 25% faster. In June 2026 the site reached 100% CSS Modules, with styled-components, styled-system, and the sx prop fully removed from dotcom. The headline sounds backwards until you measure where the time actually goes: runtime style generation taxes every render on both ends of the wire, and a larger static payload costs less than a smaller dynamic one.
This is a migration playbook distilled from the Primer team's public engineering writeup. Every number below is theirs: 55% SSR reduction and 25% faster initialization after the Primer migration (December 2024), 7,760 sx props at peak, 6,419 migrated by eight engineers over six months with 1–22% page-level SSR gains, and the final 895 props cleared in three weeks by two engineers driving Copilot coding agents. The mechanics transfer to any large React codebase; the decision to copy them depends on measurements this article will help you take.
The runtime bill: style generation on every render, on both ends of the wire
A styled.div is not a div with a class name. It is a component that evaluates a tagged template, hashes a class name, and inserts or updates a rule in a live stylesheet during render. With a theme in play, each styled element historically pulled in two extra context consumers — one for the theme, one for the stylesheet instance — plus the wrapper component itself. A 2019 teardown measured a trivial 50-element list rendering 56.6% slower in development and roughly twice as slow in production versus plain div elements. Later versions added static-component bailouts, but the bailout only helps styles with zero dynamic inputs, which is rarely the whole codebase.
On the server the tax takes a specific shape. styled-components SSR requires collecting generated styles during the render pass, serializing the critical CSS, and injecting it into the HTML response:
import { ServerStyleSheet } from 'styled-components';
// Before: every SSR pass pays the collection tax
export function renderPage(element: React.ReactElement) {
const sheet = new ServerStyleSheet();
try {
const html = renderToString(sheet.collectStyles(element));
const styleTags = sheet.getStyleTags(); // serialized critical CSS
return `<html><head>${styleTags}</head><body>${html}</body></html>`;
} finally {
sheet.seal();
}
}
Collection cost scales with component count, which is exactly the wrong scaling law for a site whose pages render hundreds of Primer components. Note the library did not stand still — v6 added React Server Component support with inline style injection, per-render dedup via React.cache, and :where() wrapping for extended components, including Next.js App Router compatibility. That ongoing investment is precisely the signal GitHub read correctly: the runtime was getting better, and it was still the bottleneck.
The client pays the same tax a second time. Hydration re-runs style generation and re-injects rules, and each rule inserted into a live stylesheet gives the browser another opportunity to recalculate styles. Independent benchmarks confirm the mechanism: runtime libraries add roughly one style rule per component instance, so the engine recalculates styles once per instance instead of once for the page, while build-time approaches inject nothing and show near-zero styling cost in hydration profiles. A simulator rendering 1,000 nodes puts numbers on it: ~10ms total for plain CSS or CSS Modules versus ~35ms for Emotion and ~42ms for styled-components, with rule injection as the dominant extra segment.
The reusable asymmetry: colocating styles with components is a developer-experience win, but generating those styles at render time converts a build-time constant into a per-render, per-visitor variable. At GitHub's scale the variable dominated.
Why CSS Modules won over Tailwind and zero-runtime extraction
GitHub evaluated the obvious alternatives implicitly by picking the one with the smallest conceptual distance from its existing code. CSS Modules keep the component-owns-its-styles model: a .module.css file sits next to the component, class names are locally scoped by default, and the build emits static stylesheets. No new utility vocabulary to teach, no Babel-time evaluation engine to feed, no runtime whatsoever. The migration is a file-by-file translation, not a re-architecture of how anyone thinks about styling.
Theming decided the rest. Primer's design tokens already existed as CSS custom properties in @primer/css, so theme switching never needed JavaScript in the first place. Seven themes, each with a high-contrast variant, moved to attribute-driven custom properties:
/* One token layer, fourteen theme variants, zero JS in the switch */
:root,
[data-theme="light"] {
--fgColor-default: #1f2328;
--bgColor-default: #ffffff;
}
[data-theme="dark"] {
--fgColor-default: #f0f6fc;
--bgColor-default: #0d1117;
}
[data-theme="dark"][data-high-contrast="true"] {
--fgColor-default: #ffffff;
--bgColor-default: #000000;
}
/* Theme-dependent component styles compile once and resolve per instance */
.primer-btn {
color: var(--fgColor-default);
background: var(--bgColor-default);
}
Styled-components entered maintenance mode around the same window, so the migration also removed an abandoned dependency from the critical path rather than adopting a new one. Tailwind lost on payload grounds. GitHub's critics have counted 41 CSS requests and roughly 2.1MB over the wire on github.com, including about 400KB of Tailwind, and proposed a single 80KB file instead. The counter-argument is caching: static stylesheets are immutable, content-hashed, and cacheable per chunk, so repeated navigations transfer far less than the cold-load number implies. A cold-load byte count and a repeat-visit byte count are different metrics, and only one of them describes a returning user.
Still, the direction of travel was the wrong direction. Shipping more CSS without question means accepting a heavier render-blocking payload, and the standard mitigation applies: inline the above-the-fold critical subset, keep it under roughly 14KB so it fits a TCP slow-start window, and load the rest asynchronously with a <noscript> fallback. What GitHub's migration bought was not fewer bytes. It was moving every byte onto a path the browser can parse before JavaScript runs.
Component-by-component rollout: the flag is the migration unit
The rollout pattern is the most transferable part of the whole effort, and it is a loop run once per component rather than a single cutover. Each cycle looks like this:
- Write a CSS Modules stylesheet translating the component's existing styles.
- Gate the new implementation behind a feature flag that toggles old versus new rendering.
- Run visual regression tests and require identical snapshots across both paths.
- Widen the flag in stages: Primer team, then all GitHub staff, then all users.
- Leave the CSS-in-JS path working for code that has not migrated yet.
Three properties of that loop matter more than the steps. First, the flag is the unit of rollback — a bad component ships disabled instead of reverting a release. Second, the old and new implementations coexist indefinitely, so no team is blocked on another team's migration schedule. Third, every widening stage produces real production data before the next widening, which is how the team observed page-level SSR improvements ranging from 1% to 22% instead of trusting an aggregate.
The dependency removal itself was flagged too. By the time styled-components was ready to leave package.json, seven themes had to be decoupled first, and that phase alone took roughly two months with several issues along the way. Feature-flagging an uninstall sounds paranoid until you remember the failure mode it prevents: a single missed import path taking down the site's styling with no toggle to reach for.
The compatibility shim that let 8,000 call sites migrate on their own schedule
Here is the move most teams skip and later regret. The real obstacle was not styled-components itself — it was the sx prop, Primer's mechanism for inline-object customization. Teams had used it to tweak components for years, and it worked: strong TypeScript support tied directly to design tokens, styles colocated with the call site. It also had a high runtime cost from dynamic inline objects and scaled badly as more components on a page leaned on it.
Rip sx out and you break thousands of call sites across hundreds of teams at once. GitHub instead built an intermediate package, @primer/styled-react, that let already-migrated components keep accepting sx. The compatibility layer decoupled the two migrations:
// Slow path: legacy sx customization keeps working on migrated components
import { Button } from '@primer/styled-react';
<Button sx={{ mb: 3, color: 'fg.muted' }}>Cancel</Button>
// Fast path: direct import, no runtime styling, full perf gains
import { Button } from '@primer/react';
<Button className={styles.spacedButton}>Cancel</Button>
Code importing through the wrapper behaved exactly as before. Code importing directly from @primer/react picked up the performance gains immediately, with no flag and no coordination meeting. The strangler-fig pattern does the heavy lifting: application code migrated on its own schedule while the design system team kept shipping, and the wrapper's import swap at the end (@primer/styled-react to @primer/react) became a mechanical, codemodable edit.
Killing 7,760 props: codemods first, agents last
The sx cleanup numbers tell a story about automation maturity. From a peak of roughly 7,760 usages starting April 2025, a rotation of eight engineers migrated 6,419 props in six months, armed with an internal codemod for whole-file rewrites and a VS Code plugin built by Ian Sanders for prop-by-prop conversion with manual oversight. Mostly automated, validation carefully manual. Then the work paused, Copilot coding agents matured, and in April 2026 a two-person team cleared the remaining 895 props in three weeks. Same funnel, stronger tooling at the tail.
A typical prop-to-module conversion is mechanical enough to codify:
// Before: dynamic inline object, resolved at render time
<Stack sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
// After: static class, resolved at build time
import styles from './Layout.module.css';
<div className={styles.stack}>
The decision gate for your own long tail: automate whatever is mechanical (codemod for whole files), build tooling for the semi-mechanical (editor plugin for assisted conversion), and point agents only at the residue small enough to validate exhaustively. All three phases required human review, which is how the effort finished without breaking the site.
When to copy this migration — and when to stay put
Copy the decision under three conditions. Your React UI renders hundreds of styled components per page, so per-instance runtime cost aggregates instead of rounding away. Your SSR path serializes critical CSS per request, which makes server CPU a direct function of component count and shows up in SSR latency dashboards. And your design system already expresses tokens as CSS custom properties or can do so without a rewrite — which removes the one CSS-in-JS feature, dynamic theming, that static files cannot match without discipline.
Stay put when the inverse holds. Two counter-scenarios are honest. Small-to-medium apps with dozens of styled components per page will measure the runtime tax in single-digit milliseconds, well below INP's 200ms threshold — the migration cost dwarfs the gain. And heavily dynamic styling (per-user theming beyond variables, runtime-computed layouts, animation systems generating keyframes on the fly) genuinely needs a runtime; trading a real engine for a static file is not a win, it is a feature cut.
The cost side deserves its own paragraph because advocates keep undercounting it. GitHub spent three years, staffed a rotation of eight engineers for half a year, needed a dedicated VS Code plugin plus a codemod plus a compatibility package, and only finished after waiting for agents to mature enough to clear the tail. The endpoint is also not the end of the work: a static stylesheet that nobody prunes re-accumulates dead rules until byte bloat recreates the pressure the migration relieved. Budget for CSS auditing as a recurring line item, not a one-time cleanup.
The bankable frame: a styled.* call is an unowned runtime dependency injected into every render path, server and client. If your pages render hundreds of instances and your SSR collects critical CSS per request, delete the runtime and take the extra bytes — browsers parse static CSS faster than your framework generates dynamic CSS. If your pages render dozens, the runtime is cheaper than the migration. Measure first; migrate behind flags; ship the static file.