Defer Offscreen Images
Introduction to Offscreen Images
The Lighthouse audit Offscreen images flags images that are requested before users can see them. Downloading those below-the-fold assets during initial navigation wastes bandwidth, competes with critical resources, and delays what matters most: above-the-fold content. The solution is lazy loading—deferring non-critical images until they are near the viewport.
What it measures
Lighthouse estimates the potential byte savings from deferring below-the-fold images. Pages with long feeds, galleries, and product grids commonly trigger this opportunity.
Why it matters (UX/SEO impact)
By prioritizing visible content, you reduce network contention and main-thread pressure, improving perceived speed, engagement, and conversions. Better responsiveness and stability also support search visibility.
Relation to other Web Vitals
- LCP — Faster hero image rendering when bandwidth isn’t wasted on offscreen assets.
- FCP — Fewer early bytes means an earlier first paint.
- TBT / INP — Less main-thread work competing with image decoding and script evaluation.
- CLS — If you reserve dimensions, lazy images won’t shift layout as they load.
Common causes of poor “Offscreen images”
- Loading an entire gallery or feed at once rather than on demand.
- Custom image components without loading="lazy" or IntersectionObserver logic.
- Accidentally lazy-loading the LCP (hero) image — it should be eager and high priority.
- Not reserving width/height, which triggers page jumps on load.
Elements commonly affected
- Images — such as standard <img /> elements or CSS background images (via url())
- Poster images on <video></video> elements
- Large block-level text elements — like<h1>,<h2>, or<p>headings above the fold</p>
What is a good result?
This is an “opportunity” audit rather than a timed vital. The goal is to eliminate unnecessary early requests and reduce potential byte savings toward zero.

How to Improve “Offscreen images”
1) Complete IntersectionObserver lazy loading (full HTML + JS demo)
The following demo shows a robust pattern: a tiny placeholder src, the real image in data-src, observer-based swapping near the viewport, and a <noscript> fallback. Dimensions are reserved to prevent CLS.
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>Lazy Images with IntersectionObserver</title>
- <style>img.lazy-img{filter:blur(6px);transition:filter .3s ease}img.lazy-loaded{filter:none}</style>
- </head>
- <body>
- <h1>Demo: Lazy Load Images</h1>
- <p>Scroll down to load images on demand.</p>
- <img class="lazy-img" alt="Hero" width="1280" height="720" decoding="async" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" data-src="https://cdn.testdom.io/images/hero-1280x720.jpg">
- <div style="height:1200px"></div>
- <img class="lazy-img" alt="Gallery 1" width="800" height="600" decoding="async" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" data-src="https://cdn.testdom.io/images/gallery-1-800x600.jpg">
- <noscript><img src="https://cdn.testdom.io/images/gallery-1-800x600.jpg" alt="Gallery 1" width="800" height="600"></noscript>
- <script>(function(){const imgs=[...document.querySelectorAll('img.lazy-img')];function swap(i){const s=i.getAttribute('data-src');if(!s)return;i.src=s;i.onload=()=>i.classList.add('lazy-loaded');i.removeAttribute('data-src');i.classList.remove('lazy-img')}if('IntersectionObserver'in window){const io=new IntersectionObserver((es,o)=>{es.forEach(e=>{if(e.isIntersecting){swap(e.target);o.unobserve(e.target)}})},{rootMargin:'200px 0px',threshold:0.01});imgs.forEach(i=>io.observe(i))}else{const tryLoad=()=>{imgs.forEach(i=>{if(i.getAttribute('data-src')){const r=i.getBoundingClientRect();if(r.top<window.innerHeight+200)swap(i)}})};['load','scroll','resize'].forEach(ev=>window.addEventListener(ev,tryLoad,{passive:true}));tryLoad()}})();</script>
- </body>
- </html>
Tip: You can also add loading="lazy" to the same images for a simple native baseline while keeping IntersectionObserver for finer control and legacy fallback.
2) Native loading="lazy" (baseline)
- <img src="https://cdn.testdom.io/images/article-figure.jpg" alt="Article figure" width="800" height="534" loading="lazy" decoding="async">
3) AMP: <amp-img> example
AMP images are lazy by default. Define dimensions or use a layout type to keep the page stable.
- <amp-img src="https://cdn.example.com/promo.jpg" width="1200" height="675" layout="intrinsic" alt="Promo banner"></amp-img>
4) Reserve space to prevent CLS
Always reserve space for images so the layout doesn’t jump when they load. You can do this by setting explicit width and height, using the CSS aspect-ratio property, or by using the padding-top technique shown below. For more on preventing shifts, see Cumulative Layout Shift (CLS).
Responsive solution (recommended for fluid layouts)
Reserve space using the padding-top trick. It preserves the aspect ratio and avoids CLS, even when the image scales responsively.
- <div class="scr-ext-wrapper" style="grid-template-columns: auto minmax(auto, 500px) auto;">
- <div class="scr-wrapper" style="position: relative; width: 100%; max-width: 500px; padding-top: 58.2301%;">
- <img
- alt="AI tools for long-form bloggers at Copy.ai"
- class="scr"
- src="//domain.com/images/my-image.jpg"
- title="Copy.ai has a built-in editor"
- style="position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover;" />
- </div>
- </div>
How it works:
- padding-top: 58.2301% preserves a 500×291 aspect ratio (291 / 500 × 100 = 58.2301%).
- position: absolute on the image makes it fill the reserved container.
- width: 100% inside a max-width–constrained wrapper keeps the image responsive without causing layout shifts.
Modern alternative: CSS aspect-ratio
If you can rely on modern browsers, aspect-ratio is cleaner and self-documenting.
- <div class="img-frame" style="width:100%; max-width:500px; aspect-ratio: 500 / 291; position:relative;">
- <img
- alt="AI tools for long-form bloggers at Copy.ai"
- src="//domain.com/images/my-image.jpg"
- title="Copy.ai has a built-in editor"
- style="position:absolute; inset:0; width:100%; height:100%; object-fit:cover;" />
- </div>
Either approach prevents CLS by reserving the image’s final footprint before the file is downloaded. For detailed guidance on layout stability, read the CLS article.
5) Do not lazy-load the LCP image
The hero (LCP) image should use loading="eager" and often fetchpriority="high" to render as fast as possible.
6) Responsive images
Use srcset/sizes or <picture> so the browser downloads the right size for each viewport.
Platform-Specific Lazy Loading
WordPress
Modern WordPress adds loading="lazy" automatically. To enforce it in custom code:
- <?php
- add_filter('wp_get_attachment_image_attributes', function($attr){
- if(empty($attr['loading'])){ $attr['loading']='lazy'; }
- return $attr;
- },10,1);
- ?>
Hero note: Set the hero image to loading="eager" and consider fetchpriority="high".
Drupal
Ensure your field formatter or Twig outputs the attribute:
- <img src="{{ file_url(image_uri) }}" alt="{{ alt }}" width="{{ width }}" height="{{ height }}" loading="lazy" decoding="async">
Joomla
Enable Lazy Loading in Global Configuration (recent versions). In template overrides:
- <img src="<?= $this->baseurl ?>/images/photo.jpg" alt="Sample" width="600" height="400" loading="lazy" decoding="async">
Magento (Adobe Commerce)
Add native lazy loading in PHTML/UI components for catalog and gallery images:
- <img src="<?= $block->getImageUrl($_product,'category_page_list') ?>" alt="<?= $block->escapeHtml($_product->getName()) ?>" width="600" height="600" loading="lazy" decoding="async">
HubSpot CMS
With HubL modules or templates, emit the attribute:
- <img src="{{ resize_image_url(module.image, 800) }}" alt="{{ module.image.alt }}" width="800" height="534" loading="lazy" decoding="async">
Contentful
When rendering via React/Next or plain HTML, add the attribute and size params:
- // JSX
- <img src={image.url + '?w=800&q=75'} alt={image.description || 'Image'} width="800" height="534" loading="lazy" decoding="async" />
Shopify (Online Store)
Most modern themes (e.g., Dawn) apply lazy loading. To be explicit in Liquid:
- {% assign img = product.featured_image | image_url: width: 1200 %}
- <img src="{{ img }}" alt="{{ product.title | escape }}" width="1200" height="800" loading="lazy" decoding="async">
Hero note: For above-the-fold product images, use loading="eager" and optionally fetchpriority="high".
Wix
Wix optimizes many image widgets by default. With Velo for custom behavior, set the image source only when it enters the viewport:
- // Velo example
- import { onReady } from 'wix-window';
- $w.onReady(function(){
- $w('#myImage').onViewportEnter(() => {
- $w('#myImage').src = 'https://static.example.com/large-photo.jpg';
- });
- });
Monitoring and Continuous Testing
After implementing lazy loading, verify improvements continuously. Testdom.io can schedule repeatable Web Vitals tests, track historical trends, and alert on regressions.
Sample code to monitor LCP in the field
- new PerformanceObserver((entryList) => {
- for (const entry of entryList.getEntries()) {
- if (entry.entryType === 'largest-contentful-paint') {
- console.log('LCP:', Math.round(entry.startTime), 'ms');
- }
- }
- }).observe({ type: 'largest-contentful-paint', buffered: true });
What else improves when you lazy load?
- Offscreen images audit: Potential byte savings approach zero.
- LCP: Bandwidth and decoder time are available for the hero image.
- FCP: Fewer early bytes lead to an earlier first paint.
- TBT / INP: Less main-thread contention from decoding and JS.
- CLS: Stable layout if dimensions are reserved.
Checklist
- Add loading="lazy" to non-critical <img> elements.
- Use IntersectionObserver for fine control and legacy support.
- Never lazy-load the LCP (hero) image; consider fetchpriority="high".
- Reserve dimensions or use aspect-ratio to avoid CLS.
- Serve properly sized, compressed images (WebP/AVIF) via CDN.
Summary
The Lighthouse audit “Offscreen images” highlights unnecessary image requests that occur before they are visible. By implementing lazy loading—through native loading="lazy", IntersectionObserver, AMP’s <amp-img>, or CMS/SaaS-specific techniques—you can ensure images load only when needed. This optimization not only eliminates wasted bytes but also improves key metrics including LCP, FCP, TBT, INP, and CLS. Always reserve image space with width/height, aspect-ratio, or the padding-top trick to prevent layout shifts. With proper implementation, pages become faster, lighter, and more stable—leading to better user experiences and stronger SEO performance.