On the median mobile home page, images weigh 911 KB, more than its 632 KB of JavaScript, in the HTTP Archive’s 2025 Web Almanac. An image optimization checklist for websites has six lines: size each image to its slot, serve WebP or AVIF, compress, send responsive sources, lazy load below the fold only, and cache what you serve.

The image optimization checklist for websites: six lines, in the order that saves the most

An image optimization checklist for a website has six lines, ordered by how many bytes each one saves: serve no image much wider than twice its slot (my working rule), use WebP or AVIF with a fallback, compress by eye at the intended size, send responsive sources, lazy load below the fold only, and cache hashed files for a year.

Those home page figures come from the page weight chapter of the HTTP Archive’s 2025 Web Almanac, and they carry a condition: on inner pages the median mobile page has 354 KB of images against 660 KB of JavaScript. So the home page and the pages built around photos are where this list pays first. Images are one line of web performance optimization; script, caching and server time are the others.

The third column below is my reading of the mistake an AI builder such as Lovable or Bolt tends to make on each line, not a measured count.

LineWhat it meansThe mistake an AI builder makesHow to check it
1. DimensionsNo image served much wider than about twice the slot it renders in, for high-density screensThe original upload placed in a 400-pixel card and shrunk with CSSLighthouse’s list of oversized images
2. FormatWebP or AVIF, with a JPEG or PNG fallbackScreenshots and photos shipped as PNGThe content-type of each image response in the Network panel
3. CompressionA quality setting checked by eye at the size the image is shownExporting at the highest quality settingTransferred bytes next to a look at the image on screen
4. Responsive sourcessrcset and sizes, so a phone takes the small fileOne file for every screenThe file a phone-sized window downloads
5. Loading behaviorLazy loading, where the browser fetches an image only as it nears the viewport; below the fold only, never on the Largest Contentful Paint (LCP) imageloading="lazy" on every image, hero includedThe attributes on the LCP element
6. DeliveryA long cache lifetime on hashed file names, served from a CDNImages streamed by an app route with no cache headerThe cache-control response header

Why dimensions come first is arithmetic, not a measurement. In my illustration, a 3,000-pixel-wide image in a 400-pixel slot needs at most 800 pixels of width at twice density, so with its shape kept it carries (3,000 / 800) squared, about 14 times the pixels it needs. A better format shrinks the bytes each pixel costs; resizing removes the pixels themselves, which is why I put it ahead of the format line. Logos and icons are the exception to all six lines: as SVG they stay sharp at any size.

What goes wrong without it: images make my website load slowly

If other sites load quickly on the same connection, the page is the place to look, and the checks under How to verify it show whether images are the cause. When images are loading slowly on a website, the table matches what the owner sees to its cause and to the checklist line that fixes it.

What the owner seesThe causeThe checklist line that fixes it
The hero arrives lateThe page’s largest element is a multi-megabyte imageLines 1, 2 and 5
The page jumps as images arriveImages with no width and height, so the browser cannot reserve their spaceLine 4, and the srcset block below
A grid of avatars or product photos stuttersEach image is served at full size, then shrunk in the browserLine 1
The bandwidth bill grows with trafficEvery view downloads the full fileLines 1 and 6

When the late hero is the page’s Largest Contentful Paint element, the trace for it, the thresholds and the layout-shift fix for the jumping page are in Core Web Vitals for AI-built apps, so they are not repeated here. On Supabase Storage, the bandwidth side has its own accounting and a worked example: find what is eating your Supabase bandwidth.

How to do it on the common stacks

The four steps below follow the checklist’s order: dimensions and responsive sources, format and compression, loading behavior, delivery.

Dimensions and responsive sources: size every image to its slot

Image dimensions come from the slot, not the upload: the rendered width at the largest breakpoint, times about 2 for sharp screens (my working rule), is the biggest file that image ever needs. srcset and sizes let a phone take a smaller file, and width and height let the browser reserve the space.

To make a website load faster with pictures on it, start here, because no later step removes pixels a file should never have had. Find the slot by opening the page at its widest layout, inspecting the image in DevTools and reading its rendered width in CSS pixels; then multiply by about 2, my working rule from above.

MDN’s responsive images guide describes the two attributes that let the browser choose. srcset lists the files with their real widths in w units, and sizes “defines a set of media conditions (e.g., screen widths) and indicates what image size would be best to choose, when certain media conditions are true.” For a card that fills the screen on a phone and takes a third of the layout on a desktop:

<img
  src="/img/card-800.webp"
  srcset="/img/card-400.webp 400w, /img/card-800.webp 800w, /img/card-1200.webp 1200w"
  sizes="(width <= 640px) 100vw, 33vw"
  width="800" height="600"
  alt="Product photo on a pricing card" />

The widths in that block are my illustration: three files cover a narrow phone, a high-density phone and the desktop slot at twice its width. The order of the conditions in sizes matters, and MDN is blunt about it: “The browser ignores everything after the first matching condition, so be careful how you order the media conditions.”

Put width and height on every img. They give the browser the image’s shape before the file arrives, so it can hold the space instead of moving the text when the image lands.

Generate the variants at build time or through a transform URL, never one at a time in an image editor. For images your users upload, my working rule is to resize on upload to a capped maximum and keep named variants, such as thumbnail, card and full, so no page ever serves the original. Upload limits and bucket permissions are separate jobs: the limits belong to file upload testing, and the permissions to the object storage security checklist.

How to serve WebP images, and AVIF, with a fallback

WebP images are served either through a picture element that lists AVIF, then WebP, then a JPEG or PNG fallback, or through an image service that picks the best format the browser supports at one URL. Next.js’s image component and Supabase’s image transformations (Pro Plan and above) do the second for you; Astro’s Picture component writes the first.

The first way works in plain HTML on any stack, and it is also how to display a WebP image with a safe fallback:

<picture>
  <source srcset="/img/hero.avif" type="image/avif" />
  <source srcset="/img/hero.webp" type="image/webp" />
  <img src="/img/hero.jpg" width="1600" height="900" alt="The dashboard on a laptop" />
</picture>

MDN’s picture element reference states the rule that makes the fallback safe: “If the user agent does not support the given type, the <source> element is skipped.” The img at the end is required, it is what an older browser shows, and it carries the alt, width and height for all three files.

The second way is one URL that answers with a different format per browser. Per the next/image reference, “Next.js automatically detects the browser’s supported image formats via the request’s Accept header in order to determine the best output format.” Where the work runs decides what it costs, so the table names the meter for each stack. Its sources are Vercel’s image optimization limits and pricing, Astro’s images guide, Supabase’s image transformations and Cloudflare Images, with the Next.js reference above.

Stack or hostWhat it does for youHow to turn it onThe meter or limit to check
Next.js next/imagePicks the output format from the Accept header; default formats is ['image/webp'], so AVIF is opt-in; generates a full srcset when you pass sizes; default quality 75; loading defaults to lazyUse the component; for AVIF, set images.formats to ['image/avif', 'image/webp'] in next.config.jsBehind your own proxy or CDN, the proxy must forward the Accept header
Vercel, running next/image or another framework’s image componentTransforms the image and caches the result on its CDN; a plain img bypasses itA framework Image component (Next.js, Astro, Nuxt); available on all plansMeters transformations, cache reads and cache writes. Hobby, restricted to non-commercial personal use, includes 5K transformations, 300K cache reads and 100K cache writes a month; over that, new images fail to optimize with a 402 while cached ones keep working, with no charge. On-demand transformations: $0.05 to $0.0812 per 1K
Astro <Image /> and <Picture /><Image /> writes a .webp file by default, with width, height, decoding="async" and loading="lazy"; <Picture /> with formats={['avif', 'webp']} writes a source per format and a fallback imgImport the components from astro:assetsPrerendered pages are transformed at build time; pages rendered on demand transform when viewed
A Vite React app from Lovable or BoltNo image component, in my reading; a build step (sharp in a script) or a transform service in front does this workAdd the build step or the serviceWhatever that service meters
Supabase StorageImage transformations resize and return the best format the client supports, WebP in Chrome; for that automatic choice, “We currently only support WebP”Pro Plan and above; in the dashboard, Storage > Settings, Enable Image Transformations100 origin images per billing cycle on Pro and Team, then $5 per 1,000 origin images; width and height from 1 to 2500 pixels
Cloudflare ImagesOptimization of images (compress, crop, resize), Storage for hosted images and Predefined variantsDashboard: Images > Transformations, select the zone, enable transformationsFree plan: up to 5,000 unique transformations a month

Prices and limits checked October 4, 2026. The Supabase transform call and what it does to egress are in the Supabase bandwidth article linked above.

Compression comes next, and quality is judged by looking at the image at the size it is shown. The defaults are starting points only: sharp’s output options default WebP to quality 80 and AVIF to 50, and next/image uses 75. For photos, lossy compression at those defaults is where I start; screenshots with small text may need a higher setting or sharp’s WebP lossless option. An animated GIF is better served as a short video or an animated WebP.

What is lazy loading images, and the one image that must never be lazy

Lazy loading images means the browser fetches an image only when it nears the viewport, set in HTML with loading="lazy". It belongs on images below the fold and never on the image that is the page’s Largest Contentful Paint, which web.dev says should never be lazy loaded and may get high fetch priority.

Put simply, lazy loading means a long page stops downloading every photo up front: the request for each image waits until the reader scrolls near it. To lazy load images in HTML, you add one attribute to each img below the fold, and MDN’s img element reference defines loading="lazy" as “Defers loading the image until it reaches a calculated distance from the viewport, as defined by the browser.” The other value, eager, is the default. MDN adds two conditions: explicit width and height “are especially important for lazy-loaded ones”, and “Loading is only deferred when JavaScript is enabled.”

The rule that matters most comes from web.dev’s guide to optimizing LCP: “Never lazy-load your LCP image, as that will always lead to unnecessary resource load delay, and will have a negative impact on LCP.” The same guide calls it “a good idea to set fetchpriority="high" on an <img> element if you think it’s likely to be your page’s LCP element”, with a limit: “setting a high priority on more than one or two images makes priority setting unhelpful in reducing LCP.”

<!-- The hero, likely the LCP element: no lazy, high priority -->
<img src="/img/hero-1600.webp" width="1600" height="900" fetchpriority="high" alt="The dashboard on a laptop" />

<!-- Further down the page -->
<img src="/img/review-800.webp" width="800" height="600" loading="lazy" alt="A customer review card" />

Framework defaults are where this bites. next/image defaults to lazy, and starting with Next.js 16 its priority prop is deprecated in favor of preload; the Next.js docs add, “In most cases, you should use loading="eager" or fetchPriority="high" instead of preload.” Of those two, only loading="eager" takes the lazy attribute off: in the component’s source, fetchPriority on its own leaves the default loading="lazy" in place, so give the hero both. Astro’s <Image /> writes loading="lazy" in its output too, and its priority prop switches an above-the-fold image to loading="eager", decoding="sync" and fetchpriority="high". On both stacks lazy loading is already added for you, and the job left is taking it off the hero.

decoding="async", which Astro also writes, lets the next paint go ahead without waiting for the image to decode.

Delivery: cache headers, a CDN, and hashed file names

Images produced at build time should get a content hash in the file name and the header Cache-Control: max-age=31536000, immutable, a year in seconds. MDN’s Cache-Control reference calls this the cache-busting pattern: new content gets a new URL, and caches of the old file are not reused.

Images that can change at the same URL, such as avatars, get a shorter lifetime or a version in the URL; that is my working rule. Serve every image from the host’s CDN or the storage provider’s CDN. An app route that reads each file and streams it back runs your code on every request that reaches it, and caches keep the response only as long as that route’s headers allow.

Deciding what to cache, for how long and how to clear it is part of the caching strategies web applications need. The other half of page weight is script, and the work there is how to reduce JavaScript bundle size.

How to verify it

Image optimization is verified with two comparisons: transferred image bytes per page before and after, and Lighthouse’s list of images larger than their rendered size. The LCP image is checked on its own: not lazy, high fetch priority, and still sharp at its intended size.

The Production Hardening Sprint verifies deliverable 9.4 this way: “Compare transferred sizes and inspect rendered images at intended display dimensions.” These six checks do the same on your own app.

  1. 01 Before changing anything, open the three heaviest pages in Chrome DevTools, go to the Network panel, tick Disable cache, click the Img filter and reload. Write down the transferred total from the status bar at the bottom of the panel and the ten largest image files.
  2. 02 Run Lighthouse on the same pages and read its oversized-image list, which sits in the Improve image delivery insight from Lighthouse 13. Pass: no image listed, or each listed image kept with a written reason.
  3. 03 On the ten largest files, read the response headers: photos come back with a content-type of image/webp or image/avif, and every image carries a cache-control header.
  4. 04 Find the LCP element in the Performance panel, where the live metrics view shows it and highlights it on the page when you hover over it. Confirm it has no loading="lazy" and has fetchpriority="high", a preload link or the framework prop that sets them.
  5. 05 Look at every changed image at its intended size on a phone and on a high-density laptop screen. A blurry product photo or unreadable screenshot text is a fail.
  6. 06 Record the after figures beside the before figures, with the date, in the record table below.

The second check rests on how Lighthouse measures. Per Lighthouse’s properly-size-images audit, it compares the rendered size with the file’s actual size, the rendered size “also accounts for device pixel ratio”, and an image fails “If the rendered size is at least 4KiB smaller than the actual size”. The rest of a Lighthouse review, scores and targets included, belongs to a Lighthouse audit.

PageImage bytes before (date)Image bytes after (date)Oversized images listedLCP image attributes
Home pageTransferred total, Img filterTransferred total, Img filterCount, or each one with its reasonloading, fetchpriority, width, height
Second-heaviest pageTransferred total, Img filterTransferred total, Img filterCount, or each one with its reasonloading, fetchpriority, width, height
Third-heaviest pageTransferred total, Img filterTransferred total, Img filterCount, or each one with its reasonloading, fetchpriority, width, height

Every meaningful image keeps its alt text through the conversion, and checking that belongs to an accessibility audit. Image bytes per page view, times the views you expect, is also the front-end line in the answer to what is a capacity plan. None of these bytes show up in API load testing, which measures the server side of the same traffic.

Where the sprint does this

In the Production Hardening Sprint, the work on this page is deliverable 9.4: “Optimize image dimensions, formats, compression, and delivery while preserving useful quality.” Bundle work is deliverable 9.3, verified this way: “Compare built bundle sizes and test route loading with representative network conditions.” The Lighthouse review is deliverable 9.5: “Report the chosen pages, device profile, numerical targets, and final results; manually check key accessibility interactions.” The production readiness report, 13.1, is checked against this line: “Account for all 123 IDs; keep failures visible until resolved and explain genuine non-applicable items.” Building new product features or modules is outside the sprint: “New features and completing unfinished core workflows are separate work.” “Hosting, paid tools, and API usage remain in your accounts.” Every line here is in the published scope.

Common questions about website images

Which browsers support WebP images?

The current versions of the major browsers support WebP: caniuse’s WebP table puts global usage with WebP support at 96.82 percent, partial support included, on October 4, 2026. Its AVIF table puts AVIF at 95.36 percent, so a fallback still matters for both.

How to disable lazy loading?

Remove the loading attribute or set loading="eager", which is the default for a plain img. In next/image, set loading="eager" on the hero, with fetchPriority="high" beside it, because fetchPriority alone keeps the lazy default; in Astro, the priority prop does both. Do it for the LCP image only and leave the images further down lazy.

Why are images on my website not loading?

Images that never appear have a different cause from slow ones: the request failed, or the browser could not decode the file, and the image request’s status code in the Network panel tells you which. In my reading, a 404 points at the path and a 403 at an access rule. MDN’s img reference lists other causes of a loading error, among them an empty src or srcset, a corrupted file and a format the browser does not support.

How do I convert a WebP file to JPEG?

For one file, open it in an image editor that reads WebP and export it as JPEG, or call sharp’s jpeg() output in a short script. For a website, keep the original upload and let the build or the image service generate each format, so no file needs converting one at a time.

Why can’t I open a WebP file?

An older image viewer may not read WebP, in my reading, while the current versions of Chrome, Safari, Firefox and Edge all display WebP images. View it in one of them, or convert it as in the answer above.