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:
- Is it a vector graphic (icon, logo, illustration)? → SVG
- Does it need animation? → WebP (animated) or GIF (if compatibility required)
- Is AVIF supported by your users? → AVIF with WebP/JPEG fallback
- 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:
- Compressed (use H.264 or H.265/HEVC for compatibility, VP9/AV1 for modern browsers)
- Lazy loaded
- 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
- Core Web Vitals - How assets affect LCP and CLS
- Loading Strategies - Lazy loading and preloading patterns
- Caching Strategies - Cache headers for static assets
- Docker Deployment - Configuring compression in containers
- Tailwind CSS - CSS purging and optimization