Skip to content
Strynal, Digital Agency

Engineering 5 min read

Lazy Loading Explained

Learn what lazy loading is, how to implement it for images and JavaScript, where it hurts more than it helps, and how to ship it without harming user experience.

By Strynal Team

The browser fetches everything in your HTML, whether the visitor ever scrolls down to see it or not. Lazy loading defers that work until a resource is near the viewport, and on most content-heavy sites it is one of the highest-return changes you can make to initial page weight. The catch is that applying it carelessly costs more than it saves.

What lazy loading is

Lazy loading is a loading strategy where resources are deferred until they are close to entering the viewport, rather than fetched during the initial page load. The inverse is eager loading: grab the asset now, regardless of whether it ever appears on screen.

The resources that benefit most are images, iframes, and JavaScript modules. Below-the-fold images are the clearest case. A long article with a dozen photos does not need the image at the bottom until the visitor is nearly there. Fetching it at load time burns bandwidth, slows the critical path, and delays the things the visitor actually sees first.

Every additional second between navigation and a usable page carries a real cost, and it compounds down the funnel. The full picture is in our post on the cost of a slow website.

The one rule: never lazy-load the LCP element

One exception to lock in before anything else.

Never apply lazy loading to the element that sets your Largest Contentful Paint. Your LCP is typically the hero image, a large heading block, or a video poster that the visitor sees immediately on arrival. It is already under time pressure. Marking it loading="lazy" tells the browser this resource can wait, which is the opposite of what you want.

Lazy loading the LCP element is a self-inflicted wound. The browser reads the attribute as “this can wait,” then your most important visible element waits while the visitor stares at a blank.

This is the most common lazy loading mistake in production sites. If your LCP score is poor and your hero image carries loading="lazy", removing that attribute is likely the fastest single fix available. The full context on what moves LCP is in the Core Web Vitals field guide.

Native lazy loading for images

The simplest implementation is the loading attribute, which every modern browser supports and which requires no JavaScript:

<img src="photo.jpg" alt="…" width="800" height="600" loading="lazy" />

A few details that matter in practice.

Set width and height on every image. The browser needs explicit dimensions to hold layout space while the image loads. Without them, the layout shifts when the image arrives, pushing CLS in the wrong direction. This is not optional.

The browser controls the fetch distance. Chrome begins loading lazy images roughly 1,200px before they enter the viewport on a fast connection, closer to 400px on a slow one. You do not set this value; the browser decides based on network conditions.

Test on a throttled connection. Images very close to the fold may flash blank for a moment on slow connections. Your office broadband will hide that. Use browser DevTools to throttle to Slow 4G and scroll through the page before shipping.

For <iframe> elements the same attribute applies:

<iframe src="map.html" loading="lazy" title="…"></iframe>

Lazy loading JavaScript

Images are the easy case. Non-critical JavaScript is where deferral returns the most.

The native mechanism is dynamic import(). Instead of bundling a module into the critical path, you load it the first time the code branch that needs it actually runs:

async function openModal() {
  const { Modal } = await import("./modal.js");
  Modal.open();
}

The module downloads the first time openModal() is called, not when the page loads. For components triggered only by interaction (modals, dropdowns, date pickers, maps), this keeps the initial bundle lean and the main thread free during load.

Bundlers like Vite and Rollup handle the split automatically, emitting separate chunks that load on demand. Deciding what belongs in the critical chunk versus a deferred chunk is a build-time discipline; setting explicit thresholds before anything ships is the core idea behind working with a performance budget.

When to reach for Intersection Observer

The native loading attribute covers most image cases. If you need precise control over when a fetch triggers, the Intersection Observer API gives it:

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        observer.unobserve(img);
      }
    });
  },
  { rootMargin: "400px" }
);

document.querySelectorAll("img[data-src]").forEach((img) => observer.observe(img));

The rootMargin: "400px" starts loading 400px before the element enters view, reducing the chance of a visible delay. The trade-off: this pattern bypasses the browser’s preload scanner, which reads ahead in HTML and starts fetching resources before the parser reaches them. Native loading="lazy" keeps preload scanning intact, so it is the right default unless you have a specific reason to reach for JavaScript.

Two trade-offs to understand

Preload scanner bypass. When you lazy-load images via a JavaScript data-src swap, the preload scanner never sees the source URL. The browser cannot start the fetch until the script runs. Native loading="lazy" does not have this problem. Prefer the native attribute.

Search crawlers. Most crawlers render JavaScript and simulate scrolling, but there is no guarantee a crawler reaches every lazy-loaded asset. If an image carries important alt text or visual context you want indexed, apply lazy loading conservatively on those elements.

Neither issue is a reason to avoid lazy loading. They are reasons to apply it with intention rather than as a blanket rule dropped across every image on the page.

How Strynal approaches lazy loading

Lazy loading is a small decision that compounds. On a content-heavy site, images that should be deferred but are not drag on page weight from day one. The LCP image that was accidentally lazy-loaded drags on the metric that matters most, visibly.

At Strynal, loading strategy is defined during build rather than left to framework defaults. Every project in our websites and apps practice ships with explicit decisions: eager for the LCP element and anything above the fold, natively lazy for everything below it, dynamic imports for interaction-triggered modules. Those decisions sit alongside format and dimension choices from the start. Choosing the right image format and encoding and choosing the right loading strategy answer different questions, but they depend on each other.

If you are auditing an existing site, the fastest starting point is checking whether your hero image carries loading="lazy". Remove it, measure in the field, then work down the page from there.