Skip to Content
MediaImage Element Audit

Image element audit

Chrome 38+ Edge 13+ Firefox 38+ Safari 9.1+ Details

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 image missing fetchpriority="high"The LCP <img>
LCP image with loading="lazy"The LCP <img>
LCP image without decoding="sync"The LCP <img>
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 image without <link rel="preload" as="image">The LCP <img>

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

LCP image, the <img> the browser reports as the Largest Contentful Paint element:

<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

Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.

// 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
    );
  }

  // Fallback when the browser cannot report the LCP element: the largest image in the viewport
  function estimateLcpCandidate(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;
  }

  // Chrome only exposes largest-contentful-paint entries through a PerformanceObserver
  function getLastLcpEntry() {
    return new Promise((resolve) => {
      const entries = [];
      const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
      observer.observe({ type: "largest-contentful-paint", buffered: true });
      setTimeout(() => {
        observer.disconnect();
        resolve(entries.at(-1) ?? null);
      }, 100);
    });
  }

  // The <img> the browser reports as the Largest Contentful Paint, or null when the LCP element is
  // not one of the page's images (text, a CSS background, a video). Estimates by area when the
  // browser reports no LCP.
  async function findLcpCandidate(imgs) {
    if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
      return { candidate: estimateLcpCandidate(imgs), nonImageTag: null };
    }
    const entry = await getLastLcpEntry();
    if (!entry) return { candidate: estimateLcpCandidate(imgs), nonImageTag: null };
    const el = entry.element;
    if (el?.tagName === "IMG") {
      return imgs.includes(el) ? { candidate: el, nonImageTag: null } : { candidate: null, nonImageTag: "IMG" };
    }
    // The element left the DOM after painting: find its image by URL
    if (!el && entry.url) {
      const byUrl = imgs.find((img) => (img.currentSrc || img.src) === entry.url) ?? null;
      return { candidate: byUrl, nonImageTag: byUrl ? null : "unknown" };
    }
    return { candidate: null, nonImageTag: el ? el.tagName : "unknown" };
  }

  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);
  }

  // Images whose format could not be read from the response (fetch blocked by CORS) and was
  // guessed from the URL instead
  let formatGuessedCount = 0;

  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 {
      formatGuessedCount++;
    }
    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 { script: "Image-Element-Audit", status: "ok", count: 0, corsLimitedAnalysis: false, items: [], issues: [] };
  }

  const { candidate: lcpCandidate, nonImageTag } = await 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 && format !== "svg")
      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 (nonImageTag) {
    console.log("");
    console.log(`%cℹ️ The LCP element is not an <img> (${nonImageTag === "unknown" ? "not in the page's images" : nonImageTag}), so no image is audited as the LCP.`, "color: #3b82f6;");
  }
  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();

  const lcpData = lcpCandidate ? audited.find((r) => r.isLcp) : null;
  // The list keeps the 50 images to look at first: the LCP image, then the most errors, warnings and notes, then those in the viewport; count and details cover all of them
  const MAX_ITEMS = 50;
  const countBy = (r, severity) => r.issues.filter((i) => i.s === severity).length;
  const rankedImages = [...audited].sort(
    (a, b) =>
      Number(b.isLcp) - Number(a.isLcp) ||
      countBy(b, "error") - countBy(a, "error") ||
      countBy(b, "warning") - countBy(a, "warning") ||
      b.issues.length - a.issues.length ||
      Number(b.inViewport) - Number(a.inViewport)
  );
  return {
    script: "Image-Element-Audit",
    status: "ok",
    count: images.length,
    corsLimitedAnalysis: formatGuessedCount > 0,
    details: {
      totalImages: images.length,
      formatGuessedCount,
      inViewport: audited.filter((r) => r.inViewport).length,
      offViewport: audited.filter((r) => !r.inViewport).length,
      totalErrors,
      totalWarnings,
      totalInfos,
      lcpCandidate: lcpData
        ? {
            selector: shortSrc(lcpData.src),
            format: lcpData.format,
            fetchpriority: lcpData.fetchpriority,
            loading: lcpData.loading,
            preloaded: !!lcpData.lcpPreload,
          }
        : null,
    },
    items: rankedImages.slice(0, MAX_ITEMS).map((r) => ({
      url: r.src,
      format: r.format,
      inViewport: r.inViewport,
      isLCP: r.isLcp,
      loading: r.loading,
      decoding: r.decoding,
      fetchpriority: r.fetchpriority,
      hasDimensions: r.hasDimensions,
      hasSrcset: r.hasSrcset,
      hasSizes: r.hasSizes,
      inPicture: r.inPicture,
      issues: r.issues.map((i) => ({ severity: i.s, message: i.msg })),
    })),
    issues: [
      ...(nonImageTag
        ? [{ severity: "info", message: `The LCP element is not an <img> (${nonImageTag === "unknown" ? "not in the page's images" : nonImageTag}), so no image is audited as the LCP` }]
        : []),
      ...audited.flatMap((r) =>
        r.issues.map((i) => ({
          severity: i.s,
          message: `${shortSrc(r.src) || "(no src)"}: ${i.msg}`,
        }))
      ),
    ],
  };
})();

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 takes the LCP candidate from the Largest Contentful Paint entry the browser reports, so it is an <img> only when the LCP element is one. When the LCP element is text, a CSS background or a video, no image is audited as the LCP and issues carries an info note saying so. Only when the browser reports no LCP entry does the snippet estimate the candidate as the largest <img> by rendered area in the viewport.

FieldDescription
fetchpriority high 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
preload found (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

The snippet needs these features to run.

FeatureChromeEdgeFirefoxSafari
HTMLImageElement.currentSrc (opens in a new tab) 38 13 38 9.1

The snippet reports on these features. It runs without them, and its advice on each one applies where it is supported.

FeatureChromeEdgeFirefoxSafari
<img loading> 77 79 75 15.4
<img decoding> (opens in a new tab) 65 79 63 11.1
<img fetchpriority> (opens in a new tab) 101 101 132 17.2

Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.

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

The snippet also names the image format of each image (WebP, AVIF, JPEG XL). For the support of each format, see MDN’s image file type and format guide (opens in a new tab).

Further reading

Last updated on
MIT 2026 © Joan León | @nucliweb