Web Performance Budgets, a core concept in frontend performance optimization. Think of a performance budget as a “weight goal” for your project, ensuring fast page loads and smooth interactions to keep users happy. Studies show that page load times exceeding 3 seconds can increase bounce rates by 40%. By setting clear performance metrics like First Contentful Paint (FCP), resource size, and request count, we can keep projects lean and efficient.
What is a Web Performance Budget?
A performance budget is a set of strict, quantifiable metrics for web performance, similar to a financial budget, ensuring resources stay within limits. Key examples include:
- First Contentful Paint (FCP): Time to first content rendering < 1.5 seconds.
- Total Resource Size: JavaScript/CSS/images total < 500KB.
- Request Count: HTTP requests < 30.
- Time to Interactive (TTI): Time until page is interactive < 3 seconds.
Performance budgets help teams focus on performance during development, preventing bloated bundles or sluggish pages. Main use cases:
- Constraining Development: Prevents uncontrolled code growth.
- Continuous Monitoring: Uses tools to ensure performance targets are met.
- Team Collaboration: Aligns designers, backend, and frontend on shared goals.
We’ll start with setting budgets, then measure, optimize, and monitor using tools, with examples in React and Next.js.
Environment Setup
To work with performance budgets, we’ll use Next.js (ideal for SSR and static generation). You’ll need Node.js (18.x+).
Create a Next.js project:
npx create-next-app@latest performance-budget-demo
cd performance-budget-demo
Select TypeScript, ESLint, and App Router. Project structure:
performance-budget-demo/
├── app/
│ ├── page.tsx
│ ├── layout.tsx
├── public/
├── package.json
├── next.config.js
Install performance analysis tools:
npm install --save-dev webpack-bundle-analyzer lighthouse
Run npm run dev and visit localhost:3000 to see the default page. We’ll use Lighthouse, Chrome DevTools, and custom scripts to monitor performance.
Setting a Performance Budget
A performance budget must be specific and measurable. Our budget:
- FCP: Less than 1.5 seconds.
- TTI: Less than 3 seconds.
- Bundle Size: JavaScript < 200KB, CSS < 50KB.
- Request Count: Less than 25.
- Largest Contentful Paint (LCP): Less than 2.5 seconds.
Why These Metrics?
- FCP: Impacts the user’s first impression of page speed.
- TTI: Affects how soon users can interact, critical for UX.
- Bundle Size: Directly influences load times, especially on slow networks.
- Request Count: More requests increase latency, particularly on mobile.
- LCP: Time for main content to load, key for SEO.
These metrics will guide our code and optimizations.
Measuring Initial Performance
Let’s establish a baseline. Update app/page.tsx:
export default function Home() {
return (
<div style={{ padding: 20 }}>
<h1>Performance Budget Demo</h1>
<p>Welcome to our site!</p>
<img src="https://via.placeholder.com/1000" alt="Placeholder" />
</div>
);
}
Run npm run build && npm start, then test with Lighthouse:
npx lighthouse http://localhost:3000 --output json --output-path lighthouse-report.json
The report (lighthouse-report.json) might show:
- FCP: ~1s
- TTI: ~1.5s
- LCP: ~1.2s
- Bundle Size: ~70KB (Next.js default)
- Request Count: ~5
The 1MB image slows LCP. We’ll optimize using tools and code to meet our budget.
Analyzing Bundle Size
Use webpack-bundle-analyzer to check JavaScript/CSS sizes. Update next.config.js:
const withBundleAnalyzer = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
webpack: (config, { isServer }) => {
if (!isServer) {
config.plugins.push(new withBundleAnalyzer());
}
return config;
},
};
Run ANALYZE=true npm run build and visit localhost:8888 to view bundle analysis. Next.js’s default bundle is small, but images and external dependencies may exceed the budget.
Image Compression
Use next/image to optimize images. Update app/page.tsx:
import Image from 'next/image';
export default function Home() {
return (
<div style={{ padding: 20 }}>
<h1>Performance Budget Demo</h1>
<p>Welcome to our site!</p>
<Image
src="https://via.placeholder.com/1000"
alt="Placeholder"
width={1000}
height={1000}
priority
/>
</div>
);
}
next/image compresses images and uses WebP format, reducing LCP to ~0.8s and minimizing resource size.
Code Splitting
Next.js’s App Router supports code splitting by default. Create a dynamic component:
app/components/LazyComponent.tsx:
export default function LazyComponent() {
return (
<div style={{ border: '1px solid', padding: 10 }}>
<h2>Lazy Loaded Component</h2>
<p>This is dynamically loaded!</p>
</div>
);
}
Update app/page.tsx:
import Image from 'next/image';
import dynamic from 'next/dynamic';
const LazyComponent = dynamic(() => import('./components/LazyComponent'), { ssr: false });
export default function Home() {
return (
<div style={{ padding: 20 }}>
<h1>Performance Budget Demo</h1>
<p>Welcome to our site!</p>
<Image
src="https://via.placeholder.com/1000"
alt="Placeholder"
width={1000}
height={1000}
priority
/>
<LazyComponent />
</div>
);
}
LazyComponent loads only on the client, keeping the initial bundle at ~70KB with no additional requests.
Reducing Request Count
Multiple external resources increase request counts. Simulate a complex page:
app/page.tsx:
import Image from 'next/image';
import dynamic from 'next/dynamic';
const LazyComponent = dynamic(() => import('./components/LazyComponent'), { ssr: false });
export default async function Home() {
const data = await fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json());
return (
<div style={{ padding: 20 }}>
<h1>Performance Budget Demo</h1>
<Image
src="https://via.placeholder.com/1000"
alt="Placeholder"
width={1000}
height={1000}
priority
/>
<ul>
{data.slice(0, 10).map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<LazyComponent />
</div>
);
}
Lighthouse shows ~10 requests (image, API, JS). Use Server Components to reduce client requests:
app/components/PostList.tsx:
export default async function PostList() {
const data = await fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json());
return (
<ul>
{data.slice(0, 10).map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Update app/page.tsx:
import Image from 'next/image';
import dynamic from 'next/dynamic';
import PostList from './components/PostList';
const LazyComponent = dynamic(() => import('./components/LazyComponent'), { ssr: false });
export default function Home() {
return (
<div style={{ padding: 20 }}>
<h1>Performance Budget Demo</h1>
<Image
src="https://via.placeholder.com/1000"
alt="Placeholder"
width={1000}
height={1000}
priority
/>
<PostList />
<LazyComponent />
</div>
);
}
PostList is a Server Component, with fetch running on the server, delivering HTML to the client, reducing requests to ~6.
Monitoring TTI and LCP
Use the Web Vitals library to monitor TTI and LCP. Install:
npm install web-vitals
Create app/components/Metrics.tsx:
'use client';
import { useEffect } from 'react';
import { onFCP, onLCP, onTTI } from 'web-vitals';
export default function Metrics() {
useEffect(() => {
onFCP((metric) => console.log('FCP:', metric.value));
onLCP((metric) => console.log('LCP:', metric.value));
onTTI((metric) => console.log('TTI:', metric.value));
}, []);
return null;
}
Update app/page.tsx:
import Image from 'next/image';
import dynamic from 'next/dynamic';
import PostList from './components/PostList';
import Metrics from './components/Metrics';
const LazyComponent = dynamic(() => import('./components/LazyComponent'), { ssr: false });
export default function Home() {
return (
<div style={{ padding: 20 }}>
<h1>Performance Budget Demo</h1>
<Image
src="https://via.placeholder.com/1000"
alt="Placeholder"
width={1000}
height={1000}
priority
/>
<PostList />
<LazyComponent />
<Metrics />
</div>
);
}
Run the app. The console logs FCP, LCP, and TTI values. Current results:
- FCP: ~800ms (< 1.5s, within budget)
- LCP: ~900ms (< 2.5s, within budget)
- TTI: ~1.2s (< 3s, within budget)
Performance Budget Script
Create a script to check bundle sizes. Create scripts/check-budget.js:
const fs = require('fs');
const path = require('path');
const distDir = path.join(__dirname, '../.next/static/chunks');
const budget = {
js: 200 * 1024, // 200KB
css: 50 * 1024 // 50KB
};
function getFileSize(filePath) {
return fs.statSync(filePath).size;
}
function checkBundleSize() {
let totalJs = 0;
let totalCss = 0;
fs.readdirSync(distDir).forEach(file => {
if (file.endsWith('.js')) {
totalJs += getFileSize(path.join(distDir, file));
} else if (file.endsWith('.css')) {
totalCss += getFileSize(path.join(distDir, file));
}
});
console.log(`Total JS: ${(totalJs / 1024).toFixed(2)}KB`);
console.log(`Total CSS: ${(totalCss / 1024).toFixed(2)}KB`);
if (totalJs > budget.js) {
console.error(`JS exceeds budget: ${totalJs} > ${budget.js}`);
process.exit(1);
}
if (totalCss > budget.css) {
console.error(`CSS exceeds budget: ${totalCss} > ${budget.css}`);
process.exit(1);
}
console.log('All within budget!');
}
checkBundleSize();
Update package.json:
{
"scripts": {
"build": "next build",
"start": "next start",
"dev": "next dev",
"check-budget": "node scripts/check-budget.js"
}
}
Run npm run build && npm run check-budget to verify bundle sizes. Current: JS ~70KB, CSS ~10KB, within budget.
Real-World Scenario: E-commerce Page
Create an e-commerce product page with a list, details, and cart.
app/products/page.tsx:
import Image from 'next/image';
import dynamic from 'next/dynamic';
import Metrics from '../components/Metrics';
const ProductList = dynamic(() => import('../components/ProductList'), { ssr: true });
export default async function Products() {
return (
<div style={{ padding: 20 }}>
<h1>Products</h1>
<Image
src="https://via.placeholder.com/500"
alt="Banner"
width={500}
height={200}
priority
/>
<ProductList />
<Metrics />
</div>
);
}
app/components/ProductList.tsx:
export default async function ProductList() {
const data = await fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json());
return (
<div>
<h2>Product List</h2>
<ul>
{data.slice(0, 5).map(post => (
<li key={post.id}>
<a href={`/products/${post.id}`}>{post.title}</a>
</li>
))}
</ul>
</div>
);
}
app/products/[id]/page.tsx:
import Image from 'next/image';
import { Suspense } from 'react';
import ProductDetails from '../../components/ProductDetails';
import AddToCart from '../../components/AddToCart';
export default async function ProductPage({ params }) {
const data = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`).then(res => res.json());
return (
<div style={{ padding: 20 }}>
<h1>{data.title}</h1>
<Image
src="https://via.placeholder.com/300"
alt="Product"
width={300}
height={300}
/>
<Suspense fallback={<p>Loading details...</p>}>
<ProductDetails id={params.id} />
</Suspense>
<AddToCart productId={params.id} />
</div>
);
}
app/components/ProductDetails.tsx:
export default async function ProductDetails({ id }) {
const data = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`).then(res => res.json());
return <p>{data.body}</p>;
}
app/components/AddToCart.tsx:
'use client';
import { useState } from 'react';
import { addToCartAction } from '../actions';
export default function AddToCart({ productId }) {
const [message, setMessage] = useState('');
const handleAdd = async () => {
await addToCartAction(productId);
setMessage('Added to cart!');
};
return (
<div>
<button onClick={handleAdd}>Add to Cart</button>
<p>{message}</p>
</div>
);
}
app/actions.ts:
'use server';
export async function addToCartAction(productId) {
await new Promise(resolve => setTimeout(resolve, 500));
console.log(`Added product ${productId} to cart`);
}
Run npm run dev and visit /products. The product list appears, and clicking a link shows the details page. Lighthouse results:
- FCP: ~700ms
- LCP: ~900ms
- TTI: ~1s
- Bundle: ~80KB
- Request Count: ~7
All metrics are within budget, thanks to Server Components and next/image.
Continuous Monitoring
Automate performance checks with GitHub Actions. Create /.github/workflows/performance.yml:
name: Performance Check
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm install
- run: npm run build
- run: npm start &
- run: npx lighthouse http://localhost:3000 --output json --output-path lighthouse-report.json
- run: node scripts/check-performance.js
Create scripts/check-performance.js:
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('lighthouse-report.json'));
const audits = report.audits;
const budget = {
fcp: 1500,
lcp: 2500,
tti: 3000
};
console.log('FCP:', audits['first-contentful-paint'].numericValue);
console.log('LCP:', audits['largest-contentful-paint'].numericValue);
console.log('TTI:', audits['interactive'].numericValue);
if (audits['first-contentful-paint'].numericValue > budget.fcp) {
console.error('FCP exceeds budget');
process.exit(1);
}
if (audits['largest-contentful-paint'].numericValue > budget.lcp) {
console.error('LCP exceeds budget');
process.exit(1);
}
if (audits['interactive'].numericValue > budget.tti) {
console.error('TTI exceeds budget');
process.exit(1);
}
console.log('Performance within budget!');
Push the code, and GitHub Actions runs Lighthouse to check FCP, LCP, and TTI against the budget.
Conclusion (Technical Details)
Web Performance Budgets enforce performance discipline through quantifiable metrics. The examples demonstrated:
- Setting budgets (FCP, TTI, LCP, bundle size, request count).
- Measuring with Lighthouse and
webpack-bundle-analyzer. - Optimizing images (
next/image), code splitting, and Server Components. - Monitoring TTI/LCP with Web Vitals.
- Automating checks with scripts and GitHub Actions.
- Implementing an e-commerce page.
Run these examples, inspect performance improvements with DevTools and Lighthouse, and feel the power of performance budgets!
Top comments (0)