Skip to content
lazy devs
5 min readLazy Devs

Image optimization for fast websites

A practical guide to image formats, sizing, lazy loading, and the Next.js Image component, and how each one moves your Core Web Vitals.

Open the network tab on almost any "slow" site and the story is the same: a handful of images that each weigh more than the entire rest of the page combined. You can spend a week shaving milliseconds off your JavaScript bundle, but a single 4MB hero photo will undo all of it before the browser even parses your code. Image optimization is the highest-leverage performance work most teams never finish, so let us go through it properly.

Why images dominate your performance budget

Most of your Largest Contentful Paint (LCP) score is a single image. The hero, the article cover, the product photo at the top of the page: that element is usually what the browser measures, and if it arrives late, your LCP is bad no matter how fast everything else is.

The math is brutal. A photo straight out of a phone camera is 3 to 8MB. On a typical mobile connection that is several seconds of download for one file. Multiply by the dozen images on a landing page and you have a site that feels broken even though nothing is technically wrong.

The good news: images are also the easiest big win. You rarely need to refactor anything. You change formats, sizes, and loading behavior, and the page gets dramatically faster.

Pick the right format

Format choice alone can cut file size by half or more before you touch dimensions.

  • AVIF is the smallest for photographs, often 30 to 50 percent below WebP at similar quality. Encoding is slower, but that happens once at build or upload time, so it does not matter.
  • WebP is the safe default. It is smaller than JPEG and PNG and supported by every browser you care about in 2025.
  • JPEG is the universal fallback. Keep it for older clients.
  • PNG only for images that need real transparency or sharp edges (logos, screenshots of UI). For photos, PNG is almost always the wrong choice and balloons file size.
  • SVG for icons, logos, and anything vector. It scales infinitely and is usually a few hundred bytes.

The browser-native way to serve modern formats with a fallback is the <picture> element. The browser picks the first source it can decode:

<picture>
  <source srcset="/hero.avif" type="image/avif" />
  <source srcset="/hero.webp" type="image/webp" />
  <img
    src="/hero.jpg"
    alt="Team reviewing a dashboard"
    width="1200"
    height="630"
    loading="eager"
    fetchpriority="high"
  />
</picture>

Note the width and height attributes. They are not optional. Without them the browser does not know how much space to reserve, the layout jumps when the image loads, and your Cumulative Layout Shift (CLS) score suffers.

Serve the right size, not the original

A 4000px-wide image displayed in a 600px column is wasted bytes. You want the browser to download something close to the size it will actually render, accounting for the device pixel ratio.

Responsive images solve this with srcset and sizes. You provide several widths and tell the browser how wide the image will be at different breakpoints, and it picks the smallest file that still looks sharp:

<img
  src="/photo-800.webp"
  srcset="/photo-400.webp 400w, /photo-800.webp 800w, /photo-1600.webp 1600w"
  sizes="(max-width: 768px) 100vw, 800px"
  alt="Product screenshot"
  width="800"
  height="600"
  loading="lazy"
/>

A phone on a 2x display reading sizes of 100vw will grab the 800w file. A desktop showing the image at a fixed 800px also grabs 800w. Nobody downloads the 1600w version unless they actually need the detail. You are no longer shipping desktop-sized images to phones.

Lazy load everything below the fold

Images that are not visible on first paint should not block it. The native loading="lazy" attribute defers them until the user scrolls near them. It costs you one word per tag.

The important nuance: do not lazy load your LCP image. Your hero should load eagerly with high priority so it arrives as fast as possible. Lazy loading it tells the browser to wait, which is the opposite of what you want for the one image that defines your LCP. Use loading="eager" and fetchpriority="high" there, and loading="lazy" on the rest.

Let Next.js do the heavy lifting

If you are on Next.js, the next/image component handles most of this for you: it generates responsive srcset entries, serves AVIF or WebP based on the request headers, lazy loads by default, and reserves space to prevent layout shift. You mostly just need to use it correctly.

import Image from "next/image";
 
export function ArticleHero() {
  return (
    <Image
      src="/covers/optimization.jpg"
      alt="Network panel showing image transfer sizes"
      width={1200}
      height={630}
      priority
      sizes="(max-width: 768px) 100vw, 1200px"
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/4AAQSk..."
    />
  );
}

Three things matter here. priority marks this as the LCP image, so Next.js loads it eagerly and skips lazy loading: use it on exactly one image per page, the one above the fold. sizes is what makes the responsive srcset actually useful, and if you omit it on a non-fixed-width image, Next.js falls back to 100vw and over-serves. placeholder="blur" shows a tiny blurred version while the full image loads, which feels instant and avoids a blank box.

For remote images you also need to allow the host in next.config.js, otherwise the optimizer refuses to touch them:

import type { NextConfig } from "next";
 
const config: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    remotePatterns: [
      { protocol: "https", hostname: "cdn.example-store.net" },
    ],
  },
};
 
export default config;

One caveat worth knowing: the built-in optimizer runs on your server (or your Vercel function), so a flood of unique image requests costs compute. If you serve a lot of user-uploaded images, point loader at a dedicated image CDN (Cloudinary, imgix, or Vercel's own) and let it handle transformation and caching at the edge instead.

Compress before you ship

Even after picking AVIF or WebP, the quality setting matters. Quality 75 to 80 is the sweet spot for photographs: visually indistinguishable from the original to almost everyone, at a fraction of the bytes. Going above 85 buys you almost nothing but weight.

For a build-time pipeline, sharp is the standard tool in the Node world and is fast because it wraps libvips:

import sharp from "sharp";
 
await sharp("input.jpg")
  .resize({ width: 1600, withoutEnlargement: true })
  .avif({ quality: 60 })
  .toFile("output.avif");

withoutEnlargement stops sharp from upscaling a small source into a bigger, blurrier, heavier file. AVIF quality 60 looks roughly like JPEG quality 80 because the codecs scale differently, so do not panic at the low number.

Measure, do not guess

Run Lighthouse or open the network tab and sort by transfer size. The offenders announce themselves immediately. Watch three numbers: LCP (is your hero arriving fast?), CLS (is the page jumping as images load?), and total image weight (how many megabytes are you actually shipping?). Fix the biggest file first, re-measure, repeat. Most sites get the bulk of their improvement from the top three or four images.

Takeaway

You do not need a fancy setup to get fast images. Serve modern formats with a fallback, size them to how they actually render, lazy load everything except the LCP image, and compress at quality 75 to 80. If you are on Next.js, next/image gives you most of this for free as long as you set priority and sizes correctly. Start with your heaviest file and measure after each change.

If you want a second set of eyes on a slow page, performance and SEO engineering is the kind of thing we untangle quickly.

Related service

Performance & SEO Engineering

Top Core Web Vitals and search visibility, by design.

Learn more

Want this built right?

This is the work we do every day. Tell us what you are building and we will show you exactly how we would ship it.

hello@lazydevsagency.com