Performance in Design Systems: Bundle Size, Tree-Shaking, Lazy
Bundle bloat kills design systems quietly. Learn how tree-shaking, lazy loading, and smart architecture keep your component library fast and your users happy.
Why Your Design System Is Probably Killing Your Bundle
Honestly, most design systems ship as a single fat barrel export and call it done. You import one button, you get the entire library. Every icon, every modal, every chart component that nobody on your team uses — all of it lands in your users' browsers.
This isn't a hypothetical. An audit of a mid-sized SaaS app last year showed a 340 kB gzip chunk traced back to a component library where only 12% of the components were actually rendered. The rest was dead weight. That's a slow LCP, a worse Interaction to Next Paint score, and users on 4G who will absolutely bounce.
Performance in design systems isn't just about writing fast code. It's about architecture — how you export things, how consumers import them, and how your build pipeline handles the rest. Get that architecture wrong and no amount of React.memo will save you.
Understanding Tree-Shaking and Why It Breaks
Tree-shaking is the process where your bundler (Webpack, Rollup, Vite, whatever you're using) statically analyzes imports and drops unused code. It sounds magical. In practice it fails constantly, and the reasons are almost always the same.
The first killer is CommonJS. If your design system ships as CJS with module.exports = { Button, Modal, Table, ... }, tree-shaking is dead on arrival. Bundlers can't statically analyze dynamic requires. You need ESM — export { Button } — in your published package. Check your package.json for the "module" or "exports" field. If it only has "main", that's your problem right there.
The second killer is side effects. If your package.json doesn't include "sideEffects": false (or a precise list of CSS files that do have side effects), Webpack will conservatively keep everything. Add that field. If you inject global CSS from component files, list those specifically like "sideEffects": ["**/*.css"].
The third killer is barrel files — the notorious index.ts that re-exports everything. Even with ESM, some bundlers struggle to tree-shake through deep barrel re-export chains. We'll get to the fix in the next section.
Structuring Exports for Maximum Tree-Shaking
The structure of your design system's package.json exports map matters enormously. Modern bundlers support the "exports" field, which lets you define per-path entry points. This is how you give consumers direct access to individual components without routing everything through a single barrel.
Here's a concrete example of what a performant exports map looks like for a component library targeting both ESM and CJS consumers:
{
"name": "@empire-ui/core",
"version": "2.4.1",
"sideEffects": ["**/*.css"],
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/types/index.d.ts"
},
"./button": {
"import": "./dist/esm/components/Button/index.js",
"require": "./dist/cjs/components/Button/index.js",
"types": "./dist/types/components/Button/index.d.ts"
},
"./modal": {
"import": "./dist/esm/components/Modal/index.js",
"require": "./dist/cjs/components/Modal/index.js",
"types": "./dist/types/components/Modal/index.d.ts"
}
}
}With this structure, import { Button } from '@empire-ui/core/button' pulls exactly one component's chunk. No modal code, no table code, nothing else. Your build tool doesn't even have to work to tree-shake — the isolation is structural. This pairs well with a solid spacing system and icon system that follow the same pattern, since icons are notoriously bad offenders for bundle bloat.
Lazy Loading Heavy Components at the Consumer Level
Some components are inherently heavy. A rich text editor, a date-range picker with locale support, a chart library wrapper — these shouldn't be in your initial bundle no matter how well you tree-shake. They need to be lazy loaded.
React's lazy and Suspense are the right tool here. The pattern is simple but a lot of teams either skip it or apply it inconsistently. Here's how you'd wrap a heavy design system component for lazy consumption:
import { lazy, Suspense } from 'react'
// Heavy component — loads only when rendered
const RichTextEditor = lazy(() =>
import('@empire-ui/core/rich-text-editor').then(mod => ({
default: mod.RichTextEditor,
}))
)
// Fallback keeps layout stable — 48px matches the editor's min-height header
function EditorSection() {
return (
<Suspense
fallback={
<div
style={{
height: 48,
borderRadius: 8,
background: 'rgba(255,255,255,0.08)',
}}
/>
}
>
<RichTextEditor />
</Suspense>
)
}The fallback matters more than most people realize. A skeleton that matches the actual component's dimensions prevents layout shift, which directly affects your CLS score. Using rgba(255,255,255,0.08) for the skeleton background works well against dark surfaces — it's subtle enough to not distract but visible enough to communicate loading state.
One thing to watch: don't lazy load things that appear above the fold on page load. Lazy loading a primary navigation component or a hero section just adds a waterfall request at the worst possible time. Reserve it for modals, drawers, tab panels, and anything the user has to explicitly trigger.
Tailwind v4 and Design Systems: What Changed for Bundle Size
Tailwind v4.0.2 made a significant shift in how the CSS engine works. It moved from a PostCSS plugin scanning your source files to a Rust-based engine that parses your content at build time. The practical result is that unused utility classes are eliminated more aggressively and the output CSS is smaller — often 20-40% smaller for typical app builds.
For design systems built on Tailwind, this creates an interesting tension. If you're shipping pre-built component CSS with your library, consumers may end up with duplicate Tailwind utilities — once in your library's CSS, once in their app's build output. The cleanest approach is to ship components without pre-compiled Tailwind CSS and let the consumer's Tailwind build process handle everything.
That means your components should use Tailwind class names as strings in JSX, and your library's package.json should tell consumers to include your source in their Tailwind content scanning config. Document this explicitly. A lot of integration bugs in design system adoption come from missing content paths, not actual code errors. If you've already worked through a Figma-to-React workflow, you'll recognize this as one of the same handoff pain points.
Measuring What You're Actually Shipping
You can't fix what you don't measure. Before touching any code, get a baseline. There are a few tools worth having in your workflow permanently, not just for a one-time audit.
Bundlephobia (bundlephobia.com) gives you a quick read on any npm package's gzip size and tree-shakeable size. Run your own library through it. Then run your dependencies through it — sometimes the problem isn't your code, it's a transitive dependency you didn't notice.
For your app bundle specifically, rollup-plugin-visualizer and webpack-bundle-analyzer generate interactive treemaps showing exactly what's in each chunk. The first time you run one of these on a real production app it's often a little shocking. You'll find lodash, moment.js, or some ancient polyfill you forgot was there. Setting a bundle size budget in your CI pipeline — say, failing the build if a specific chunk exceeds 150 kB gzip — keeps the discipline enforced without relying on manual audits.
Does your design system have a Storybook setup? You can instrument Storybook stories to report component render times, which catches slow component initialization that won't show up in bundle analysis. Check out the Storybook component library guide for how to integrate performance addons into your existing setup.
Code Splitting Strategies for Design System Consumers
Beyond tree-shaking and lazy loading individual components, route-level code splitting is where most apps actually get their biggest wins. If your checkout flow uses a date picker and your marketing pages don't, that date picker code shouldn't load on the marketing pages.
Next.js, Remix, and Vite-based frameworks all handle route-level splitting automatically when you use their file-system routing. But dynamic imports for design system components within routes still require manual attention. Identify which components are only used in specific routes and make those imports dynamic.
For theme switching specifically — if you're loading multiple theme token sets from your design system — consider loading only the active theme's tokens and deferring the others. You don't need your dark theme CSS variables parsed and applied if the user hasn't toggled dark mode yet. This is especially relevant if you're running 40 visual styles like Empire UI does; not every style variant needs to be in the critical path.
Keeping Performance a First-Class Concern in Your System
Here's a pattern that works: treat bundle size the same way you treat accessibility. Not an afterthought, not a separate team's problem — a property of every component that gets checked before it merges. What would it take for your team to never ship a component that causes a regression?
Automated size-limit checks in CI are the answer. The size-limit npm package lets you define limits per export path and fails your CI if something exceeds it. You define the budget, the tool enforces it. Set it up once and you stop having the conversation.
Performance and correctness aren't in conflict. A well-structured design system — clean exports, ESM output, sideEffects declared, heavy components lazy-loaded — is also easier to maintain, easier to consume, and easier to reason about. The same discipline that produces a fast system tends to produce a clear one. That's not an accident.
FAQ
Named exports alone aren't enough. Your package needs to ship ESM (not CJS), declare "sideEffects": false in package.json, and ideally use the "exports" field with per-component entry points. Check what your build tool is actually outputting — if the "main" field points to a CJS file and there's no "module" field, bundlers will use CJS and tree-shaking won't work.
It depends on your audience. Pre-compiled CSS is easier to consume but causes duplication when consumers also run Tailwind v4. The better approach for Tailwind-based libraries is to ship the component JSX with class strings and ask consumers to add your package to their Tailwind content scan config. Document this clearly in your README.
There's no universal number, but a useful heuristic: your core primitives (Button, Input, Typography, Badge) should add under 15 kB gzip to a consumer's bundle when imported individually. Heavy components (modals, date pickers, rich text editors) should never be in the main chunk — they should always be lazy loaded. Use the size-limit package to enforce these budgets in CI.
Yes, in some configurations. Vite and Rollup handle barrel file tree-shaking better than older Webpack setups, but deep re-export chains can still confuse static analysis. The safest pattern is per-component entry points in your "exports" map, which eliminates the problem entirely regardless of bundler. Barrel files inside your library source are fine — it's the published entry point that matters.
Run rollup-plugin-visualizer or webpack-bundle-analyzer to get a visual treemap of your production build. Look for unexpectedly large chunks or components that appear in chunks they shouldn't. Also try Bundlephobia on your design system package directly to see its declared size and whether it reports as tree-shakeable.
Yes, if your Suspense fallback doesn't match the loaded component's dimensions. Always give your fallback skeleton the same height and basic layout as the real component. For example, if your modal trigger button is 40px tall with a 4px border-radius, your skeleton fallback should match those values exactly. This keeps your CLS score clean even during the loading state.