Media
Image Element Audit

Image Element Audit

Audits all <img> elements on the page against image performance best practices — covering loading strategy, fetch priority, format modernisation, responsive markup, and CLS prevention.

What this snippet checks:

CheckScope
LCP candidate missing fetchpriority="high"Largest in-viewport image
LCP candidate with loading="lazy"Largest in-viewport image
LCP candidate without decoding="sync"Largest in-viewport image
In-viewport image with loading="lazy"Above-the-fold images
Off-viewport image missing loading="lazy"Below-the-fold images
Off-viewport image with fetchpriority="high"Below-the-fold images
Conflicting loading="lazy" + fetchpriority="high"All images
Missing width / height attributesAll images (CLS risk)
No modern format detected (WebP / AVIF / JXL)All images not inside <picture>
LCP candidate without <link rel="preload" as="image">Largest in-viewport image

Overview

Every image on a page falls into one of three roles, each with its own optimal configuration:

LCP image — the largest image visible on load, most likely the element measured by Largest Contentful Paint:

<link rel="preload" as="image" href="hero.avif" fetchpriority="high" />
<img src="hero.avif" fetchpriority="high" decoding="sync" width="1200" height="630" alt="..." />

A <link rel="preload"> in the <head> allows the browser to discover and start fetching the LCP image before the parser reaches the <img> tag, which is especially important when the image is inside a <picture>, a CSS background, or injected by JavaScript. Pair it with fetchpriority="high" to boost it above other preloaded resources.

Above-the-fold image — visible on load but not the LCP element:

<img src="logo.svg" decoding="async" width="120" height="40" alt="..." />

Below-the-fold image — outside the viewport on load:

<img
  src="content.avif"
  loading="lazy"
  decoding="async"
  fetchpriority="low"
  width="800"
  height="600"
  alt="..."
/>

fetchpriority="low" on below-the-fold images is optional — the browser already deprioritises off-viewport resources. It is most useful for images that are technically inside the viewport area but not visible, such as inactive carousel slides.

For maximum format coverage, wrap images in a <picture> element:

<picture>
  <source type="image/jxl" srcset="image.jxl" />
  <source type="image/avif" srcset="image.avif" />
  <source type="image/webp" srcset="image.webp" />
  <img src="image.jpg" loading="lazy" width="800" height="600" alt="..." />
</picture>

The browser uses the first format it supports. Ordering from most to least optimised (JXL → AVIF → WebP → JPEG) ensures the best available format is served. CDN image services such as Cloudinary handle this automatically via the HTTP Accept header, so no <source> elements are needed.

Snippet

// Image Element Audit
// https://webperf-snippets.nucliweb.net
 
(async () => {
  function isInViewport(el) {
    const rect = el.getBoundingClientRect();
    return (
      rect.top < window.innerHeight &&
      rect.bottom > 0 &&
      rect.left < window.innerWidth &&
      rect.right > 0 &&
      rect.width > 0 &&
      rect.height > 0
    );
  }
 
  function findLcpCandidate(imgs) {
    let candidate = null;
    let maxArea = 0;
    imgs.filter(isInViewport).forEach((img) => {
      const { width, height } = img.getBoundingClientRect();
      const area = width * height;
      if (area > maxArea) {
        maxArea = area;
        candidate = img;
      }
    });
    return candidate;
  }
 
  function detectFormat(url) {
    if (!url) return "unknown";
    const lower = url.toLowerCase();
    const path = lower.split("?")[0];
    const query = lower.includes("?") ? lower.split("?")[1] : "";
 
    // Cloudinary path transforms: /f_auto/, /f_avif,q_auto/, etc.
    const cloudinaryMatch = path.match(/\/f_(auto|avif|webp|jxl|png|jpg|jpeg|gif|svg)[,/]/);
    if (cloudinaryMatch) {
      const fmt = cloudinaryMatch[1];
      return fmt === "auto" ? "auto (cdn)" : fmt === "jpeg" ? "jpg" : fmt;
    }
 
    // Query string params: ?fm=webp (imgix), ?format=avif (generic)
    const qMatch = query.match(/(?:^|&)(?:fm|format)=(avif|webp|jxl|png|jpg|jpeg|gif|svg)(?:&|$)/);
    if (qMatch) return qMatch[1] === "jpeg" ? "jpg" : qMatch[1];
 
    // File extension in path
    const extMatch = path.match(/\.(avif|webp|jxl|png|gif|svg|jpg|jpeg)(?:[?#]|$)/);
    if (extMatch) return extMatch[1] === "jpeg" ? "jpg" : extMatch[1];
 
    // No extension at all — likely a DAM or CDN serving format via Accept header
    const lastSegment = path.split("/").pop() || "";
    if (!lastSegment.includes(".")) return "auto (cdn?)";
 
    return "unknown";
  }
 
  function isModernFormat(format) {
    return ["avif", "webp", "jxl", "auto (cdn)", "auto (cdn?)"].includes(format);
  }
 
  async function fetchFormat(url) {
    if (!url) return detectFormat(url);
    try {
      const res = await fetch(url, { cache: "force-cache" });
      const ct = res.headers.get("content-type")?.split(";")[0]?.trim() || "";
      if (ct.includes("avif")) return "avif";
      if (ct.includes("webp")) return "webp";
      if (ct.includes("jxl")) return "jxl";
      if (ct.includes("png")) return "png";
      if (ct.includes("gif")) return "gif";
      if (ct.includes("svg")) return "svg";
      if (ct.includes("jpeg")) return "jpg";
    } catch {}
    return detectFormat(url);
  }
 
  function shortSrc(url) {
    if (!url) return "";
    return url.split("/").pop()?.split("?")[0]?.slice(0, 40) || url.slice(-40);
  }
 
  function normalizeUrl(url) {
    try {
      return new URL(url, location.origin).href;
    } catch {
      return url;
    }
  }
 
  function findPreloadForLcp(img, preloads) {
    const src = normalizeUrl(img.currentSrc || img.src || "");
    return (
      preloads.find((link) => {
        const href = link.getAttribute("href");
        const imagesrcset = link.getAttribute("imagesrcset") || "";
        if (href && normalizeUrl(href) === src) return true;
        if (imagesrcset) {
          return imagesrcset
            .split(",")
            .map((s) => normalizeUrl(s.trim().split(/\s+/)[0]))
            .includes(src);
        }
        return false;
      }) ?? null
    );
  }
 
  const images = Array.from(document.querySelectorAll("img"));
 
  if (images.length === 0) {
    console.log("No <img> elements found on this page.");
    return;
  }
 
  const lcpCandidate = findLcpCandidate(images);
  const imagePreloads = Array.from(document.querySelectorAll('link[rel="preload"][as="image"]'));
 
  // Fetch actual formats from Content-Type headers in parallel.
  // Uses the browser cache (force-cache) so already-loaded images
  // don't trigger new network requests. Falls back to URL detection
  // if the server does not expose CORS headers.
  const formats = await Promise.all(images.map((img) => fetchFormat(img.currentSrc || img.src)));
 
  const audited = images.map((img, i) => {
    const inViewport = isInViewport(img);
    const isLcp = img === lcpCandidate;
    const rect = img.getBoundingClientRect();
    const src = img.currentSrc || img.src || "";
    const format = formats[i];
    const inPicture = img.parentElement?.tagName === "PICTURE";
 
    const loading = img.getAttribute("loading");
    const decoding = img.getAttribute("decoding");
    const fetchpriority = img.getAttribute("fetchpriority");
    const hasDimensions = img.hasAttribute("width") && img.hasAttribute("height");
 
    const issues = [];
    let lcpPreload = null;
 
    if (isLcp) {
      if (fetchpriority !== "high")
        issues.push({ s: "error", msg: 'Add fetchpriority="high" to the LCP image' });
      if (loading === "lazy")
        issues.push({ s: "error", msg: 'Remove loading="lazy" from the LCP image' });
      if (decoding !== "sync")
        issues.push({ s: "warning", msg: 'Consider decoding="sync" for the LCP image' });
      lcpPreload = findPreloadForLcp(img, imagePreloads);
      if (!lcpPreload)
        issues.push({ s: "warning", msg: 'LCP image has no <link rel="preload" as="image">' });
      else if (lcpPreload.getAttribute("fetchpriority") !== "high")
        issues.push({ s: "info", msg: 'LCP image preload is missing fetchpriority="high"' });
    } else if (inViewport) {
      if (loading === "lazy")
        issues.push({ s: "warning", msg: 'Remove loading="lazy" (image is above the fold)' });
    } else {
      if (loading !== "lazy")
        issues.push({ s: "warning", msg: 'Add loading="lazy" (image is off-viewport)' });
      if (fetchpriority === "high")
        issues.push({ s: "error", msg: 'Remove fetchpriority="high" (image is off-viewport)' });
    }
 
    if (loading === "lazy" && fetchpriority === "high")
      issues.push({ s: "error", msg: 'Conflict: loading="lazy" + fetchpriority="high"' });
 
    if (!hasDimensions)
      issues.push({ s: "warning", msg: "Missing width/height attributes (CLS risk)" });
 
    if (!isModernFormat(format) && !inPicture)
      issues.push({ s: "info", msg: "No modern format detected (WebP / AVIF / JXL)" });
 
    return {
      img,
      inViewport,
      isLcp,
      src,
      format,
      loading: loading ?? "(not set)",
      decoding: decoding ?? "(not set)",
      fetchpriority: fetchpriority ?? "(not set)",
      hasDimensions,
      hasSrcset: img.hasAttribute("srcset"),
      hasSizes: img.hasAttribute("sizes"),
      inPicture,
      dimensions: `${Math.round(rect.width)}×${Math.round(rect.height)}`,
      lcpPreload,
      issues,
    };
  });
 
  const withIssues = audited.filter((r) => r.issues.length > 0);
  const totalErrors = audited.flatMap((r) => r.issues.filter((i) => i.s === "error")).length;
  const totalWarnings = audited.flatMap((r) => r.issues.filter((i) => i.s === "warning")).length;
  const totalInfos = audited.flatMap((r) => r.issues.filter((i) => i.s === "info")).length;
 
  console.group("%c🖼️ Image Element Audit", "font-weight: bold; font-size: 14px;");
 
  // Summary
  console.log("");
  console.log("%cSummary", "font-weight: bold;");
  console.log(`   Total images  : ${images.length}`);
  console.log(`   In viewport   : ${audited.filter((r) => r.inViewport).length}`);
  console.log(`   Off viewport  : ${audited.filter((r) => !r.inViewport).length}`);
  console.log(
    `   Issues        : ${totalErrors} errors · ${totalWarnings} warnings · ${totalInfos} info`,
  );
 
  // LCP candidate
  if (lcpCandidate) {
    const lcp = audited.find((r) => r.isLcp);
    const fpOk = lcp.fetchpriority === "high";
    const preloadStatus = lcp.lcpPreload
      ? lcp.lcpPreload.getAttribute("fetchpriority") === "high"
        ? "✅ found (fetchpriority=high)"
        : "⚠️ found (no fetchpriority=high)"
      : "⚠️ not found";
    console.log("");
    console.log("%cLCP Candidate", "font-weight: bold;");
    console.log(`   fetchpriority : ${fpOk ? "✅" : "⚠️"} ${lcp.fetchpriority}`);
    console.log(`   decoding      : ${lcp.decoding}`);
    console.log(`   loading       : ${lcp.loading}`);
    console.log(`   format        : ${lcp.format}`);
    console.log(`   preload       : ${preloadStatus}`);
    console.log(`   dimensions    : ${lcp.dimensions}`);
    console.log("   Element:", lcpCandidate);
    if (lcp.lcpPreload) console.log("   Preload:", lcp.lcpPreload);
  }
 
  // Full table
  console.log("");
  console.group(`%c📋 All Images (${images.length})`, "font-weight: bold;");
  console.table(
    audited.map((r) => ({
      src: shortSrc(r.src),
      format: r.format,
      viewport: r.inViewport ? "✓" : "",
      LCP: r.isLcp ? "⭐" : "",
      loading: r.loading,
      decoding: r.decoding,
      fetchpriority: r.fetchpriority,
      srcset: r.hasSrcset ? "✓" : "",
      sizes: r.hasSizes ? "✓" : "",
      "in <picture>": r.inPicture ? "✓" : "",
      "w/h": r.hasDimensions ? "✓" : "⚠️",
      issues:
        r.issues.length === 0
          ? "✅"
          : r.issues
              .map((i) => (i.s === "error" ? "🔴" : i.s === "warning" ? "⚠️" : "ℹ️"))
              .join(" "),
    })),
  );
  console.groupEnd();
 
  // Issues detail
  if (withIssues.length > 0) {
    console.log("");
    console.group(
      `%c⚠️ Issues Detail (${totalErrors} errors · ${totalWarnings} warnings · ${totalInfos} info)`,
      "color: #ef4444; font-weight: bold;",
    );
 
    withIssues.forEach((r) => {
      const hasError = r.issues.some((i) => i.s === "error");
      const icon = hasError ? "🔴" : "⚠️";
      console.log("");
      console.log(`%c${icon} ${shortSrc(r.src) || "(no src)"}`, "font-weight: bold;");
      r.issues.forEach((issue) => {
        const prefix = issue.s === "error" ? "   🔴" : issue.s === "warning" ? "   ⚠️" : "   ℹ️";
        console.log(`${prefix} ${issue.msg}`);
      });
      console.log("   Element:", r.img);
    });
 
    console.groupEnd();
  } else {
    console.log("");
    console.log("%c✅ No issues found.", "color: #22c55e; font-weight: bold;");
  }
 
  // Quick reference
  console.log("");
  console.group("%c📝 Quick Reference", "color: #3b82f6; font-weight: bold;");
  console.log("");
  console.log("%c  ⭐ LCP image + preload:", "color: #22c55e;");
  console.log(
    '%c  <link rel="preload" as="image" href="hero.avif" fetchpriority="high">\n  <img src="hero.avif" fetchpriority="high" decoding="sync" width="1200" height="630" alt="...">',
    "font-family: monospace;",
  );
  console.log("");
  console.log("%c  ✅ Below-fold image:", "color: #22c55e;");
  console.log(
    '%c  <img src="content.avif" loading="lazy" decoding="async" width="800" height="600" alt="...">',
    "font-family: monospace;",
  );
  console.log("");
  console.log("%c  ✅ Picture with modern format fallback chain:", "color: #22c55e;");
  console.log(
    '%c  <picture>\n    <source type="image/jxl" srcset="img.jxl">\n    <source type="image/avif" srcset="img.avif">\n    <source type="image/webp" srcset="img.webp">\n    <img src="img.jpg" loading="lazy" width="800" height="600" alt="...">\n  </picture>',
    "font-family: monospace;",
  );
  console.groupEnd();
 
  console.groupEnd();
})();

Understanding the Results

Summary

FieldDescription
Total imagesCount of all <img> elements on the page
In viewportImages visible in the current viewport
Off viewportImages outside the current viewport
IssuesCount by severity: errors (must fix), warnings (should fix), info (consider)

LCP Candidate

The snippet estimates the LCP candidate as the largest <img> by rendered area currently in the viewport. This is an approximation — the browser's actual LCP element may differ if non-image elements are larger, or if the page has scrolled before running the snippet.

FieldDescription
fetchpriorityhigh is correct · ⚠️ any other value is a missed optimisation
decodingsync is recommended for LCP; async or auto adds decoding latency
loadinglazy on the LCP image delays loading and harms the LCP score
formatDetected from currentSrc — reflects what the browser actually loaded
preloadfound (fetchpriority=high) · ⚠️ found (no fetchpriority=high) · ⚠️ not found
dimensionsRendered size in pixels

All Images Table

ColumnDescription
srcFilename extracted from currentSrc (what the browser actually loaded)
formatImage format detected from the URL extension
viewport✓ if currently visible
LCP⭐ for the estimated LCP candidate
loadinglazy · eager · (not set) (browser default: eager)
decodingasync · sync · auto · (not set) (browser default: auto)
fetchpriorityhigh · low · auto · (not set) (browser default: auto)
srcset✓ if srcset attribute is present
sizes✓ if sizes attribute is present
in <picture>✓ if the <img> is wrapped in a <picture> element
w/h✓ if both width and height are set · ⚠️ if missing (CLS risk)
issues✅ no issues · 🔴 error · ⚠️ warning · ℹ️ info

Issues Detected

IssueSeverityExplanation
LCP image: no fetchpriority="high"🔴 ErrorBrowser discovers and fetches the LCP image too late
LCP image: loading="lazy"🔴 ErrorBrowser defers the most important image on the page
Off-viewport: fetchpriority="high"🔴 ErrorHigh priority fetch for an image the user cannot see
Conflict: loading="lazy" + fetchpriority="high"🔴 ErrorContradictory signals — browser ignores one or both
LCP image: no decoding="sync"⚠️ WarningAsync decoding adds latency before the image is painted
In-viewport: loading="lazy"⚠️ WarningDeferring a visible image delays its display
Off-viewport: no loading="lazy"⚠️ WarningImage loads eagerly, consuming bandwidth before it is needed
Missing width/height⚠️ WarningNo reserved space causes layout shift when the image loads (CLS)
No modern format detectedℹ️ InfoServing JPEG/PNG when WebP or AVIF would reduce file size by 25–50%
LCP image: no <link rel="preload">⚠️ WarningBrowser discovers the LCP image late — a preload allows an earlier fetch
LCP image preload: no fetchpriority="high"ℹ️ InfoPreload without a priority hint may be deprioritised relative to other resources

Format Detection

The snippet detects image formats using two strategies in order of accuracy:

1. Content-Type response header (primary)

The snippet performs a fetch with cache: "force-cache" for each image. Since images are already loaded on the page, the browser serves the response from its HTTP cache — no new network requests are made. The Content-Type header reveals the actual format served, regardless of the URL.

This correctly handles CDNs that use automatic format negotiation (e.g. Cloudinary f_auto, DAM systems that respond based on the Accept header): a URL ending in .jpg may return image/webp or image/avif.

If the server does not send CORS headers, the fetch fails silently and the snippet falls back to strategy 2.

2. URL-based heuristic (fallback)

Detected valueSource
avif, webp, jxl, png, jpg, gif, svgFile extension or CDN transform parameter in the URL
auto (cdn)Cloudinary f_auto-style path transform (e.g. /f_auto,q_auto/)
auto (cdn?)URL has no file extension — likely format-negotiated by a DAM or CDN
unknownNo recognisable extension or pattern found

auto (cdn) and auto (cdn?) are treated as modern formats and do not trigger the "no modern format" info note.

Images inside a <picture> element are exempt from the format check: currentSrc already reflects the URL the browser selected.

fetchpriority="low" on Off-Viewport Images

The browser already assigns low priority to off-viewport images during initial load, so setting fetchpriority="low" explicitly is redundant in most cases. It becomes useful for images that are geometrically inside the viewport but not actually visible — for example, slides 2 and 3 of a full-viewport carousel. Adding fetchpriority="low" on those hidden slides prevents them from competing with the visible first slide (which is likely the LCP candidate).

<!-- Active carousel slide — likely LCP -->
<img src="slide-1.avif" fetchpriority="high" decoding="sync" width="1200" height="630" alt="..." />
 
<!-- Hidden carousel slides — deprioritise explicitly -->
<img src="slide-2.avif" fetchpriority="low" loading="lazy" width="1200" height="630" alt="..." />
<img src="slide-3.avif" fetchpriority="low" loading="lazy" width="1200" height="630" alt="..." />

Browser Support

AttributeChromeEdgeFirefoxSafari
loading="lazy"77797515.4
decoding65796311.1
fetchpriority10110113217.2
WebP32186514
AVIF851219316
JPEG XL17 ⚠️

⚠️ Safari 17 supports JPEG XL for still images only — animated sequences are not supported. Chrome and Firefox require enabling an experimental flag and do not support JPEG XL by default.

Browsers that do not support fetchpriority or loading ignore the attributes safely — no polyfill is needed.

Further Reading