All ArticlesComponents•October 31, 2026•12 min read

10,000-Component Enterprise Design System: Benchmarking CSS-in-JS, Tailwind, CSS Modules, and Vanilla Extract for Build Latency, Runtime Cost, and Bundle Bloat

We rebuilt a 10,000-component enterprise design system four times—once per styling strategy—and measured cold build latency, incremental rebuilds, runtime style insertion, and shipped bytes on identical hardware. Here is the reproducible harness, the numbers, and the per-layer decision matrix that survived contact with production.

Close-up of colorful programming code displayed on a computer monitor with a dark background.Photo by Nemuel Sereti on Pexels

At ~50 components, every styling strategy is fine. At ~500, the differences are measurable but survivable. At 10,000 components spread across a dozen product surfaces, your styling layer stops being a preference and becomes a distributed system with its own build graph, cache-invalidation semantics, memory ceiling, and runtime cost profile.

This article is the benchmark write-up we wish we'd had before committing an enterprise design system to a styling stack. It contains a reproducible harness, honest numbers for build latency, runtime insertion cost and shipped bytes, and the architectural reasoning that determines which strategy belongs at which layer of your system.

Three Nonlinear Effects You Only See at 10,000 Components

Most styling comparisons are run on a to-do app. That's misleading because the cost curves are not linear in component count:

  1. Build graph fan-out. A CSS-in-JS Babel transform runs per JSX file. Ten thousand files means ten thousand serial-ish AST passes on top of your existing TypeScript and SWC work. Vanilla Extract adds a compile pass but it is per-file, parallelizable, and cacheable.
  2. Style-ordering surface area. Every runtime CSS-in-JS engine inserts rules into the CSSOM in mount order. With 10k components you get tens of thousands of rules whose cascade order depends on render sequence—non-deterministic specificity bugs that are nearly impossible to reproduce.
  3. Tree-shaking failure modes. A single module-scope styled() call in a barrel file, or one dynamically constructed Tailwind class string, can pull an entire slice of your design system into every consumer's bundle. At 10k components the blast radius is enormous.

The Reference Harness

All figures below come from one harness, re-run four times with only the styling layer swapped. Treat them as directional and re-run on your own hardware—absolute numbers vary ±10–15%, but the ratios are stable.

DimensionConfiguration
Components10,000 (8,200 presentational, 1,400 compound, 400 layout)
Shared tokens1,240 design tokens (color, space, type, motion, elevation)
FrameworkReact 19.0, TypeScript 5.6
BundlerVite 5.4 (Rollup 4), esbuild transform
Node20.11.1, pnpm 9
CI runner4 vCPU / 16 GB, ubuntu-22.04
Consumers4 product apps, ~180 routes total

Each build measured: (a) cold cache, no node_modules/.vite, (b) warm incremental with one leaf component edited, (c) production CSS and JS output gzipped at level 9, (d) React 19 hydration on a throttled 4x CPU profile.

The Four Contenders, Implemented

Vanilla Extract (zero-runtime, build-time extraction)

ts
// Button.css.ts
import { recipe, type RecipeVariants } from '@vanilla-extract/recipes';
import { styleVariants } from '@vanilla-extract/css';
import { vars } from '../../tokens/tokens.css';

export const button = recipe({
  base: {
    display: 'inline-flex',
    alignItems: 'center',
    gap: vars.space.xs,
    borderRadius: vars.radius.md,
    fontFamily: vars.font.body,
    transition: `background-color ${vars.motion.fast} ease`,
    selectors: {
      '&:focus-visible': { outline: `2px solid ${vars.color.focusRing}`, outlineOffset: '2px' },
    },
  },
  variants: {
    size: {
      sm: { padding: `${vars.space.xs} ${vars.space.sm}`, fontSize: vars.font.size.sm },
      md: { padding: `${vars.space.sm} ${vars.space.md}`, fontSize: vars.font.size.md },
    },
    tone: {
      primary: { background: vars.color.accent, color: vars.color.onAccent },
      neutral: { background: vars.color.surface, color: vars.color.text },
    },
  },
  defaultVariants: { size: 'md', tone: 'primary' },
});

export type ButtonVariants = RecipeVariants<typeof button>;

CSS Modules (zero-runtime, scoped)

css
/* Button.module.css */
.base {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  border-radius: var(--radius-md);
  font-family: var(--font-body);
  transition: background-color var(--motion-fast) ease;
}
.base:focus-visible {
  outline: 2px solid var(--color-focus-ring);
  outline-offset: 2px;
}
.sizeSm { padding: var(--space-xs) var(--space-sm); font-size: var(--font-size-sm); }
.tonePrimary { background: var(--color-accent); color: var(--color-on-accent); }

Tailwind CSS v4 (atomic, scan-based)

tsx
// Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';

const button = cva(
  'inline-flex items-center gap-1 rounded-md font-body transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring',
  {
    variants: {
      size: { sm: 'px-1 py-0.5 text-sm', md: 'px-2 py-1 text-base' },
      tone: { primary: 'bg-accent text-on-accent', neutral: 'bg-surface text-fg' },
    },
    defaultVariants: { size: 'md', tone: 'primary' },
  },
);

export type ButtonProps = React.ComponentPropsWithoutRef<'button'> & VariantProps<typeof button>;

export function Button({ size, tone, className, ...rest }: ButtonProps) {
  return <button className={twMerge(button({ size, tone }), className)} {...rest} />;
}

Emotion / styled-components (runtime CSS-in-JS)

tsx
import styled from '@emotion/styled';
import { css } from '@emotion/react';

const Base = styled.button<{ tone: 'primary' | 'neutral' }>(({ theme, tone }) => css`
  display: inline-flex;
  align-items: center;
  gap: ${theme.space.xs};
  border-radius: ${theme.radius.md};
  background: ${tone === 'primary' ? theme.color.accent : theme.color.surface};
  color: ${tone === 'primary' ? theme.color.onAccent : theme.color.text};
  &:focus-visible { outline: 2px solid ${theme.color.focusRing}; outline-offset: 2px; }
`);

Build Latency: Where the Seconds Actually Go

StrategyCold buildIncremental (1 leaf)Peak Node RSS
Tailwind v4 (Oxide)148 s1.2 s3.1 GB
Tailwind v3 (JIT)162 s1.6 s3.6 GB
CSS Modules171 s0.9 s3.4 GB
Vanilla Extract214 s1.4 s4.2 GB
Emotion268 s2.6 s6.9 GB
styled-components281 s2.8 s7.3 GB

The headline is not the 133-second cold-build delta between styled-components and Tailwind v4—it's the memory ceiling. Both runtime CSS-in-JS engines pushed peak RSS past 7 GB because their Babel plugins walk every JSX file, and Babel retains a large AST working set. On a 16 GB runner you must set NODE_OPTIONS=--max-old-space-size=8192, and on 8 GB runners the build OOMs outright. That forces you onto larger CI runners, which is where the real cost appears.

Vanilla Extract's +66 s over CSS Modules is the price of its TypeScript-to-CSS evaluation pass. It's parallel-friendly, so a monorepo split across Nx or Turborepo tasks absorbs most of it:

ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';

export default defineConfig({
  plugins: [react(), vanillaExtractPlugin({ identifiers: ({ hash }) => `ds_${hash}` })],
  build: {
    cssCodeSplit: true, // ship per-route CSS instead of one 180 KB monolith
    target: 'es2022',
    rollupOptions: {
      output: { manualChunks: (id) => (id.includes('/design-system/') ? 'ds-core' : undefined) },
    },
  },
  optimizeDeps: { esbuildOptions: { target: 'es2022' } },
});

Runtime Cost: Insertion, Recalculation, and Hydration

Runtime CSS-in-JS defers work from build to browser. In a 1,800-node dashboard view:

StrategyStyle-related JS (gzip)Rules inserted at mountInsert phaseHydration delta
Tailwind v40 KB00 msbaseline
CSS Modules0 KB00 ms+0 ms
Vanilla Extract3.1 KB00 ms+0 ms
Emotion12.4 KB2,41038 ms+61 ms
styled-components14.1 KB2,41052 ms+88 ms

38 ms is not catastrophic on a desktop; on a mid-tier Android device it's 4–6x that, and it lands squarely inside your Interaction to Next Paint (INP) budget. Worse, with React 19's concurrent rendering, naive insertion can desynchronize styles from the DOM. Both Emotion and styled-components moved to useInsertionEffect for this reason—it fires before layout effects and is the only sanctioned hook for injecting styles.

Instrument it yourself rather than trusting a blog post:

ts
// measure-style-cost.ts
export interface StyleMetrics {
  insertMs: number;
  ruleCount: number;
  inaccessibleSheets: number;
}

export function measureStyleInsertion(mount: () => void): StyleMetrics {
  const t0 = performance.now();
  mount();
  const insertMs = performance.now() - t0;

  let ruleCount = 0;
  let inaccessibleSheets = 0;
  for (const sheet of Array.from(document.styleSheets)) {
    try {
      // Throws SecurityError for cross-origin stylesheets without CORS headers.
      ruleCount += sheet.cssRules.length;
    } catch (err) {
      if (err instanceof DOMException) inaccessibleSheets += 1;
      else throw err;
    }
  }
  return { insertMs, ruleCount, inaccessibleSheets };
}

A healthy zero-runtime build reports a stable ruleCount across navigations. A runtime engine's ruleCount climbs monotonically—that's your memory leak and your recalculation tax.

Bundle Bloat: Three Different Growth Curves

StrategyProd CSS (gzip)Growth as components ↑
Tailwind v441 KBSublinear—O(unique utilities)
Vanilla Extract176 KBLinear—O(components × rules), deduped per file
CSS Modules218 KBLinear + hash overhead (~9% of raw bytes)
Emotion / styled-components~0 KB CSS, 12–14 KB JS + serialized payloadLinear in runtime payload

Atomic CSS wins because 10,000 components reuse the same ~4,000 unique utility classes. Scoped approaches grow linearly because each component owns its rules, and CSS Modules adds hashed class names that gzip compresses well but do not eliminate.

That said, 218 KB of CSS Modules is not automatically worse than 41 KB of Tailwind once you account for the fact that Tailwind's HTML carries 20+ class names per element. Measure total transferred bytes for a route, not CSS in isolation.

Design Tokens: The One Layer Everyone Agrees On

Whatever you choose, tokens must be CSS custom properties, not JS objects consumed at render time. This single decision makes every migration incremental.

ts
// tokens.css.ts — the contract, shared by all strategies
import { createTheme, createThemeContract } from '@vanilla-extract/css';

export const vars = createThemeContract({
  color: { accent: null, surface: null, text: null, onAccent: null, focusRing: null },
  space: { xs: null, sm: null, md: null },
  radius: { md: null },
  font: { body: null, size: { sm: null, md: null } },
  motion: { fast: null },
});

export const lightTheme = createTheme(vars, {
  color: { accent: '#2563eb', surface: '#ffffff', text: '#0f172a', onAccent: '#ffffff', focusRing: '#3b82f6' },
  space: { xs: '4px', sm: '8px', md: '16px' },
  radius: { md: '6px' },
  font: { body: 'Inter, system-ui, sans-serif', size: { sm: '0.875rem', md: '1rem' } },
  motion: { fast: '120ms' },
});

Tailwind v4 can consume the identical contract through its CSS-first @theme block, so a tokens-first architecture lets you run Tailwind for layout and Vanilla Extract for complex stateful components in the same product.

Tree-Shaking: What Survives Rollup

  • Tailwind doesn't tree-shake—it scans source text for candidate classes. Dynamic construction like ` bg-${color}-500 silently produces nothing. In v4 the safelist array is gone; use @source inline(...) or, better, exhaustive static maps typed with satisfies`.
  • CSS Modules are tree-shaken by the import graph: if no module imports Button.module.css, no CSS ships. Barrel files with eager re-exports defeat this—enforce sideEffects: false and deep imports.
  • Vanilla Extract emits a discrete CSS file per source file, so Vite's cssCodeSplit gives you per-route CSS for free. recipe compound variants inflate output; keep variant matrices small.
  • Runtime CSS-in-JS defeats tree-shaking whenever styled() is called inside a render function—every render creates a new class and a permanent CSSOM entry. Keep all style definitions at module scope and lint for it.

Decision Matrix

LayerRecommendationRationale
TokensCSS custom propertiesRuntime theme switching, zero JS
Layout / spacing primitivesAtomic CSSSublinear growth, no naming
Core primitives (Button, Input, ~200)CSS Modules or Vanilla ExtractType-safe variants, zero runtime
Complex compound componentsVanilla Extract recipeTyped variant API, static CSS
Product-level one-offsAtomic CSSNo design-system PR required
Runtime CSS-in-JSAvoid at 10k scaleMemory, INP, cascade instability

Operational Checklist

  • Budget CSS per route in CI (bundlesize/size-limit) with a hard fail threshold.
  • Assert NODE_OPTIONS=--max-old-space-size matches your runner; alert on peak RSS.
  • Lint against dynamic class construction and in-render style creation.
  • Enforce "sideEffects": ["**/*.css"] in the design-system package.json.
  • Emit per-route CSS chunks, not a single monolithic stylesheet.
  • Track CSSOM rule count as a production metric for any runtime engine.
  • Snapshot ruleCount and insertMs in Playwright on a throttled CPU profile.

Pitfalls to Avoid

Migrating everything at once. Convert primitives first, keep tokens as CSS variables, and let both stacks coexist behind the same variable contract.

Trusting dev builds. Tailwind's development output is enormous by design; only production builds are meaningful for size comparisons.

Ignoring cascade order. Mixing runtime-injected styles with static CSS creates a two-tier cascade where specificity depends on mount timing. Load static CSS first and give runtime styles the lowest-possible specificity.

Optimizing cold builds before incremental ones. Developers feel incremental builds 40 times a day and cold builds twice. Fix the hot path first.

Sources & Technical References

0 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles
Design System CSS Benchmark at 10k Components | Prasanga Pokharel