Front-end Engineering Lab

Asset Optimization

Optimize images, fonts, and videos for maximum performance

Asset Optimization

Assets (images, fonts, videos) typically account for 50-70% of page weight. Optimizing them is critical for performance.

Image Optimization

1. Choose the Right Format

JPEG: Photos, complex images
PNG: Transparency, logos, graphics
WebP: Modern format, 25-35% smaller than JPEG/PNG
AVIF: Next-gen format, 50% smaller than JPEG (limited support)
SVG: Icons, logos, simple graphics

2. Responsive Images

// Serve different sizes based on viewport
<img
  srcSet="
    /image-320w.webp 320w,
    /image-640w.webp 640w,
    /image-1280w.webp 1280w,
    /image-1920w.webp 1920w
  "
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
  src="/image-1280w.webp"
  alt="Description"
  loading="lazy"
/>

3. Modern Image Formats with Fallback

<picture>
  <source srcSet="/image.avif" type="image/avif" />
  <source srcSet="/image.webp" type="image/webp" />
  <img src="/image.jpg" alt="Description" />
</picture>

4. Next.js Image Component

import Image from 'next/image';

// Automatic optimization, lazy loading, responsive
<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={600}
  priority  // LCP image - load immediately
  quality={85}  // Default: 75
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

// Remote images
<Image
  src="https://cdn.example.com/image.jpg"
  alt="Remote image"
  width={800}
  height={400}
  loader={({ src, width, quality }) => {
    return `${src}?w=${width}&q=${quality || 75}`;
  }}
/>

5. Image Compression

# Sharp (Node.js)
npm install sharp

# Compress JPEG
sharp('input.jpg')
  .jpeg({ quality: 85, mozjpeg: true })
  .toFile('output.jpg');

# Convert to WebP
sharp('input.jpg')
  .webp({ quality: 80 })
  .toFile('output.webp');

# Convert to AVIF
sharp('input.jpg')
  .avif({ quality: 70 })
  .toFile('output.avif');

# Resize
sharp('input.jpg')
  .resize(1280, 720, { fit: 'cover' })
  .toFile('output.jpg');

6. Lazy Loading

// Native lazy loading (modern browsers)
<img src="/image.jpg" loading="lazy" alt="Description" />

// Intersection Observer (more control)
function LazyImage({ src, alt }: { src: string; alt: string }) {
  const [loaded, setLoaded] = useState(false);
  const imgRef = useRef<HTMLImageElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setLoaded(true);
          observer.disconnect();
        }
      },
      { rootMargin: '50px' }  // Start loading 50px before visible
    );

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <img
      ref={imgRef}
      src={loaded ? src : undefined}
      alt={alt}
      style={{ opacity: loaded ? 1 : 0 }}
    />
  );
}

7. Image CDN

// Cloudinary example
const imageUrl = (publicId: string, transformations: string) => {
  return `https://res.cloudinary.com/demo/image/upload/${transformations}/${publicId}`;
};

// Usage
<img
  src={imageUrl('sample', 'w_800,h_600,c_fill,q_auto,f_auto')}
  alt="Optimized"
/>

// Imgix example
const imgixUrl = (src: string, params: Record<string, string>) => {
  const query = new URLSearchParams(params).toString();
  return `https://demo.imgix.net/${src}?${query}`;
};

<img
  src={imgixUrl('image.jpg', { w: '800', h: '600', fit: 'crop', auto: 'format,compress' })}
  alt="Optimized"
/>

8. Progressive JPEG

# ImageMagick
convert input.jpg -interlace Plane output.jpg

# Sharp
sharp('input.jpg')
  .jpeg({ progressive: true, quality: 85 })
  .toFile('output.jpg');

Font Optimization

1. Font Loading Strategy

/* font-display strategies */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
  /* 
    swap: Show fallback immediately, swap when font loads (best for most)
    optional: Only use font if cached (best for performance)
    fallback: Show fallback briefly (~100ms), swap if loaded quickly
    block: Wait for font (~3s), then show fallback (avoid)
  */
}

2. Subset Fonts

# Glyphhanger - subset to only used characters
npx glyphhanger https://example.com --formats=woff2 --subset=fonts/font.ttf

# Manual subset (Latin only)
npx glyphhanger --subset=fonts/font.ttf --latin

3. Variable Fonts

/* Single file, all weights */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;  /* All weights from 100-900 */
  font-display: swap;
}

/* Use any weight */
h1 {
  font-weight: 650;  /* Exact weight between 600 and 700 */
}

4. Preload Critical Fonts

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <link
          rel="preload"
          href="/fonts/inter-var.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

5. Next.js Font Optimization

import { Inter, Roboto_Mono } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
  display: 'swap',
});

const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  variable: '--font-roboto-mono',
  display: 'swap',
});

export default function RootLayout({ children }) {
  return (
    <html className={`${inter.variable} ${robotoMono.variable}`}>
      <body>{children}</body>
    </html>
  );
}

6. System Fonts

/* Use system fonts - instant, no download */
body {
  font-family: 
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    'Roboto',
    'Oxygen',
    'Ubuntu',
    'Cantarell',
    'Fira Sans',
    'Droid Sans',
    'Helvetica Neue',
    sans-serif;
}

Video Optimization

1. Choose the Right Format

H.264 (MP4): Universal support, good compression
H.265 (HEVC): Better compression (50%), limited support
VP9: Google's format, YouTube uses it
AV1: Next-gen, best compression (30% better than VP9), growing support

2. Lazy Load Videos

function LazyVideo({ src, poster }: { src: string; poster: string }) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && videoRef.current) {
          const video = videoRef.current;
          video.src = src;
          video.load();
          setLoaded(true);
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }
    );

    if (videoRef.current) {
      observer.observe(videoRef.current);
    }

    return () => observer.disconnect();
  }, [src]);

  return (
    <video
      ref={videoRef}
      poster={poster}
      controls
      preload="none"
      muted
      playsInline
    />
  );
}

3. Adaptive Streaming

// HLS (HTTP Live Streaming)
<video controls>
  <source src="/video/playlist.m3u8" type="application/x-mpegURL" />
</video>

// DASH (Dynamic Adaptive Streaming)
<video controls>
  <source src="/video/manifest.mpd" type="application/dash+xml" />
</video>

// Use video.js for better compatibility
import VideoJS from 'video.js';
import 'video.js/dist/video-js.css';

<video-js
  data-setup='{"fluid": true}'
  controls
  preload="auto"
>
  <source src="/video/playlist.m3u8" type="application/x-mpegURL" />
</video-js>

4. Animated Content: Video > GIF

GIF: 2-3 MB
MP4: 200-300 KB (10x smaller!)

Use <video> instead of GIF for animations
// Replace GIF with video
<video
  autoPlay
  loop
  muted
  playsInline
  style={{ width: '100%', height: 'auto' }}
>
  <source src="/animation.mp4" type="video/mp4" />
  <source src="/animation.webm" type="video/webm" />
</video>

5. Cloudflare Stream / Mux

// Cloudflare Stream
<iframe
  src="https://iframe.videodelivery.net/VIDEO_ID"
  style={{ border: 'none', position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
  allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture"
  allowFullScreen
/>

// Mux
import MuxPlayer from '@mux/mux-player-react';

<MuxPlayer
  playbackId="PLAYBACK_ID"
  metadata={{
    video_title: "Video Title",
    viewer_user_id: "user-id",
  }}
  streamType="on-demand"
  poster="/poster.jpg"
/>

Best Practices

Images

  1. WebP/AVIF: Use modern formats with fallbacks
  2. Responsive: Serve appropriate sizes
  3. Lazy Load: Load images as needed
  4. Compress: 80-85% quality is usually fine
  5. CDN: Use image CDN for automatic optimization
  6. Dimensions: Always specify width/height to prevent layout shift
  7. Priority: Mark LCP images with priority

Fonts

  1. Subset: Remove unused characters
  2. Variable Fonts: One file, all weights
  3. font-display: swap: Prevent invisible text
  4. Preload: Only preload critical fonts
  5. Limit: 2-3 font families maximum
  6. System Fonts: Consider for performance

Videos

  1. Lazy Load: Load when visible
  2. Adaptive Streaming: HLS/DASH for long videos
  3. Compress: Use H.265/VP9/AV1
  4. Poster: Always include a poster image
  5. Video > GIF: Replace GIFs with videos
  6. CDN: Use video CDN (Cloudflare, Mux)
  7. Preload: preload="none" for below-fold videos

Measurement

// Measure asset transfer sizes
performance.getEntriesByType('resource').forEach(entry => {
  if (entry.initiatorType === 'img' || entry.initiatorType === 'video') {
    console.log(entry.name, `${(entry.transferSize / 1024).toFixed(2)} KB`);
  }
});

// Check font loading
document.fonts.ready.then(() => {
  console.log('All fonts loaded');
  document.fonts.forEach(font => {
    console.log(font.family, font.weight, font.status);
  });
});

Optimizing assets is the fastest way to improve page load performance—start here first!

On this page