The performance cost of your design system
Design systems are sold on consistency and developer experience, and they deliver both. What rarely makes it onto the slide is that a design system is also the most duplicated dependency in your company: every page of every product pays for its architectural choices. A 20-kilobyte mistake in one app is a 20-kilobyte mistake in all of them.
I’ve lost count of the times I’ve profiled a slow product page, opened the bundle visualiser and found that the largest rectangle isn’t the app’s code at all. It’s @company/design-system - a single <Button> import dragging in a theming runtime, an icon set and a CSS-in-JS engine. The team that owns the page can’t fix it, and the team that owns the system doesn’t feel it, because the cost only shows up in everyone else’s Lighthouse report.
I wrote recently about fixing slow interactions at the application level. This post is about the layer underneath: where design systems cost you performance, and the fixes that actually move the metrics.
Where the cost actually comes from
A design system makes four architectural decisions that end up in your users’ browsers, and each one damages a different metric:
- Component architecture - how components are packaged and exported determines what tree-shaking can remove. Get it wrong and every consumer ships the whole library, which means more JavaScript to download, parse and execute. That shows up in LCP, and in INP as input delay from long tasks.
- CSS strategy - whether styles are generated at runtime or shipped as a static stylesheet determines whether the main thread does style work during render. Runtime style generation lands squarely inside the window INP measures.
- Token delivery - whether design tokens ship as JavaScript objects or CSS custom properties determines whether theming needs a re-render, and whether dark mode can apply before hydration.
- Icons - how icons are packaged determines whether one
<Check>costs you the whole set, and whether identical SVG markup is duplicated across your bundle and your DOM.
The distinction matters because the fix is different for each. Fixing your tree-shaking config won’t help if the problem is a runtime CSS-in-JS engine, and neither will help if every icon in the set ships on every page.
There’s also a structural reason these problems survive so long: the design system sits below every team’s line of sight. Product teams treat it as a given, like the framework, so nobody’s sprint ever includes “make the Button cheaper”. The costs compound quietly across every product until someone finally opens the treemap.
Measure what one component costs
The single most useful diagnostic takes about ten minutes: import one component into an otherwise empty app and build it.
// src/main.js - the entire application
import { Button } from '@acme/design-system';
console.log(Button);
Build that with your normal production config, then point source-map-explorer or vite-bundle-visualizer at the output. What you’re looking at is the true marginal cost of the system - the price every consumer pays before they’ve written a line of their own code. If <Button> costs 80 kilobytes, everything downstream is built on sand.
The treemap usually tells the story on its own. Riding along with the button you’ll typically find the theme object, a chunk of the icon set and the styling runtime. The Coverage tab in DevTools tells you the other half: how much of that shipped CSS and JavaScript was actually used on the page. Each of the following sections is one rectangle in that treemap.
Component architecture: the barrel file tax
Most design systems export everything through a barrel file - an index.ts that re-exports two hundred components so consumers get a tidy single import. The tidiness is real, and so is the tax. For a bundler to tree-shake a barrel it has to prove that every re-export is free of side effects. If the package ships CommonJS, or ships ESM without declaring itself side-effect-free, the bundler gives up and includes the lot.
// Resolves through the barrel: the bundler has to evaluate
// every re-export before it can discard any of them
import { Button } from '@acme/design-system';
// Resolves one module: nothing else is even considered
import { Button } from '@acme/design-system/button';
Consumers can work around this with import-rewriting transforms like Next.js’s optimizePackageImports, but that’s a band-aid applied in every app. The real fix belongs in the library’s package.json: ship real ESM, declare sideEffects, and expose per-component entry points so the direct import path is official rather than a reach into dist:
{
"name": "@acme/design-system",
"type": "module",
"sideEffects": false,
"exports": {
".": "./dist/index.js",
"./button": "./dist/button/index.js",
"./*": "./dist/*/index.js"
}
}
There’s a useful early-warning sign here too: if your dev server takes noticeably longer to start when the design system is imported, that’s the cost of parsing a two-hundred-module barrel graph. The DX pain and the production pain have the same root cause.
CSS strategy: runtime cost is paid per render, stylesheet cost is paid once
A static stylesheet is paid for once - the browser parses it, caches it, and it’s done. Runtime CSS-in-JS libraries like styled-components and Emotion instead serialise your styles and inject rules into the document on the main thread, during render. That’s work inside the exact window INP measures, and it happens on every render, not just the first one.
Dynamic props are the trap that makes it expensive in practice:
// A new class is generated, serialised and injected
// for every distinct value of `color`
const Button = styled.button`
background: ${(props) => props.color};
`;
// One static class; the dynamic part travels as a
// custom property and never touches the style engine
const Button = ({ color, ...props }) => (
<button
className="button"
style={{ '--button-bg': color }}
{...props}
/>
);
The second version pairs with a single static rule - .button { background: var(--button-bg); } - and gives you the same API ergonomics with none of the runtime. This is the highest-leverage change in the post, because a design system multiplies its styling strategy across every component of every product.
To be clear, this isn’t “CSS-in-JS bad”. The authoring model is genuinely good, and the zero-runtime implementations keep it: vanilla-extract, Panda CSS and Linaria all extract static CSS at build time, and CSS Modules remain a boring, excellent default. The ecosystem has already voted - styled-components moved into maintenance mode in 2025 with a recommendation against using it for new projects, and Spot’s widely-read migration write-up measured render times roughly halving on a heavy component after moving off Emotion. The principle is simple: style work belongs at build time, not inside your users’ interactions.
Design tokens: ship custom properties, not JavaScript objects
The common anti-pattern is tokens as a deeply nested TypeScript object, consumed through a ThemeProvider. It feels natural - the tokens get autocomplete, the theme is just props - but look at what it means at runtime. Every token ships to the browser as JavaScript, in every entry point. Switching theme means re-rendering the entire component tree. And because the theme lives in JavaScript, dark mode can’t apply until hydration, which is exactly the flash of white your users see on every page load.
The fix is to treat the JavaScript object as a build-time artefact and make CSS custom properties the runtime format. Token pipelines like Style Dictionary exist to do precisely this - one source of truth, emitted as TypeScript for autocomplete and as CSS for the browser:
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--space-2: 0.5rem;
}
[data-theme="dark"] {
--color-bg: #1a1a1a;
--color-text: #f5f5f5;
}
Now theme switching is one attribute flip on <html> - no re-render, no JavaScript in the critical path, and it works before hydration, so the dark-mode flash disappears. It composes with prefers-color-scheme for free, and because custom properties inherit, component-level overrides come built in. The hundreds of tokens that used to ship as JavaScript to every entry point become a few kilobytes of cacheable CSS.
Icons: the thousand-icon import
Icon packages fail in two familiar ways. The first is the barrel problem again, in its most extreme form: icon sets are enormous, so an icon barrel that doesn’t tree-shake cleanly is how one <CheckIcon /> costs you a four-figure number of icons - and even when production tree-shaking works, your dev server still pays to parse the full set. The second is subtler: inlining every icon as a component duplicates identical SVG path data in the bundle and again in the DOM, once per rendered instance. A results page with an icon per row carries the same paths hundreds of times.
Direct imports solve the first problem - most icon libraries document a per-icon path precisely because of this. For the second, the old-fashioned SVG sprite has quietly become good advice again:
<svg width="16" height="16" aria-hidden="true">
<use href="/icons.svg#check" />
</svg>
One sprite file, cached once, referenced everywhere, zero bundle cost, and currentColor handles theming. Two caveats from experience: <use> references don’t cross Shadow DOM boundaries, so web-component-based systems need the icons inside the shadow root; and whichever route you take, run the SVGs through SVGO in the build - hand-exported icons carry a surprising amount of editor metadata.
Start here
- Measure the cost of one component first. The empty-app-plus-Button fixture takes ten minutes and tells you whether you have an architecture problem or a discipline problem. Everything else in this post follows from that treemap.
- Make zero-runtime the default. Static CSS for components, custom properties for tokens and theming, build-time extraction if you want the CSS-in-JS authoring model. The main thread during an interaction is the most expensive place on the web to generate a class name.
- Give the design system its own performance budget. Wire size-limit into the system’s CI so a heavy component fails the build there - once, at the source - instead of failing quietly in every product that consumes it.
A design system is the one dependency you actually control end to end, which makes it the highest-leverage performance work in the company: fix it once and the fix lands everywhere at once.