Performance No. 298

Image Optimization skill for Claude Code

Right size, modern format, loaded at the right time. Claude fixes images in the order that saves the most bytes: dimensions first, then format, then loading behavior. It generates width variants with sharp or an image CDN, writes srcset and sizes that match the real layout, gives the hero fetchpriority instead of lazy loading, and adds dimensions everywhere. A bundled auditor flags markup mistakes in rendered HTML.

  • nameimage-optimization
  • size176 lines · ≈1,950 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Optimizes web images end to end — choosing AVIF, WebP, JPEG, PNG, or SVG, encoding with sharp or CLI encoders at sensible quality, responsive srcset and sizes, picture for art direction, lazy loading below the fold, fetchpriority for the LCP image, explicit dimensions against layout shift, image CDNs, and framework components like Next.js Image — with a bundled markup auditor. Use when pages ship heavy images, LCP is an image, images cause layout shift, setting up an image pipeline or CDN, or reviewing img tags and Next.js Image usage.

Try asking

  • Our product listing page loads 6 MB of images on mobile — cut it down
  • Set up an image pipeline that outputs AVIF and WebP in several widths
  • Check every img tag on these pages for sizing, lazy loading and layout shift problems

Use it when

  • Pages ship heavy or oversized images
  • The LCP element is an image
  • Images cause layout shift
  • Choosing an image CDN or configuring Next.js Image

Skip it when

  • LCP is slow because of server time or render-blocking scripts — use core-web-vitals
  • Designing SVG icon systems rather than optimizing delivery — use svg-icons

How to check the result

scripts/audit_img_tags.py reports no errors, transferred image bytes dropped on the same viewport, the LCP image loads with high priority and CLS from images is zero.

The common mistake it prevents

Applying loading=lazy to every image by default, including the hero, which delays LCP by hundreds of milliseconds on every page.

~/.claude/skills/image-optimization/SKILL.md

--- name: image-optimization description: Optimizes web images end to end — choosing AVIF, WebP, JPEG, PNG, or SVG, encoding with sharp or CLI encoders at sensible quality,… license: MIT ---

Done well means: each image is served in a modern format at roughly the size it is displayed, the LCP image is discovered early and fetched with high priority, everything below the fold loads lazily, no image causes layout shift, and total image bytes on key pages dropped measurably with no visible quality loss.

Ground rules

  • The biggest wins are dimensions and format, in that order. A 4000 px JPEG displayed at 400 px wastes 90%+ of its bytes regardless of codec.
  • Never lazy-load the LCP image or anything visible in the first viewport.
  • Every <img> gets width and height (intrinsic ratio) or a CSS aspect-ratio.
  • Prefer an image CDN or framework image component that generates variants on demand over hand-maintained derivative files — unless the site is static and small.
  • Judge quality visually at 1x and 2x on the real layout; do not ship by file size alone.
  • Keep originals in source control or storage; never overwrite them with compressed derivatives.

Workflow

- [ ] 1. Inventory: biggest images by bytes on key pages; which is the LCP element
- [ ] 2. Audit markup: python3 scripts/audit_img_tags.py <page.html | url>
- [ ] 3. Right-size: generate widths matching layout breakpoints, cap at 2x DPR
- [ ] 4. Re-encode: AVIF/WebP with JPEG/PNG fallback (or let the CDN negotiate)
- [ ] 5. Fix loading: fetchpriority on LCP, lazy below the fold, dimensions everywhere
- [ ] 6. Verify: bytes before/after, LCP and CLS in the lab, visual check

1. Inventory

  • DevTools Network panel, filter Img, sort by size; check “Size” vs. rendered dimensions (hover the image in Elements to see intrinsic vs. rendered size).
  • Lighthouse audits “Properly size images”, “Serve images in next-gen formats”, “Efficiently encode images”, and “Largest Contentful Paint element” give a ranked list.

2. Audit markup

python3 scripts/audit_img_tags.py dist/index.html https://example.com/product/42 --above-fold 2

Errors (exit 1): missing dimensions, missing alt, loading="lazy" on the first N images, srcset with w descriptors but no sizes. Warnings: large images without srcset, multiple fetchpriority="high", JPEG/PNG without a modern alternative, GIFs. It parses rendered HTML; for client-rendered apps, audit saved DOM or SSR output.

3–4. Format and encoding decision

ContentFormatStarting quality
Photos, complex imagesAVIF, fallback WebP or JPEGAVIF ~50–60, WebP ~75, JPEG (mozjpeg) ~75–80
Screenshots, UI, flat graphics with textWebP lossless or PNG (oxipng); AVIF can blur textLossless or high quality
Logos, icons, illustrationsSVG (optimized with SVGO)n/a
Transparency + photoAVIF or WebP (both support alpha)as photos
AnimationMP4/WebM <video autoplay muted loop playsinline> instead of GIFn/a

AVIF is supported in all current major browsers; still keep a fallback for older clients via <picture> or CDN Accept-based negotiation.

Generate variants with sharp (Node):

import sharp from "sharp";

const widths = [480, 800, 1200, 1600];
for (const w of widths) {
  const img = sharp("src/hero.jpg").resize({ width: w, withoutEnlargement: true });
  await img.clone().avif({ quality: 55 }).toFile(`public/img/hero-${w}.avif`);
  await img.clone().webp({ quality: 75 }).toFile(`public/img/hero-${w}.webp`);
  await img.clone().jpeg({ quality: 78, mozjpeg: true }).toFile(`public/img/hero-${w}.jpg`);
}

CLI alternatives: avifenc, cwebp -q 75 in.jpg -o out.webp, oxipng -o 4 in.png, npx svgo -f icons/, or ImageMagick magick in.jpg -resize 1200x -quality 78 out.jpg. Strip metadata (EXIF, including GPS location) from user-facing derivatives; sharp strips by default unless withMetadata() is used.

5. Markup

Responsive image (same crop, different sizes):

<img src="/img/card-800.jpg"
     srcset="/img/card-480.jpg 480w, /img/card-800.jpg 800w, /img/card-1200.jpg 1200w"
     sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
     width="800" height="533" alt="Blue running shoe, side view"
     loading="lazy" decoding="async">
  • sizes must describe the rendered width in the layout; wrong sizes makes the browser download too large (or too small) a candidate. Omitting it means 100vw.
  • Use x descriptors (1x, 2x) for fixed-size images like avatars.

Format fallback and art direction:

<picture>
  <source type="image/avif" srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w" sizes="100vw">
  <source type="image/webp" srcset="/img/hero-800.webp 800w, /img/hero-1600.webp 1600w" sizes="100vw">
  <img src="/img/hero-1600.jpg" srcset="/img/hero-800.jpg 800w, /img/hero-1600.jpg 1600w" sizes="100vw"
       width="1600" height="900" alt="..." fetchpriority="high">
</picture>

Art direction (different crop on mobile) uses <source media="(max-width: 640px)" srcset="hero-portrait.avif">.

Loading attributes:

ImageAttributes
LCP / herofetchpriority="high", no loading="lazy"; preload only if not in initial HTML
Other above-the-folddefault loading, no fetchpriority
Below the foldloading="lazy" decoding="async"
CSS background hero<link rel="preload" as="image" imagesrcset="..." imagesizes="..." fetchpriority="high">

Next.js <Image>: always pass sizes for responsive layouts (fill or width-based), mark the hero with priority (newer versions rename or add preload; check the installed version), and configure images.remotePatterns for remote sources. Avoid unoptimized unless the CDN already transforms images.

Image CDNs

URL-based transforms (Cloudinary, imgix, Cloudflare Images, Vercel, Netlify, Fastly IO, Bunny) resize and negotiate format per request. Rules:

  • Request widths from a fixed list (e.g., 320, 640, 960, 1280, 1920) to keep cache hit rates high; do not pass arbitrary pixel widths.
  • Use automatic format (f_auto-style options) so the CDN sends AVIF/WebP based on the Accept header; it must send Vary: Accept or use its own cache key.
  • Keep width/height attributes in markup; the CDN does not fix CLS.

Gotchas

  • loading="lazy" applied globally by a CMS or component default breaks LCP; override for the first images.
  • Carousels: only the first slide is LCP-relevant; lazy-load the rest, and render the first slide in HTML rather than via JS.
  • decoding="async" does not speed up downloads; it only avoids blocking the main thread on decode.
  • Upscaling small originals produces large blurry files; use withoutEnlargement.
  • Retina 3x variants rarely pay off for photos; cap at 2x.
  • CSS background-image cannot be lazy-loaded natively and is discovered late; use <img> with object-fit: cover for content images.
  • SVGs from design tools can contain embedded base64 bitmaps; check size after SVGO.
  • Placeholder blur images inlined as base64 add HTML bytes; keep them tiny (< 1 KB) or use a dominant background color.

Verification

  1. python3 scripts/audit_img_tags.py <pages> reports no errors.
  2. Image bytes on the key page (DevTools Network, Img filter, transferred size): before vs. after, same viewport and DPR.
  3. Lighthouse or DevTools: LCP image has high priority and no load delay; CLS from images is zero.
  4. Visual check of re-encoded images at 1x and 2x on the real layout; no banding or blurry text.

Report template

**Page:** /collections/shoes (mobile, 412 px, DPR 2.6)
**Image bytes:** 4.8 MB -> 610 KB (transferred)
**Changes:** AVIF/WebP via image CDN f_auto; srcset 320-1280 with correct sizes (50vw grid);
hero: removed lazy, added fetchpriority=high; dimensions on all 48 cards
**Lab:** LCP 4.1 s -> 2.2 s; CLS 0.18 -> 0.01
**Audit:** audit_img_tags.py 0 errors, 3 warnings (decorative PNG icons, acceptable)

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/audit_img_tags.py 139 lines · 6.0 KB
#!/usr/bin/env python3
"""Audit <img> and <picture> markup in rendered HTML for common performance mistakes.

Checks per image: missing width/height (CLS), missing alt, loading="lazy" on the first
images in the document (likely LCP candidates), srcset with w-descriptors but no sizes,
large images without srcset, multiple fetchpriority="high", legacy formats without a
modern <source>, and animated GIFs that should be video.

Usage:
  python3 audit_img_tags.py <file.html | directory | http(s)://url> [...] [--above-fold N]

  --above-fold N   how many leading images to treat as above the fold (default 2)

Exit code: 0 no errors (warnings allowed), 1 errors found, 2 usage or fetch failure.
Audits rendered HTML; for SPAs save the DOM (DevTools > Elements > Copy outerHTML) or
point it at server-rendered pages. Standard library only.
"""
from __future__ import annotations

import argparse
import os
import re
import sys
import urllib.request
from html.parser import HTMLParser

LEGACY = re.compile(r"\.(?:jpe?g|png)(?:[?#]|$)", re.I)
GIF = re.compile(r"\.gif(?:[?#]|$)", re.I)


class ImgCollector(HTMLParser):
    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.images: list[dict] = []
        self.picture_depth = 0
        self.picture_sources: list[str] = []

    def handle_starttag(self, tag, attrs):
        a = {k.lower(): (v if v is not None else "") for k, v in attrs}
        if tag == "picture":
            self.picture_depth += 1
            self.picture_sources = []
        elif tag == "source" and self.picture_depth:
            self.picture_sources.append(a.get("type", "") + " " + a.get("srcset", ""))
        elif tag == "img":
            a["_line"] = self.getpos()[0]
            a["_in_picture"] = bool(self.picture_depth)
            a["_sources"] = list(self.picture_sources) if self.picture_depth else []
            self.images.append(a)

    def handle_endtag(self, tag):
        if tag == "picture" and self.picture_depth:
            self.picture_depth -= 1


def load(target: str) -> list[tuple[str, str]]:
    if target.startswith(("http://", "https://")):
        req = urllib.request.Request(target, headers={"User-Agent": "img-audit/1.0"})
        with urllib.request.urlopen(req, timeout=20) as resp:
            return [(target, resp.read().decode(resp.headers.get_content_charset() or "utf-8", "replace"))]
    if os.path.isdir(target):
        out = []
        for root, _, files in os.walk(target):
            for f in sorted(files):
                if f.endswith((".html", ".htm")):
                    p = os.path.join(root, f)
                    with open(p, encoding="utf-8", errors="replace") as fh:
                        out.append((p, fh.read()))
        return out
    with open(target, encoding="utf-8", errors="replace") as fh:
        return [(target, fh.read())]


def audit(images: list[dict], above_fold: int) -> tuple[list[str], list[str]]:
    errors, warns = [], []
    high = [img for img in images if img.get("fetchpriority", "").lower() == "high"]
    if len(high) > 1:
        warns.append(f"{len(high)} images have fetchpriority=high; reserve it for the single LCP image")
    for idx, img in enumerate(images):
        src = img.get("src", "") or img.get("data-src", "")
        label = f"line {img['_line']} <img src={src[:70]!r}>"
        srcset = img.get("srcset", "")
        if not (img.get("width") and img.get("height")):
            style = img.get("style", "")
            if "aspect-ratio" not in style:
                errors.append(f"{label}: missing width/height attributes (layout shift risk)")
        if "alt" not in img:
            errors.append(f"{label}: missing alt (use alt=\"\" for decorative images)")
        lazy = img.get("loading", "").lower() == "lazy"
        if idx < above_fold and lazy:
            errors.append(f"{label}: loading=lazy on an above-the-fold image delays LCP")
        if idx >= above_fold and not lazy and not img["_in_picture"]:
            warns.append(f"{label}: below-the-fold candidate without loading=lazy")
        if re.search(r"\s\d+w(?:\s*,|\s*$)", " " + srcset) and not img.get("sizes"):
            errors.append(f"{label}: srcset uses w descriptors but sizes is missing (browser assumes 100vw)")
        try:
            width = int(img.get("width", "0") or 0)
        except ValueError:
            width = 0
        if width >= 600 and not srcset and not img["_in_picture"]:
            warns.append(f"{label}: {width}px wide with no srcset; mobile downloads the full size")
        modern = any(("avif" in s or "webp" in s) for s in img["_sources"]) or re.search(r"\.(?:avif|webp)", srcset + src, re.I)
        if LEGACY.search(src) and not modern:
            warns.append(f"{label}: JPEG/PNG without AVIF/WebP alternative (unless an image CDN negotiates format)")
        if GIF.search(src):
            warns.append(f"{label}: GIF; animated GIFs are usually 5-10x larger than an equivalent muted looping video")
    return errors, warns


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("targets", nargs="+")
    ap.add_argument("--above-fold", type=int, default=2)
    args = ap.parse_args()
    total_errors = 0
    for target in args.targets:
        try:
            docs = load(target)
        except (OSError, ValueError) as exc:
            print(f"cannot read {target}: {exc}", file=sys.stderr)
            return 2
        for name, html in docs:
            parser = ImgCollector()
            parser.feed(html)
            errors, warns = audit(parser.images, args.above_fold)
            print(f"\n=== {name}: {len(parser.images)} <img> tag(s)")
            for e in errors:
                print(f"  ERROR: {e}")
            for w in warns:
                print(f"  WARN:  {w}")
            if not errors and not warns:
                print("  no issues found")
            total_errors += len(errors)
    print(f"\n{total_errors} error(s)")
    return 1 if total_errors else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc