Skip to content
➜cat blog/performance-budgets-web-apps.md

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.

10 min

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:

MetricBudget
Total JS (compressed)< 150 KB
Total CSS (compressed)< 30 KB
Largest image< 200 KB
LCP< 2.0s
TBT< 200ms
CLS0

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 native Intl API.
  • 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:

  1. Use <NuxtImage> or equivalent. It generates responsive srcset with AVIF/WebP formats automatically.
  2. Set explicit dimensions. width and height attributes prevent layout shift.
  3. Lazy load below-the-fold images. loading="lazy" is native and works in all modern browsers.
  4. Eager load the LCP image. The hero image or first visible image should have loading="eager" and fetchpriority="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:

  1. Preload the primary weights. The fonts you use above the fold.
  2. Use font-display: swap. Shows a system font immediately, swaps to the custom font when loaded. The flash is preferable to invisible text.
  3. 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.