Performance Budgets: How I Keep Web Apps Fast
Bundle analysis, lazy loading strategies, image optimization, and the metrics I track from day one of every project.
Performance degrades gradually. No single commit makes a site slow. It's the accumulation of a third-party script here, an unoptimized image there, a new dependency that pulls in 80KB of code you use 5% of. By the time someone notices, the bundle is 2MB and the LCP is 4 seconds.
Performance budgets prevent this by making the cost of every addition visible. I set them on day one of every project and treat violations as build failures.
The Metrics That Matter
Core Web Vitals are the framework. Three numbers that capture what users actually experience:
LCP (Largest Contentful Paint): how quickly the main content appears. Target: under 2.5 seconds. This is the most important metric for perceived speed.
INP (Interaction to Next Paint): how quickly the page responds to user input. Target: under 200ms. Replaced FID in 2024 and is a better measure of runtime responsiveness.
CLS (Cumulative Layout Shift): how much the page layout jumps around during load. Target: under 0.1. Layout shifts are disorienting and erode trust.
I also track Total Blocking Time (TBT) during development as a proxy for INP, and Time to First Byte (TTFB) to catch server-side bottlenecks.
Setting the Budget
My starting budgets for a content-focused site:
| Metric | Budget |
|---|---|
| Total JS (compressed) | < 150 KB |
| Total CSS (compressed) | < 30 KB |
| Largest image | < 200 KB |
| LCP | < 2.0s |
| TBT | < 200ms |
| CLS | 0 |
For an interactive application (dashboard, editor), I relax the JS budget to 250KB but keep everything else tight.
These numbers aren't arbitrary. 150KB of compressed JavaScript is roughly 500KB parsed, which takes about 200ms to parse on a mid-range phone. That leaves room for the framework, one or two libraries, and your application code.
Bundle Analysis
I run bundle analysis on every project. In Nuxt:
npx nuxt analyze
This generates a treemap showing exactly what's in the bundle and how much space each dependency consumes. Common findings:
- Lodash imported wholesale.
import _ from 'lodash'pulls in 70KB.import debounce from 'lodash/debounce'pulls in 1KB. - Moment.js with all locales. 300KB. Use
dayjs(2KB) or the nativeIntlAPI. - Icon libraries imported entirely.
import * as Icons from 'lucide-vue-next'bundles every icon. Named imports tree-shake correctly. - Duplicate dependencies. Two versions of the same library because of transitive dependency conflicts.
I review the analysis after every new dependency is added. If a dependency increases the bundle by more than 10KB compressed, I evaluate whether it's worth it or if a lighter alternative exists.
Lazy Loading
Not everything needs to be in the initial bundle. Code-splitting by route is automatic in Nuxt and Next.js. The additional step I take: lazy-loading heavy components within routes.
<script setup lang="ts">
const HeavyChart = defineAsyncComponent(() => import('~/components/HeavyChart.vue'))
</script>
<template>
<div>
<h2>Analytics</h2>
<Suspense>
<HeavyChart :data="chartData" />
<template #fallback>
<div class="h-64 bg-surface-card animate-pulse rounded-sm" />
</template>
</Suspense>
</div>
</template>
The chart component, often 50-100KB with a charting library, loads only when the component renders. The fallback shows a placeholder skeleton that matches the final layout (preventing CLS).
Components I always lazy-load:
- Charting libraries
- Rich text editors
- Map embeds
- Code editors
- Anything below the fold that the user might not scroll to
Image Optimization
Images are the largest payload on most pages. The strategy:
- Use
<NuxtImage>or equivalent. It generates responsivesrcsetwith AVIF/WebP formats automatically. - Set explicit dimensions.
widthandheightattributes prevent layout shift. - Lazy load below-the-fold images.
loading="lazy"is native and works in all modern browsers. - Eager load the LCP image. The hero image or first visible image should have
loading="eager"andfetchpriority="high".
<NuxtImage
src="/images/hero.jpg"
alt="Project screenshot"
width="1200"
height="630"
loading="eager"
fetchpriority="high"
format="avif,webp"
quality="80"
/>
Font Loading
Custom fonts block rendering if loaded incorrectly. My approach:
- Preload the primary weights. The fonts you use above the fold.
- Use
font-display: swap. Shows a system font immediately, swaps to the custom font when loaded. The flash is preferable to invisible text. - Subset the font. If you only use Latin characters, don't load Cyrillic and Greek subsets.
<link rel="preload" href="/fonts/Geist-Regular.woff2" as="font" type="font/woff2" crossorigin />
This portfolio preloads four font files (regular, semibold, bold, mono regular). Total font payload: about 120KB. Significant, but these fonts are the site's visual identity, so the cost is justified.
Monitoring in Production
Budgets are useless without monitoring. I use a combination of:
- Lighthouse CI in the deployment pipeline, which blocks deploys that regress beyond thresholds
- Search Console Core Web Vitals report for real user data from Chrome, aggregated by page
- Manual checks with Chrome DevTools Performance tab after significant changes
The real-user data from Search Console matters most. Lab tests (Lighthouse) run on powerful machines with fast connections. Real users are on three-year-old phones with spotty LTE. If the lab says 90 and real users say 60, believe the real users.
The Hardest Part
Saying no. Every third-party script, analytics tool, chat widget, and A/B testing library adds weight. The conversation with stakeholders is always "this tool provides X value" versus "this tool adds Y milliseconds to every page load."
I frame it in user terms: "Adding this script will make the site take an extra half-second to become interactive for users on mobile. Is the feature worth that trade-off?" Sometimes it is. Often, it isn't. The budget makes the trade-off visible instead of invisible.