Images are often a third or more of a page’s weight, the LCP element on most templates, and a meaningful source of organic traffic via Google Images. They’re also the easiest performance win on most sites — the gap between a 1.5 MB hero PNG and a 120 KB WebP is enormous, requires no application logic changes, and survives every redesign.
Why it matters
Three concrete impacts:
- Performance. Hero images are the LCP element on most templates. A slow hero image drops LCP into “needs improvement” territory regardless of how fast the rest of the page is.
- Image search traffic. Google Images is one of the largest discovery channels — alt text and filename hygiene are the difference between ranking for “knee-length wool coat” and ranking for nothing.
- Accessibility. Alt text is required for screen readers. The cost of doing it well for SEO is zero; the cost of doing it badly is a real accessibility failure.
How to detect it
# Quick weight audit — sum image bytes on a page
curl -s https://example.com/some/page \
| grep -oE 'src="[^"]+\.(jpg|jpeg|png|gif|webp|avif)[^"]*"' \
| sed 's/src="//;s/"$//' \
| while read url; do
curl -sI "$url" | grep -i content-length
done
PageSpeed Insights → Opportunities → “Properly size images” and “Serve images in next-gen formats” surface the worst offenders with exact byte savings.
For alt text audits:
curl -s https://example.com/some/page | grep -oE '<img[^>]*>' | grep -v 'alt='
Every <img> should have an alt attribute. alt="" is valid for purely decorative images; missing alt is a bug.
The fix
Modern formats — WebP and AVIF
WebP saves ~25–35% over JPEG at the same visual quality. AVIF saves another ~20% on top of WebP but encodes slowly and isn’t supported in some legacy browsers. The pattern: WebP with JPEG fallback covers >97% of traffic.
<picture>
<source srcset="/photo.avif" type="image/avif" />
<source srcset="/photo.webp" type="image/webp" />
<img src="/photo.jpg" alt="…" width="1600" height="900" />
</picture>
Responsive images — srcset and sizes
Send the right pixel dimensions for the device:
<img
src="/photo-800.webp"
srcset="/photo-400.webp 400w,
/photo-800.webp 800w,
/photo-1600.webp 1600w,
/photo-3200.webp 3200w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="…"
width="800"
height="450"
/>
sizes tells the browser how wide the image will display at each breakpoint; the browser picks the smallest candidate from srcset that fits.
Lazy-loading — native only
<img src="/photo.webp" loading="lazy" alt="…" width="800" height="600" />
loading="lazy" is supported in every current browser. Don’t use a JS-based lazy-loading library — the native attribute is faster and doesn’t run on the main thread.
Exception: the LCP image. Setting loading="lazy" on the hero delays its load and tanks LCP. Use loading="eager" (the default) plus fetchpriority="high" on the LCP image:
<img src="/hero.webp" fetchpriority="high" alt="…" width="1600" height="900" />
Alt text — write for the search query, not the image
Bad: alt="image1.jpg", alt="photo", alt="banner".
Good: alt="Knee-length wool overcoat in charcoal, front view".
Rules:
- Describe what’s in the image and what’s relevant. Don’t write “image of” or “photo of” — screen readers already announce that.
- 8–15 words is typical. Longer is fine if the image is detailed and central to the page.
- Don’t keyword-stuff. Google’s image alt analysis is dense and stuffed alt text is detected.
alt=""(empty) is correct for purely decorative images — tells assistive tech to skip.- Image filename matters too:
/charcoal-wool-overcoat.webpbeats/IMG_2847.webp.
Byte budget
There’s no universal rule, but useful targets:
- Hero images: < 200 KB for WebP, < 300 KB for JPEG fallback
- Inline content images: < 100 KB
- Thumbnails: < 20 KB
- Total page image weight: < 1 MB on mobile
WordPress
WP 5.5+ adds loading="lazy" automatically and generates WebP from uploads (6.0+). The Modern Image Formats plugin or ShortPixel handles AVIF. Verify the generated srcset covers your real breakpoint range — WP’s default sizes don’t always match the active theme.
Next.js (App Router)
import Image from "next/image";
<Image
src="/photo.jpg"
alt="Knee-length wool overcoat in charcoal, front view"
width={1600}
height={900}
sizes="(max-width: 768px) 100vw, 50vw"
priority={isHero} // skips lazy-load + adds fetchpriority=high
/>
next/image auto-emits WebP, generates the srcset, and handles dimensions.
Shopify
Use the image_url filter with format conversion:
<img
src="{{ image | image_url: width: 800, format: 'webp' }}"
srcset="{{ image | image_url: width: 400, format: 'webp' }} 400w,
{{ image | image_url: width: 800, format: 'webp' }} 800w,
{{ image | image_url: width: 1600, format: 'webp' }} 1600w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="{{ image.alt | escape }}"
width="{{ image.width }}"
height="{{ image.height }}"
loading="lazy"
/>
Common pitfalls
- PNG for photos. PNG is lossless — great for logos, terrible for photos. JPEG or WebP for photographs; PNG only for graphics with text, sharp edges, or transparency.
loading="lazy"on the LCP image. Single biggest accidental LCP regression.alt=""on meaningful images. Empty alt tells screen readers to skip; it should only be used for decoration. A product photo with empty alt is broken.- Same-resolution image for every breakpoint. A 3200px image served to a 320px mobile screen is 99% wasted bytes.
- Repeating the caption in the alt. Screen readers read both. Differentiate, or leave alt empty if the caption already covers it.