Establish and enforce performance budgets for your design system — bundle size limits, render performance benchmarks, and CI enforcement that prevents bundle bloat from shipping.
Establish and enforce performance budgets for your design system — bundle size limits, render performance benchmarks, and CI enforcement that prevents bundle bloat from shipping.
Performance budgets must be specific, measurable, and enforced automatically. Define budgets per package at the gzip level because that reflects what users actually download:
1// .size-limit.json
2[
3 {
4 "name": "@acme/tokens (CSS)",
5 "path": "packages/tokens/dist/index.css",
6 "limit": "12 KB",
7 "gzip": true
8 },
9 {
10 "name": "@acme/react (full bundle)",
11 "path": "packages/react/dist/index.esm.js",
12 "limit": "45 KB",
13 "gzip": true
14 },
15 {
16 "name": "@acme/react Button only (tree-shaken)",
17 "path": "packages/react/dist/index.esm.js",
18 "import": "{ Button }",
19 "limit": "3 KB",
20 "gzip": true
21 },
22 {
23 "name": "@acme/icons (full set)",
24 "path": "packages/icons/dist/index.esm.js",
25 "limit": "8 KB",
26 "gzip": true
27 }
28]Set the per-component tree-shaken budget (Button at 3KB) to incentivize keeping components lean. The full bundle limit catches dependency bloat. Run npx size-limit locally before any dependency addition.
Size-limit is great for local checks; Bundlewatch adds PR-level comments and history tracking. Configure both for defense in depth:
1// bundlewatch.config.json
2{
3 "files": [
4 {
5 "path": "packages/react/dist/index.esm.js",
6 "maxSize": "45 kB",
7 "compression": "gzip"
8 },
9 {
10 "path": "packages/tokens/dist/index.css",
11 "maxSize": "12 kB",
12 "compression": "gzip"
13 }
14 ],
15 "ci": {
16 "trackBranches": ["main"],
17 "repoBranchBase": "main"
18 }
19}1# .github/workflows/bundle-size.yml
2jobs:
3 bundle-size:
4 runs-on: ubuntu-latest
5 steps:
6 - uses: actions/checkout@v4
7 - run: pnpm install --frozen-lockfile
8 - run: pnpm build
9 - name: Check bundle size
10 env:
11 BUNDLEWATCH_GITHUB_TOKEN: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}
12 run: npx bundlewatch
13 - name: Size limit report
14 run: npx size-limit --json > size-report.json
15 - uses: andresz1/size-limit-action@v1
16 with:
17 github_token: ${{ secrets.GITHUB_TOKEN }}Bundlewatch posts a size comparison table to every PR showing current vs. base branch sizes with traffic-light status indicators.
Bundle size is necessary but not sufficient — a small component can be slow to render. Use Vitest's bench mode for render performance baselines:
1// packages/react/src/Button/Button.bench.ts
2import { bench, describe } from 'vitest';
3import { render, cleanup } from '@testing-library/react';
4import { Button } from './Button';
5
6describe('Button render performance', () => {
7 bench(
8 'initial render',
9 () => {
10 render(<Button variant="primary">Submit</Button>);
11 },
12 {
13 iterations: 100,
14 time: 1000,
15 // Fail if median render time exceeds 2ms
16 threshold: 2,
17 }
18 );
19
20 bench(
21 'render with all props',
22 () => {
23 render(
24 <Button
25 variant="primary"
26 size="lg"
27 isLoading={false}
28 disabled={false}
29 leftIcon={<PlusIcon />}
30 >
31 Add to cart
32 </Button>
33 );
34 cleanup();
35 },
36 { iterations: 100 }
37 );
38});Run pnpm vitest bench in CI and post results as a PR comment. Alert on regressions greater than 20% from the previous baseline.
CSS specificity creep is a silent performance and maintainability killer. High-specificity selectors force browsers to do more work and create override wars in consuming apps. Enforce specificity limits with PostCSS plugins:
1// postcss.config.mjs
2import postcssSpecificity from 'postcss-specificity';
3
4export default {
5 plugins: [
6 postcssSpecificity({
7 // Fail if any selector exceeds specificity 0,2,0
8 // (two classes, zero IDs)
9 maxSpecificity: '0,2,0',
10 excludeSelectors: [':root', ':where'],
11 }),
12 ],
13};Also track total stylesheet size as a budget item. Design systems that grow stylesheet size unchecked impose costs on every page load. Set a CSS budget of 15KB gzip for the token layer and 30KB gzip for all component styles combined. Run these checks in the same CI job as bundle size to see both metrics together in every PR.
Give contributors visibility into performance impact within the Storybook workflow. A custom Storybook panel can show render time, bundle contribution, and re-render count:
1// .storybook/addons/performance-panel.tsx
2import { addons, types } from '@storybook/addons';
3import { useEffect, useState } from 'react';
4
5addons.register('performance-panel', () => {
6 addons.add('performance-panel/panel', {
7 type: types.PANEL,
8 title: 'Performance',
9 render: ({ active }) => {
10 const [metrics, setMetrics] = useState({ renderTime: 0, bundleSize: 0 });
11
12 useEffect(() => {
13 // Observe component render via PerformanceObserver
14 const obs = new PerformanceObserver((list) => {
15 for (const entry of list.getEntries()) {
16 if (entry.name.includes('storybook-root')) {
17 setMetrics(prev => ({
18 ...prev,
19 renderTime: Math.round(entry.duration),
20 }));
21 }
22 }
23 });
24 obs.observe({ type: 'measure', buffered: true });
25 return () => obs.disconnect();
26 }, []);
27
28 if (!active) return null;
29 return (
30 <div style={{ padding: '1rem' }}>
31 <p>Render time: {metrics.renderTime}ms</p>
32 <p>Bundle contribution: load from size-report.json</p>
33 </div>
34 );
35 },
36 });
37});Add a .size-limit.json to your design system repository with budgets for each package. Run pnpm size-limit on your current build and identify which package is closest to its limit.
Your progress is saved in this browser, on this device only.