Drupalwoo ☉

Lazy loading images in Drupal with JavaScript

Large Drupal pages can feel slow before a visitor reaches the main content. Product catalogues, event listings and media-heavy articles often request every image at once, so a browser in Brisbane or Perth competes for bandwidth with scripts, styles and third-party widgets. Deferring offscreen images reduces that initial workload without changing the page’s editorial structure.

Modern browsers already understand the loading="lazy" attribute, which gives Drupal sites a fast, dependable baseline. JavaScript becomes useful when a project needs custom placeholders, tighter control over responsive sources or a controlled embedded client environment. The goal is to defer transmission of image bytes, not to delay the page from becoming readable.

This guide builds a progressive approach for Drupal 10 and 11: use native lazy loading by default, add an Intersection Observer only where the markup requires it, and preserve an eager path for the primary image. Each script works with the image styles, Twig output and asset pipeline that Drupal already provides.

The examples also cover dimensions, alternative text, fallback behaviour and performance checks. They suit Australian publishers, retailers and agencies whose audiences may move between metropolitan 4G/5G, fixed broadband and less consistent regional mobile connections while browsing image-rich pages.

Why deferred images matter

Initial bandwidth is only part of the experience. A browser can spend time decoding dozens of full-resolution photographs, increasing main-thread work and delaying interaction. Offscreen images are therefore good candidates for deferred loading, especially on long articles and category listings that Drupal editors update frequently.

The exception is the largest above-the-fold visual. If it is the page’s Largest Contentful Paint element, lazy loading can make the perceived load slower. Keep that image eager, set explicit dimensions and consider fetchpriority="high". Decorative icons, a site logo and small interface graphics usually gain little from lazy loading and add unnecessary observer work.

Plan the Drupal markup

Before writing JavaScript, map every image to its rendering method. Drupal’s responsive image field usually produces srcset, sizes, intrinsic width and height, plus an alt value. Native lazy loading can sit directly on that output. Hero media, social preview images and images inside tabs that open automatically may need eager loading instead.

A simplified Twig response can include the native attribute without extra JavaScript:

<img src="{{ image_url }}"
     srcset="{{ responsive_sources }}"
     sizes="{{ sizes }}"
     width="{{ width }}"
     height="{{ height }}"
     loading="lazy"
     decoding="async"
     alt="{{ alt }}">

A custom loader is justified when a design calls for a blurred placeholder, several templates produce the same markup, or the team must coordinate many responsive variants. Use a consistent class and data-lazy-src attributes rather than identifying images by URL. Keeping the final URL in markup also preserves predictable caching, CDN delivery and Drupal image-style derivatives.

Compare loading strategies

There are three practical loading options, and they can coexist on one site. Choose the method for each placement rather than applying one policy to every <img>. This makes trade-offs visible during a Drupal rebuild and reduces the amount of JavaScript shipped to visitors.

Approach How it works Best Drupal use Main caution
Native lazy loading The browser defers offscreen images Standard editorial images and responsive fields Placeholder styling is limited
Intersection Observer JavaScript loads an image near the viewport Custom placeholders and controlled loading sequences Requires markup and a fallback
Eager loading The browser requests the image immediately Hero images, logos and above-the-fold media Adds weight to the initial request
Responsive derivatives Drupal serves smaller image styles Cards, thumbnails and catalogue listings Reduces size but does not defer requests

Native loading is normally the safest default in current browsers. The scripted method earns its complexity only when it provides a visible benefit, such as a branded low-resolution placeholder or an application-specific loading sequence. Eager loading remains essential for the first meaningful image.

Build an Intersection Observer loader

Intersection Observer watches elements as they approach the visible viewport. When an image crosses a generous boundary, the script copies the deferred source into src, applies any responsive srcset, and stops observing that element. This avoids scroll-event calculations and lets the browser schedule work efficiently.

const images = document.querySelectorAll('.js-lazy-image');

const loadImage = (image) => {
  const srcset = image.dataset.lazySrcset;
  const src = image.dataset.lazySrc;

  if (!src) return;

  if (srcset) {
    image.setAttribute('srcset', srcset);
  }

  image.addEventListener('error', () => {
    image.classList.add('is-error');
  }, { once: true });

  image.src = src;
  image.classList.add('is-loaded');
  image.removeAttribute('data-lazy-src');
  image.removeAttribute('data-lazy-srcset');
};

if ('IntersectionObserver' in window) {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;

      loadImage(entry.target);
      observer.unobserve(entry.target);
    });
  }, {
    rootMargin: '250px 0px',
    threshold: 0.01
  });

  images.forEach((image) => observer.observe(image));
} else {
  images.forEach(loadImage);
}

The rootMargin value begins loading images slightly before they enter the viewport, which is useful on slow connections. The observer also keeps only visible candidates active instead of recalculating positions on every scroll. Once assigned, the real image can transition from a placeholder through an is-loaded class without changing its reserved box.

Production code should also handle failed requests. Switch to a known fallback when one exists, expose a useful class for monitoring and avoid an infinite retry loop. If JavaScript is disabled, the template must render a normal image; the observer should enhance the page rather than make its content dependent on script execution.

Connect Drupal templates and assets

Drupal can attach the loader through a small library declared in MODULE.info.yml, then include that library with hook_preprocess_html() or a template preprocess hook. A defer attribute prevents the observer from blocking initial parsing, and the selector should be scoped to the content region.

libraries:
  - core/drupal
  - drupal/site
  - my_module/lazy_images

js:
  - js/lazy-load.js:
      attributes:
        defer: true

The Twig template should expose the actual derivative through a data attribute while retaining a tiny placeholder as the initial src. Intrinsic dimensions prevent layout shift as the file arrives. A <noscript> copy provides the full image when scripting is unavailable.

<img class="js-lazy-image"
     src="{{ placeholder_url }}"
     data-lazy-src="{{ lazy_image.url }}"
     data-lazy-srcset="{{ lazy_image.srcset }}"
     sizes="{{ lazy_image.sizes }}"
     width="{{ lazy_image.width }}"
     height="{{ lazy_image.height }}"
     alt="{{ lazy_image.alt }}">

<noscript>
  <img src="{{ lazy_image.url }}"
       srcset="{{ lazy_image.srcset }}"
       sizes="{{ lazy_image.sizes }}"
       width="{{ lazy_image.width }}"
       height="{{ lazy_image.height }}"
       loading="lazy"
       alt="{{ lazy_image.alt }}">
</noscript>

Build lazy_image in hook_preprocess_node() or a template preprocess, using Drupal’s ImageStyle derivative URL and the file entity’s metadata. When several responsive candidates are needed, store the complete value in data-lazy-srcset; the script then moves it into the native srcset attribute. Clear both data attributes after loading so later code can identify completed images.

Protect layout, accessibility, and analytics

Deferring bytes must not move the page around. Reserve space with width and height, keep the placeholder decorative, and retain meaningful alternative text on content images. Automated checks should run against the rendered page, because a stale Twig cache can leave old eager-loading attributes in place.

Before deployment:

Privacy and measurement deserve the same attention. Intersection Observer itself does not identify a visitor, but paired analytics can record viewing behaviour. Under Australia’s Privacy Act and Australian Privacy Principles, a project still needs a lawful basis, accurate notice and appropriate consent where required. Keep image loading free of third-party requests.

During browser and field checks:

Test and maintain the change

Use Drupal’s performance tests, browser developer tools and real-device checks before release. A useful test loads a representative article or product listing, throttles the connection and records when each image begins and finishes. Repeat after clearing Drupal’s cache, since aggregate or custom render arrays can otherwise conceal template errors.

Staging should include pages produced by several content types: long news stories, a busy retail category, a media gallery and a campaign landing page. That coverage matters for sites featuring the Sydney Opera House, Melbourne laneways or Great Barrier Reef trips, where large travel photographs can outweigh the rest of the document. The background to Drupalwoo’s practical Drupal focus is available on the project overview.

Monitor field data after launch and keep the loader under version control with its Drupal library. Record which templates use .js-lazy-image, remove unused rules when image styles change, and revisit native lazy loading when browser capabilities improve. For an Australian retailer preparing an EOFY, Black Friday, Christmas or Boxing Day campaign, the same checks protect seasonal pages built around product, fashion and event imagery while visitors use a mixture of metropolitan broadband and mobile data.