PAUL CHONGSenior Software Engineer

Image Optimization

2026-08-08 · 8 min read

Images are the largest assets on most pages and the biggest contributor to slow load times and layout shift. Optimizing them is one of the highest-leverage performance investments available — and most of it is declarative HTML with no JavaScript required.

Loading

Native lazy loading

<img src="photo.jpg" loading="lazy" alt="..." />

loading="lazy" defers loading until the image is near the viewport. It's a one-attribute win for any image below the fold. The browser decides when "near" is — typically a few hundred pixels — which is good enough for most cases.

IntersectionObserver

When you need more control — loading images slightly before they enter the viewport to avoid a visible pop-in — use IntersectionObserver with a rootMargin:

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        observer.unobserve(img);
      }
    });
  },
  { rootMargin: '200px' }
);

document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));

The 200px margin triggers loading 200px before the image enters the viewport, giving the browser a head start. This prevents the brief blank-then-loaded flash that native lazy loading can produce on fast scrolls.

Prioritizing LCP images

For images that are immediately visible — hero images, the first post in a feed, the primary product photo — tell the browser to fetch them before other resources:

<!-- fetchpriority hint on the img itself -->
<img src="hero.jpg" fetchpriority="high" alt="..." />

<!-- Or preload in the <head> for SSR'd pages -->
<link rel="preload" as="image" href="hero.jpg" />

fetchpriority="high" moves the image up in the browser's fetch queue. <link rel="preload"> goes further — it's discovered by the browser's preload scanner, a lightweight second pass that looks ahead in the HTML stream and dispatches fetches before the main parser has even reached the <img> tag.

Both are hints for your LCP (Largest Contentful Paint) image — the one the browser uses to measure when the page feels loaded. Prioritizing it directly improves your LCP score.

Preventing layout shift

Layout shift happens when an image loads and pushes content down because the browser didn't reserve space for it. The fix is to tell the browser the image's dimensions before it downloads.

Width and height attributes

<img src="photo.jpg" width="800" height="600" alt="..." />

With explicit width and height, the browser computes the aspect ratio (600/800 = 0.75) and reserves a box of the correct proportions before the image loads. Content below the image doesn't move. This is the single most impactful CLS fix.

CSS aspect-ratio

The width/height attribute approach works when you can hardcode specific pixel values in HTML. But in a feed where every image has different dimensions and the layout is fluid (the image stretches to fill its column), you can't hardcode width="1200" height="800" — the actual rendered size depends on the column width, which varies by screen.

The solution is to set the aspect ratio on a container element and let the image fill it:

.image-container {
  aspect-ratio: 4 / 3;
  width: 100%;
  overflow: hidden;
}

.image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

aspect-ratio: 4 / 3 tells the browser: whatever width this container ends up being, make the height 3/4 of that. Space is reserved immediately, before the image downloads, so no layout shift occurs.

If the API returns the image's intrinsic dimensions, compute the ratio dynamically in JSX rather than hardcoding 4 / 3:

<div style={{ aspectRatio: `${image.intrinsicWidth} / ${image.intrinsicHeight}`, width: '100%' }}>
  <img src={image.src} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="..." />
</div>

If the API returns no dimensions at all, a fixed ratio like 16 / 9 or 4 / 3 is an approximation — it won't be exact for every image, but it prevents the worst CLS by at least reserving some space before the image arrives.

Masonry layouts

Masonry is the hardest case: the layout algorithm needs to know each image's height before placing it in a column, but heights are unknown until images load. The solution is to include intrinsicWidth and intrinsicHeight in the API response so the client can compute the display height from the aspect ratio before any image downloads:

// API returns:
{ id: 1, src: "photo.jpg", intrinsicWidth: 1200, intrinsicHeight: 800 }

// Client computes:
const displayHeight = (columnWidth / item.intrinsicWidth) * item.intrinsicHeight;

With display heights known upfront, the masonry layout can be computed and rendered as skeleton placeholders, and images load into already-sized boxes with zero layout shift.

Responsive images

Serving a 2000px image to a 400px mobile screen wastes bandwidth and slows load time. srcset and sizes tell the browser which image to download for the current screen:

<img
  src="photo-800w.jpg"
  srcset="photo-400w.jpg 400w, photo-800w.jpg 800w, photo-1600w.jpg 1600w"
  sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1600px"
  alt="Photo description"
/>

srcset lists available image files and their widths. sizes tells the browser how wide the image will be rendered at each breakpoint. The browser picks the best match — accounting for display pixel density — and downloads only that file.

For art direction — serving a different crop on mobile vs desktop, not just a different size — use <picture>:

<picture>
  <source media="(max-width: 600px)" srcset="photo-square.jpg" />
  <source media="(min-width: 601px)" srcset="photo-landscape.jpg" />
  <img src="photo-landscape.jpg" alt="..." />
</picture>

Modern formats

File size varies dramatically by format for identical visual quality. From smallest to largest:

FormatCompressionSupport
AVIFBestGood (not IE, older Safari)
WebPVery goodExcellent
JPEGBaselineUniversal

Use a <picture> fallback chain so modern browsers get the smallest file and older browsers fall back gracefully:

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

The browser picks the first <source> whose type it supports. JPEG as the <img> fallback ensures universal compatibility.

Adaptive loading

Network conditions vary widely. Serving the same image to a user on WiFi and a user on 2G wastes bandwidth and increases load time for the constrained user.

const connection = navigator.connection?.effectiveType;

function getImageSrc(baseSrc) {
  if (connection === '2g' || connection === 'slow-2g') {
    return `${baseSrc}?quality=20`; // low-res placeholder, user clicks for full
  }
  return baseSrc;
}

On good connections, prefetch offscreen images that are likely to enter the viewport soon. On poor connections, don't — let the user explicitly request full resolution by tapping.

navigator.connection is not universally supported (notably absent in Safari), so always treat it as a progressive enhancement — if the API is unavailable, serve the full image.

Upload-side optimization

Client-side processing before upload reduces bandwidth, fixes orientation issues, and strips sensitive metadata.

Strip EXIF metadata

Photos taken on mobile devices embed GPS coordinates, device model, timestamp, and other metadata in the EXIF headers. Re-encoding through a <canvas> strips it:

async function stripExif(file) {
  const bitmap = await createImageBitmap(file);
  const canvas = document.createElement('canvas');
  canvas.width = bitmap.width;
  canvas.height = bitmap.height;
  canvas.getContext('2d').drawImage(bitmap, 0, 0);

  return new Promise((resolve) =>
    canvas.toBlob(resolve, 'image/jpeg', 0.9)
  );
}

Fix EXIF orientation

iOS cameras write rotation into the EXIF orientation flag rather than rotating the pixel data. Without correction, portraits render sideways on other platforms. Reading and applying the orientation flag before re-encoding fixes this — or re-encoding through <canvas> (as above) naturally bakes in the correct orientation since drawImage renders it visually correct.

Client-side resize

There's no reason to upload a 4000px image if the maximum display size is 800px. Resize before upload to reduce upload time, especially on mobile:

async function resizeForUpload(file, maxWidth = 1200) {
  const bitmap = await createImageBitmap(file);
  const scale = Math.min(1, maxWidth / bitmap.width);
  const canvas = document.createElement('canvas');
  canvas.width = bitmap.width * scale;
  canvas.height = bitmap.height * scale;
  canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);

  return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
}

CDN delivery

Even perfectly optimized images are slow if served from a single origin far from the user. A CDN solves this at the infrastructure layer:

  • Geographic distribution: Serve from an edge node close to the user. A request from Tokyo hits a Tokyo node, not a US origin server.
  • Format negotiation: CDNs can inspect the Accept header and automatically serve AVIF or WebP to browsers that support them, without <picture> tags in the HTML.
  • Resize on demand: Request photo.jpg?w=400 and the CDN generates and caches the 400px variant. No pre-generating every size at upload time.
  • Aggressive caching: Images are content-addressable (filename includes a hash) — serve with Cache-Control: max-age=31536000, immutable for a one-year cache. The CDN layer absorbs repeat requests without hitting the origin.

The CDN and client-side optimizations are complementary: srcset tells the browser which size to request; the CDN generates and serves it efficiently.