A

Asset Optimization

performanceimagesfontswebpavifoptimizationresponsive-images

Asset Optimization

Images and fonts typically account for 50-80% of page weight. Optimizing these assets delivers outsized performance gains compared to code optimization. A single unoptimized hero image can negate all your JavaScript splitting efforts.

Image Optimization

Format Selection

Modern image formats offer dramatically better compression than legacy formats:

Format Use Case Browser Support Typical Savings
AVIF Photos, complex images 92% (all modern browsers) 50-70% vs JPEG
WebP Photos, graphics 97% 25-35% vs JPEG
JPEG Fallback for photos 100% Baseline
PNG Graphics needing transparency 100% Baseline
SVG Icons, logos, illustrations 100% Vector, scales perfectly

Decision tree:

  1. Is it a vector graphic (icon, logo, illustration)? → SVG
  2. Does it need animation? → WebP (animated) or GIF (if compatibility required)
  3. Is AVIF supported by your users? → AVIF with WebP/JPEG fallback
  4. Otherwise → WebP with JPEG fallback

Using the <picture> Element

Serve modern formats with fallbacks:

<picture>
  <!-- AVIF for modern browsers (smallest file) -->
  <source srcset="/hero.avif" type="image/avif">

  <!-- WebP fallback -->
  <source srcset="/hero.webp" type="image/webp">

  <!-- JPEG for older browsers -->
  <img
    src="/hero.jpg"
    alt="Hero image description"
    width="1200"
    height="600"
    loading="lazy"
  >
</picture>

The browser selects the first supported format. Always include dimensions to prevent layout shift.

Responsive Images with srcset

Serve appropriately sized images for each device:

<!-- Resolution switching: same image, different sizes -->
<img
  srcset="
    /photo-400.webp 400w,
    /photo-800.webp 800w,
    /photo-1200.webp 1200w,
    /photo-1600.webp 1600w
  "
  sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 800px"
  src="/photo-800.webp"
  alt="Photo"
  width="1600"
  height="900"
  loading="lazy"
>

How sizes works:

  • At viewport ≤600px: image is 100% of viewport width
  • At viewport ≤1200px: image is 50% of viewport width
  • Otherwise: image displays at 800px

The browser picks the smallest image that satisfies the calculated display width × device pixel ratio.

Art Direction with <picture>

Different crops for different screens:

<picture>
  <!-- Square crop for mobile -->
  <source
    media="(max-width: 600px)"
    srcset="/hero-mobile.avif 600w"
    type="image/avif"
  >
  <source
    media="(max-width: 600px)"
    srcset="/hero-mobile.webp 600w"
    type="image/webp"
  >

  <!-- Wide crop for desktop -->
  <source srcset="/hero-wide.avif 1600w" type="image/avif">
  <source srcset="/hero-wide.webp 1600w" type="image/webp">

  <img
    src="/hero-wide.jpg"
    alt="Hero"
    width="1600"
    height="600"
  >
</picture>

Image Compression Tools

Build-time optimization (recommended):

# sharp-cli for batch processing
pnpm add -D sharp-cli

# Convert and optimize
sharp -i input.jpg -o output.avif --quality 75
sharp -i input.jpg -o output.webp --quality 80

Vite plugin:

// vite.config.ts
import { defineConfig } from 'vite';
import { imagetools } from 'vite-imagetools';

export default defineConfig({
  plugins: [
    imagetools({
      defaultDirectives: (url) => {
        if (url.searchParams.has('hero')) {
          return new URLSearchParams({
            format: 'avif;webp;jpg',
            w: '400;800;1200;1600',
            quality: '75',
          });
        }
        return new URLSearchParams();
      },
    }),
  ],
});

Usage:

import heroAvif from './hero.jpg?hero&format=avif';
import heroWebp from './hero.jpg?hero&format=webp';

Next.js Image component (automatic optimization):

import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority // For LCP images
/>

Next.js automatically serves WebP/AVIF and generates multiple sizes.

Astro Image:

---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---

<Image
  src={heroImage}
  alt="Hero image"
  widths={[400, 800, 1200]}
  formats={['avif', 'webp']}
/>

Placeholder Strategies

Show something meaningful while the full image loads.

Blur-up placeholders (Low Quality Image Placeholder - LQIP):

<!-- Inline tiny blurred placeholder -->
<div class="image-container">
  <img
    src="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
    class="placeholder"
    alt=""
    aria-hidden="true"
  >
  <img
    src="/photo.jpg"
    class="main-image"
    alt="Photo"
    loading="lazy"
    onload="this.classList.add('loaded')"
  >
</div>
.image-container {
  position: relative;
}

.placeholder {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  filter: blur(20px);
  transform: scale(1.1);
}

.main-image {
  opacity: 0;
  transition: opacity 0.3s;
}

.main-image.loaded {
  opacity: 1;
}

Generate LQIP with sharp:

// scripts/generate-lqip.js
import sharp from 'sharp';

async function generateLQIP(inputPath) {
  const buffer = await sharp(inputPath)
    .resize(20) // Very small
    .blur(5)
    .jpeg({ quality: 20 })
    .toBuffer();

  return `data:image/jpeg;base64,${buffer.toString('base64')}`;
}

Dominant color placeholder:

import sharp from 'sharp';

async function getDominantColor(imagePath) {
  const { dominant } = await sharp(imagePath).stats();
  return `rgb(${dominant.r}, ${dominant.g}, ${dominant.b})`;
}
<img
  src="/photo.jpg"
  alt="Photo"
  style="background-color: rgb(142, 89, 67);"
  loading="lazy"
>

Blurhash (compact blur encoding):

// Generate blurhash
import { encode } from 'blurhash';
import sharp from 'sharp';

async function generateBlurhash(imagePath: string): Promise<string> {
  const { data, info } = await sharp(imagePath)
    .raw()
    .ensureAlpha()
    .resize(32, 32, { fit: 'inside' })
    .toBuffer({ resolveWithObject: true });

  return encode(
    new Uint8ClampedArray(data),
    info.width,
    info.height,
    4, // X components
    3  // Y components
  );
}
// Decode and display
import { Blurhash } from 'react-blurhash';

<div style={{ position: 'relative' }}>
  <Blurhash
    hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
    width={400}
    height={300}
    resolutionX={32}
    resolutionY={32}
    punch={1}
  />
  <img
    src="/photo.jpg"
    alt="Photo"
    loading="lazy"
    onLoad={(e) => e.target.style.opacity = 1}
    style={{ position: 'absolute', inset: 0, opacity: 0 }}
  />
</div>

SVG Optimization

SVGs from design tools contain unnecessary metadata. Optimize with SVGO:

# Install SVGO
pnpm add -D svgo

# Optimize a single file
npx svgo icon.svg -o icon.min.svg

# Optimize a directory
npx svgo -f ./icons -o ./icons-optimized

Configure in svgo.config.js:

// svgo.config.js
export default {
  plugins: [
    'preset-default',
    'removeDimensions',
    {
      name: 'removeAttrs',
      params: {
        attrs: '(stroke|fill)', // Remove if using CSS for colors
      },
    },
  ],
};

Typical savings: 30-60% file size reduction.

Inline SVGs for icons:

// components/Icon.tsx
const icons = {
  search: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
      <circle cx="11" cy="11" r="8" />
      <path d="m21 21-4.35-4.35" />
    </svg>
  ),
  // ...
};

export function Icon({ name, className }: { name: keyof typeof icons; className?: string }) {
  return (
    <span className={className} aria-hidden="true">
      {icons[name]}
    </span>
  );
}

Font Optimization

Self-Hosting vs. CDN

Self-hosting advantages:

  • No external requests (faster TTFB)
  • Better caching control
  • Privacy (no Google tracking)
  • Works offline

Google Fonts advantages:

  • No build setup
  • Automatic subsetting for some languages
  • Cross-site caching (minimal benefit with cache partitioning)

Recommendation: Self-host for production sites. Use Google Fonts only for prototyping.

Converting and Hosting Fonts

Get WOFF2 files (best compression) from Google Fonts:

# Use google-webfonts-helper
# Visit: https://gwfh.mranftl.com/fonts

# Or download directly and convert
# Most foundries provide WOFF2 directly now

Host in your project:

public/
  fonts/
    inter-var.woff2
    inter-var-italic.woff2

Font-Face Declarations

/* Optimal font-face setup */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900; /* Variable font weight range */
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
    U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC,
    U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Optional: Italic variant */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var-italic.woff2') format('woff2');
  font-weight: 100 900;
  font-style: italic;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}

Font Subsetting

Remove characters you don't need to reduce file size:

# Install fonttools
pip install fonttools brotli

# Subset to Latin characters only
pyftsubset Inter-Regular.ttf \
  --output-file=Inter-Regular-latin.woff2 \
  --flavor=woff2 \
  --layout-features='*' \
  --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"

Or use Glyphhanger to subset based on actual page content:

# Analyze a page and create a subset
npx glyphhanger https://example.com --subset=*.ttf --formats=woff2

Typical savings: 60-80% for Latin-only subsets.

font-display Strategies

Control how fonts load and swap:

Value Behavior Layout Shift Use When
swap Fallback immediately, swap when loaded Yes Default choice
optional Fallback, swap only if loaded in ~100ms Minimal Best for CLS
fallback 100ms invisible, then fallback, limited swap Moderate Compromise
block 3s invisible, then fallback None, but FOIT Never use
auto Browser decides Varies Avoid

For best CLS: Use font-display: optional:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: optional;
}

The font only applies if it loads within ~100ms. Slower connections see the fallback, but there's no layout shift.

Fallback Font Matching

Minimize reflow when switching from fallback to custom font:

/* Size-adjusted fallback */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107.64%;
  ascent-override: 90%;
  descent-override: 22.43%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}

Generate these metrics with Fontaine:

npx fontaine ./public/fonts/inter.woff2

Or use Capsize for precise font metrics.

Preloading Fonts

Critical fonts should be preloaded:

<head>
  <link
    rel="preload"
    href="/fonts/inter-var.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  >
</head>

Important: crossorigin is required even for same-origin fonts (browser quirk).

Preload only 1-2 fonts used above the fold. Don't preload italic/bold variants unless immediately visible.

Variable Fonts

Variable fonts include multiple weights/styles in a single file:

/* One file replaces Regular, Medium, Bold, etc. */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}

/* Use any weight */
h1 { font-weight: 700; }
p { font-weight: 400; }
.light { font-weight: 300; }

Variable font file sizes are larger than single-weight files, but smaller than multiple weight files combined:

  • Inter Regular: ~100KB
  • Inter Regular + Medium + Bold: ~300KB
  • Inter Variable: ~250KB (all weights)

Use variable fonts if you need 3+ weights.

Other Static Assets

JavaScript and CSS

Handled primarily by your bundler. Key optimizations:

// vite.config.ts
export default defineConfig({
  build: {
    // Minification (default: esbuild)
    minify: 'esbuild',

    // CSS code splitting
    cssCodeSplit: true,

    // Source maps for production debugging
    sourcemap: true,

    // Target modern browsers for smaller output
    target: 'es2020',
  },
});

For CSS, use PurgeCSS or Tailwind's built-in purging to remove unused styles.

Video Optimization

Videos should be:

  1. Compressed (use H.264 or H.265/HEVC for compatibility, VP9/AV1 for modern browsers)
  2. Lazy loaded
  3. Served from a CDN or video platform
<video
  controls
  preload="none"
  poster="/video-poster.webp"
  width="1920"
  height="1080"
>
  <source src="/video.webm" type="video/webm">
  <source src="/video.mp4" type="video/mp4">
</video>

For complex video needs, consider platforms like Mux, Cloudinary, or Cloudflare Stream that handle transcoding, adaptive bitrate streaming, and delivery.

Audio Optimization

<audio controls preload="none">
  <source src="/audio.opus" type="audio/opus">
  <source src="/audio.mp3" type="audio/mpeg">
</audio>

Opus offers better compression than MP3 with similar support.

Compression

Ensure your server compresses text-based assets:

Nginx:

gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;

# Brotli (if module installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml;

Node.js (Express):

import compression from 'compression';
app.use(compression());

Pre-compress assets at build time for best results:

// vite.config.ts
import viteCompression from 'vite-plugin-compression';

export default defineConfig({
  plugins: [
    viteCompression({ algorithm: 'gzip' }),
    viteCompression({ algorithm: 'brotliCompress' }),
  ],
});

This creates .gz and .br files that nginx/CDNs can serve directly.

See Also

Last updated: March 23, 2026