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.
| Line | What it means | The mistake an AI builder makes | How to check it |
|---|---|---|---|
| 1. Dimensions | No image served much wider than about twice the slot it renders in, for high-density screens | The original upload placed in a 400-pixel card and shrunk with CSS | Lighthouse’s list of oversized images |
| 2. Format | WebP or AVIF, with a JPEG or PNG fallback | Screenshots and photos shipped as PNG | The content-type of each image response in the Network panel |
| 3. Compression | A quality setting checked by eye at the size the image is shown | Exporting at the highest quality setting | Transferred bytes next to a look at the image on screen |
| 4. Responsive sources | srcset and sizes, so a phone takes the small file | One file for every screen | The file a phone-sized window downloads |
| 5. Loading behavior | Lazy loading, where the browser fetches an image only as it nears the viewport; below the fold only, never on the Largest Contentful Paint (LCP) image | loading="lazy" on every image, hero included | The attributes on the LCP element |
| 6. Delivery | A long cache lifetime on hashed file names, served from a CDN | Images streamed by an app route with no cache header | The 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 sees | The cause | The checklist line that fixes it |
|---|---|---|
| The hero arrives late | The page’s largest element is a multi-megabyte image | Lines 1, 2 and 5 |
| The page jumps as images arrive | Images with no width and height, so the browser cannot reserve their space | Line 4, and the srcset block below |
| A grid of avatars or product photos stutters | Each image is served at full size, then shrunk in the browser | Line 1 |
| The bandwidth bill grows with traffic | Every view downloads the full file | Lines 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 host | What it does for you | How to turn it on | The meter or limit to check |
|---|---|---|---|
Next.js next/image | Picks 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 lazy | Use the component; for AVIF, set images.formats to ['image/avif', 'image/webp'] in next.config.js | Behind your own proxy or CDN, the proxy must forward the Accept header |
Vercel, running next/image or another framework’s image component | Transforms the image and caches the result on its CDN; a plain img bypasses it | A framework Image component (Next.js, Astro, Nuxt); available on all plans | Meters 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 img | Import the components from astro:assets | Prerendered pages are transformed at build time; pages rendered on demand transform when viewed |
| A Vite React app from Lovable or Bolt | No image component, in my reading; a build step (sharp in a script) or a transform service in front does this work | Add the build step or the service | Whatever that service meters |
| Supabase Storage | Image 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 Transformations | 100 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 Images | Optimization of images (compress, crop, resize), Storage for hosted images and Predefined variants | Dashboard: Images > Transformations, select the zone, enable transformations | Free 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
| Page | Image bytes before (date) | Image bytes after (date) | Oversized images listed | LCP image attributes |
|---|---|---|---|---|
| Home page | Transferred total, Img filter | Transferred total, Img filter | Count, or each one with its reason | loading, fetchpriority, width, height |
| Second-heaviest page | Transferred total, Img filter | Transferred total, Img filter | Count, or each one with its reason | loading, fetchpriority, width, height |
| Third-heaviest page | Transferred total, Img filter | Transferred total, Img filter | Count, or each one with its reason | loading, 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.
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase