Performance Monitoring, Budgets & Culture
Optimizing performance once is easy. Keeping it fast across 20 developers, 50 PRs/week, and years of feature development β that requires systems: automated monitoring, enforced budgets, and a culture that treats performance as a feature, not an afterthought.
The Monitoring Stack
Development CI/CD Pipeline Production
ββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββββββ
β DevTools β β Lighthouse CI β β Real User Monitoring β
β Lighthouse β β Bundle budgets β β (RUM) β
β Performance β β β Size checks ββ β Error tracking β
β tab β β Visual regressionβ β Alerting β
ββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββββββLab Monitoring (Development & CI)
Lighthouse CI
# .lighthouserc.yml
ci:
collect:
url:
- http://localhost:3000/
- http://localhost:3000/products
- http://localhost:3000/dashboard
settings:
preset: desktop
assert:
assertions:
categories:performance:
- error
- minScore: 0.9
largest-contentful-paint:
- error
- maxNumericValue: 2500
cumulative-layout-shift:
- error
- maxNumericValue: 0.1
total-blocking-time:
- error
- maxNumericValue: 300
interactive:
- warn
- maxNumericValue: 3500Bundle Size Checks
// bundlewatch.config.js
module.exports = {
files: [
{ path: '.next/static/chunks/main-*.js', maxSize: '80kb' },
{ path: '.next/static/chunks/pages/**/*.js', maxSize: '100kb' },
{ path: '.next/static/css/**/*.css', maxSize: '30kb' },
],
ci: {
trackBranches: ['main'],
repoBranchBase: 'main',
},
};Every PR shows the bundle size diff. Increases beyond the budget require justification.
Real User Monitoring (RUM)
Lab data tells you what could happen. RUM tells you what does happen β on real devices, real networks, for real users.
Core Implementation
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
function sendMetric({ name, value, rating, id, navigationType }) {
const body = {
metric: name,
value: Math.round(name === 'CLS' ? value * 1000 : value),
rating, // 'good', 'needs-improvement', 'poor'
id,
page: window.location.pathname,
connection: navigator.connection?.effectiveType,
deviceMemory: navigator.deviceMemory,
userAgent: navigator.userAgent,
timestamp: Date.now(),
navigationType,
};
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', JSON.stringify(body));
} else {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify(body),
keepalive: true,
});
}
}
onLCP(sendMetric);
onINP(sendMetric);
onCLS(sendMetric);
onFCP(sendMetric);
onTTFB(sendMetric);Why sendBeacon?
navigator.sendBeacon sends data even when the page is unloading (tab close, navigation away). Regular fetch may be cancelled. For metrics that finalize at page unload (CLS, INP), sendBeacon is essential.
Segmenting RUM Data
Aggregate p75 is misleading. Segment by:
| Dimension | Why |
|---|---|
| Page/route | Different pages have different characteristics |
| Device class | Mobile vs desktop performance differs 3-5x |
| Connection type | 4G vs 3G vs WiFi |
| Geography | CDN coverage varies by region |
| User journey | First visit vs return (cache effects) |
| Browser | Safari vs Chrome rendering differences |
-- Example: LCP by device and page
SELECT
page,
device_class,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY lcp_value) as p75_lcp,
COUNT(*) as sample_size
FROM web_vitals
WHERE metric = 'LCP' AND timestamp > NOW() - INTERVAL '7 days'
GROUP BY page, device_class
ORDER BY p75_lcp DESC;Performance Budgets
Budget Types
| Budget Type | What it Controls | Example |
|---|---|---|
| Size budget | Transfer size of resources | JS < 200KB, CSS < 50KB, Images < 500KB |
| Timing budget | User-perceived metrics | LCP < 2.5s, INP < 200ms |
| Count budget | Number of requests | < 50 requests, < 5 third-party scripts |
| Rule budget | Lighthouse audit scores | Performance > 90, Accessibility > 95 |
Implementing Budgets in CI
# GitHub Actions
name: Performance Budget
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Lighthouse CI
uses: treosh/lighthouse-ci-action@v11
with:
configPath: .lighthouserc.yml
uploadArtifacts: true
bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Check bundle size
uses: siddharthkp/bundlewatch@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}Budget Alerts
// Alert when p75 metrics degrade
function checkBudgets(metrics) {
const budgets = {
LCP: 2500,
INP: 200,
CLS: 0.1,
FCP: 1800,
TTFB: 800,
};
for (const [metric, threshold] of Object.entries(budgets)) {
const p75 = calculatePercentile(metrics[metric], 75);
if (p75 > threshold) {
alert({
severity: 'warning',
message: `${metric} p75 is ${p75}ms (budget: ${threshold}ms)`,
page: metrics.page,
});
}
}
}Custom Performance Metrics
Beyond Web Vitals, measure what matters for your product:
// Time to first meaningful interaction (custom)
performance.mark('app-interactive');
performance.measure('time-to-interactive', 'navigationStart', 'app-interactive');
// Feature-specific metrics
function trackFeatureLoad(featureName) {
performance.mark(`${featureName}-start`);
return () => {
performance.mark(`${featureName}-end`);
const measure = performance.measure(
`${featureName}-load`,
`${featureName}-start`,
`${featureName}-end`
);
sendMetric({ name: featureName, value: measure.duration });
};
}
const endTrack = trackFeatureLoad('dashboard-charts');
await loadCharts();
endTrack(); // Reports: "dashboard-charts: 450ms"Server Timing
Server-Timing: db;dur=53, cache;desc="Cache Read";dur=2, render;dur=47const [navigation] = performance.getEntriesByType('navigation');
navigation.serverTiming.forEach(({ name, duration, description }) => {
console.log(`${name}: ${duration}ms (${description})`);
});Server-Timing headers let you surface server-side performance data in browser DevTools and RUM.
The Performance Culture
Make Performance Visible
- Dashboard β Real-time Web Vitals displayed in the office / Slack
- PR comments β Bot posts bundle size diff and Lighthouse scores on every PR
- Weekly reports β p75 trends by route, regression detection
- Incident process β Performance regressions treated with the same urgency as bugs
The Performance Review Checklist
Every PR that touches UI should consider:
## Performance Review
- [ ] No new synchronous blocking resources in <head>
- [ ] Images have explicit width/height
- [ ] New features are lazy-loaded if not above-fold
- [ ] No new dependencies > 10KB gzipped without justification
- [ ] Event handlers use passive where appropriate
- [ ] Bundle size change is within budgetOwnership
Assign a "performance champion" per team:
- Reviews performance implications of PRs
- Monitors RUM dashboards
- Investigates regressions within 24 hours
- Maintains budgets and updates them quarterly
Interview Signal
Senior candidates demonstrate:
- End-to-end thinking β Dev tools β CI gates β production monitoring β alerting
- RUM sophistication β Segmentation, p75 targeting, sendBeacon for reliable collection
- Budget enforcement β Not just setting budgets but integrating them into CI/CD
- Custom metrics β Measuring what matters for the specific product, not just generic vitals
- Culture perspective β Performance as ongoing discipline, not a sprint task