Monitor design system component performance in production — Core Web Vitals attribution, render performance tracking, Interaction to Next Paint measurement, and alerting for regressions.
Monitor design system component performance in production — Core Web Vitals attribution, render performance tracking, Interaction to Next Paint measurement, and alerting for regressions.
The web-vitals library provides element-level attribution for LCP and CLS, enabling you to identify which design system component is causing a Core Web Vitals failure:
1// src/performance/vitals.ts
2import { onLCP, onCLS, onINP } from 'web-vitals/attribution';
3
4function sendToAnalytics(metric: Record<string, unknown>) {
5 navigator.sendBeacon('/analytics/vitals', JSON.stringify(metric));
6}
7
8onLCP((metric) => {
9 const attribution = metric.attribution;
10 sendToAnalytics({
11 name: 'LCP',
12 value: metric.value,
13 // Which element triggered LCP
14 element: attribution.element,
15 // Which design system component (via data-ds-component attribute)
16 dsComponent: attribution.element?.closest('[data-ds-component]')
17 ?.getAttribute('data-ds-component') ?? 'unknown',
18 url: window.location.href,
19 });
20});
21
22onINP((metric) => {
23 sendToAnalytics({
24 name: 'INP',
25 value: metric.value,
26 // Which interaction caused the delay
27 eventType: metric.attribution.eventType,
28 // The element interacted with
29 eventTarget: metric.attribution.eventTarget,
30 dsComponent: metric.attribution.eventTarget
31 ?.closest('[data-ds-component]')
32 ?.getAttribute('data-ds-component') ?? 'unknown',
33 });
34});Add data-ds-component={componentName} to the root element of each design system component. This creates a queryable breadcrumb in your analytics data that maps Core Web Vitals failures to specific components.
React Profiler tracks render times for individual components during development. Wrap design system components in a development-only Profiler to catch slowdowns:
1// packages/react/src/utils/withProfiler.tsx
2import { Profiler, type ProfilerOnRenderCallback } from 'react';
3
4const SLOW_RENDER_THRESHOLD_MS = 16; // 1 frame at 60fps
5
6function onRender: ProfilerOnRenderCallback = (
7 id, phase, actualDuration, baseDuration
8) => {
9 if (actualDuration > SLOW_RENDER_THRESHOLD_MS) {
10 console.warn(
11 `[DS Performance] Slow render: ${id} took ${actualDuration.toFixed(2)}ms ` +
12 `(expected <${SLOW_RENDER_THRESHOLD_MS}ms). Phase: ${phase}`
13 );
14 }
15};
16
17export function withProfiler<P extends object>(
18 Component: React.ComponentType<P>,
19 id: string
20) {
21 if (process.env.NODE_ENV === 'production') return Component;
22
23 return function ProfiledComponent(props: P) {
24 return (
25 <Profiler id={id} onRender={onRender}>
26 <Component {...props} />
27 </Profiler>
28 );
29 };
30}
31
32// Usage
33export const Button = withProfiler(_Button, 'ds/Button');
34export const DataTable = withProfiler(_DataTable, 'ds/DataTable');Development Profiler warnings surface performance issues during development before they affect users. Remove profilers in production by checking NODE_ENV — the withProfiler HOC handles this automatically.
Interaction to Next Paint (INP) is the most important 2024+ performance metric for interactive design system components like DataTable, Select, and Dialog. Measure INP in development with PerformanceObserver:
1// packages/react/src/utils/measureINP.ts
2export function measureComponentINP(
3 componentName: string,
4 rootRef: React.RefObject<HTMLElement>
5) {
6 if (process.env.NODE_ENV !== 'development') return () => {};
7
8 const observer = new PerformanceObserver((list) => {
9 for (const entry of list.getEntries()) {
10 // Only measure interactions within this component
11 const target = (entry as PerformanceEventTiming).target;
12 if (rootRef.current?.contains(target)) {
13 const duration = entry.duration;
14 if (duration > 200) {
15 console.warn(
16 `[DS INP] ${componentName} interaction took ${duration}ms. ` +
17 `Target: ${target?.tagName}#${target?.id}. ` +
18 `Good INP is under 200ms.`
19 );
20 }
21 }
22 }
23 });
24
25 observer.observe({ type: 'event', buffered: true, durationThreshold: 100 });
26 return () => observer.disconnect();
27}
28
29// In DataTable component:
30useEffect(() => {
31 const cleanup = measureComponentINP('DataTable', tableRef);
32 return cleanup;
33}, []);Catch performance regressions before they ship using Playwright and Lighthouse in CI:
1// tests/performance/lighthouse.spec.ts
2import { test, expect } from '@playwright/test';
3import { playAudit } from 'playwright-lighthouse';
4
5const PERFORMANCE_THRESHOLDS = {
6 performance: 90,
7 accessibility: 100,
8 'best-practices': 95,
9 seo: 90,
10};
11
12const TEST_PAGES = [
13 { url: '/storybook/iframe.html?id=components-button--all-variants', label: 'Button' },
14 { url: '/storybook/iframe.html?id=components-datatable--large-dataset', label: 'DataTable' },
15 { url: '/storybook/iframe.html?id=components-dialog--complex', label: 'Dialog' },
16];
17
18for (const page of TEST_PAGES) {
19 test(`Performance: ${page.label}`, async ({ page: playwrightPage, browserName }) => {
20 test.skip(browserName !== 'chromium', 'Lighthouse requires Chromium');
21
22 await playwrightPage.goto(`http://localhost:6006${page.url}`);
23 await playwrightPage.waitForLoadState('networkidle');
24
25 const audit = await playAudit({
26 page: playwrightPage,
27 thresholds: PERFORMANCE_THRESHOLDS,
28 reports: { formats: { json: true } },
29 });
30
31 // Store results for trend tracking
32 expect(audit.lhr.categories.performance.score * 100)
33 .toBeGreaterThanOrEqual(PERFORMANCE_THRESHOLDS.performance);
34 });
35}Production alerting closes the loop between monitoring and action. Configure Sentry or your observability stack to alert on Core Web Vitals degradation:
1// src/performance/alerts.ts
2import * as Sentry from '@sentry/browser';
3import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
4
5const THRESHOLDS = {
6 lcp: { good: 2500, needsImprovement: 4000 },
7 inp: { good: 200, needsImprovement: 500 },
8 cls: { good: 0.1, needsImprovement: 0.25 },
9};
10
11function classifyVital(name: string, value: number): 'good' | 'needs-improvement' | 'poor' {
12 const t = THRESHOLDS[name.toLowerCase() as keyof typeof THRESHOLDS];
13 if (!t) return 'good';
14 if (value <= t.good) return 'good';
15 if (value <= t.needsImprovement) return 'needs-improvement';
16 return 'poor';
17}
18
19onLCP((metric) => {
20 const classification = classifyVital('lcp', metric.value);
21 if (classification === 'poor') {
22 Sentry.captureMessage(`Poor LCP: ${metric.value}ms`, {
23 level: 'warning',
24 extra: {
25 dsComponent: metric.attribution.element
26 ?.closest('[data-ds-component]')
27 ?.getAttribute('data-ds-component'),
28 url: window.location.pathname,
29 },
30 });
31 }
32});Set Sentry alerts to notify the design system channel in Slack when any Core Web Vital drops to 'poor' rating in production for more than 1% of page views. Include the dsComponent attribution so the on-call designer knows exactly which component to investigate first.
Add data-ds-component attributes to your 5 most-used components. Set up web-vitals attribution tracking in your main application to see which components are contributing to LCP and INP failures.
Your progress is saved in this browser, on this device only.