Search IOCombats

Search challenges, guides, questions and articles

Implement Lazy Image Loading Without IntersectionObserver

Intermediate16 min interview
Skills tested:
Using getBoundingClientRect to check if an element is within or near the viewportThrottling scroll and resize handlers to avoid performance-degrading layout reads on every eventUsing data-src to defer the actual src assignment until load timeRemoving already-loaded images from the tracking list to reduce work on each scroll eventComparing the scroll approach to IntersectionObserver in terms of main-thread cost

Advertisement

🧩 Scenario

In a real codebase, lazy loading images on a long product listing or news feed dramatically reduces initial page weight and Time to Interactive. Native loading="lazy" handles most cases in modern browsers. The scroll-based approach is needed for custom pre-load margins, low-quality-to-high-quality transitions, or environments that require a polyfill.

Architecture Walkthrough

getBoundingClientRect and Viewport Detection

getBoundingClientRect() returns the size and position of an element relative to the viewport (not the document). An element is in the viewport when its top is less than window.innerHeight and its bottom is greater than zero. Adding an offset margin (for example, 200 pixels) loads images slightly before they enter the viewport, preventing visible pop-in when the user scrolls quickly.

The key performance concern is that getBoundingClientRect triggers a layout recalculation (reflow) for each call. Calling it on 100 images on every scroll event (which fires many times per second) is expensive. The solution is to throttle the scroll handler so it runs at most once every 100 to 200ms, and to remove each image from the watched list immediately after it loads so subsequent scroll events have less work to do.

Throttled Scroll Handler

A throttle using a boolean flag is sufficient for this use case: set a flag on the first call, schedule the actual work in a setTimeout, and reset the flag after the work runs. This ensures the work runs at most once per interval regardless of how rapidly scroll events fire.

The handler must also be attached to the resize event because a viewport resize can bring new images into view without any scroll. Without a resize handler, images that become visible after a window resize would not load until the user scrolls.

Initialization and Cleanup

The load check must run once synchronously on page load to handle images already in the viewport. Without this, images visible on first render will not load until the user scrolls. After all images have loaded, remove the scroll and resize event listeners to avoid processing an empty watched list forever.


Key Code Explained

function lazyLoadImages(selector = 'img[data-src]', offset = 200) {
  // Collect all images that need lazy loading
  const images = new Set(document.querySelectorAll(selector));

  function isNearViewport(el) {
    const rect = el.getBoundingClientRect();
    return (
      rect.top < window.innerHeight + offset && // approaching from below
      rect.bottom > -offset // not above the viewport
    );
  }

  function loadImage(img) {
    img.src = img.dataset.src; // trigger the actual download
    img.removeAttribute('data-src');
    images.delete(img); // remove from watched set — no more work for this image
  }

  function checkImages() {
    images.forEach((img) => {
      if (isNearViewport(img)) loadImage(img);
    });

    if (images.size === 0) {
      // All images loaded — clean up listeners
      window.removeEventListener('scroll', throttledCheck);
      window.removeEventListener('resize', throttledCheck);
    }
  }

  // Throttle: run at most once per 150ms
  let throttleTimer = null;
  function throttledCheck() {
    if (throttleTimer) return;
    throttleTimer = setTimeout(() => {
      throttleTimer = null;
      checkImages();
    }, 150);
  }

  window.addEventListener('scroll', throttledCheck, { passive: true });
  window.addEventListener('resize', throttledCheck);

  // Run once immediately for images already in viewport on page load
  checkImages();
}

// Usage: mark images with data-src, optionally include a placeholder
// <img data-src="/images/product-1.jpg" src="/images/placeholder.svg" alt="Product" />
lazyLoadImages();

// Low-quality to high-quality transition
function lazyLoadWithTransition(selector = 'img[data-src]') {
  const images = new Set(document.querySelectorAll(selector));

  function loadImage(img) {
    const full = new Image();
    full.onload = () => {
      img.src = full.src; // swap to full image only when loaded
      img.classList.add('loaded'); // CSS: .loaded { opacity: 1; transition: opacity 0.3s; }
      images.delete(img);
    };
    full.src = img.dataset.src;
  }

  // ... attach scroll/resize handlers as above
}

The { passive: true } option on the scroll listener tells the browser that the handler will never call preventDefault(). This allows the browser to optimize scroll performance by not waiting for the JavaScript handler to complete before performing the scroll update, which prevents scroll jank.


Tradeoffs

ApproachMain-thread costBrowser supportAccuracy of timing
Scroll-based + getBoundingClientRectModerate (with throttle)UniversalGood with offset margin
IntersectionObserverVery low (off main thread)Modern browsersExcellent
Native loading="lazy"Zero JSModern browsersBrowser-controlled

What Interviewers Actually Check

  • Whether you know getBoundingClientRect triggers layout and why that makes throttling necessary
  • Whether you remove loaded images from the watched list to keep the check efficient
  • Whether you run the check once on page load for images already in the viewport
  • Whether you use { passive: true } on the scroll listener
  • Whether you can describe IntersectionObserver as the preferred modern approach and explain what makes it cheaper

Follow-Up Questions

  1. How does IntersectionObserver avoid the layout-triggering cost of getBoundingClientRect?
  2. How would you lazy-load background images set via CSS background-image rather than <img> src?
  3. What is the loading="lazy" HTML attribute and when would you prefer it over a JavaScript solution?
  4. How would you implement a blur-up effect where a tiny blurred version of the image is shown first and replaced by the full image on load?
  5. How would you handle lazy loading in a virtualized list where images are added and removed from the DOM as the user scrolls?

Common Candidate Mistakes

  • Attaching an unthrottled scroll listener that calls getBoundingClientRect on every scroll event, which triggers layout reflow hundreds of times per second
  • Not removing images from the watched set after they load, causing getBoundingClientRect to run on already-loaded images indefinitely
  • Forgetting to call the check on page load, leaving above-the-fold images unloaded until the user scrolls
  • Setting the offset to 0 pixels, causing images to only load once they are fully in the viewport and creating visible pop-in
  • Not attaching a resize listener, missing the case where a viewport resize brings new images into view without a scroll event

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you explain what getBoundingClientRect returns and how to use it to determine viewport visibility?
  • Can you implement a throttled scroll handler that checks and loads pending images?
  • Can you explain why images already loaded should be removed from the watched set?
  • Can you describe the difference in main-thread cost between the scroll approach and IntersectionObserver?
  • Can you explain the role of the offset margin and how it prevents visible pop-in?

Summary

Scroll-based lazy image loading defers <img> source assignment until images approach the viewport. Each image stores its real URL in a data-src attribute. A throttled scroll handler checks remaining unloaded images using getBoundingClientRect and assigns the real URL when an image is within the offset margin of the viewport. After assignment, the image is removed from the watched set.

The main performance concern is that getBoundingClientRect triggers a layout reflow. Without throttling, a fast scroll fires dozens of events per second and causes proportionally many reflows. Throttling to once per 150ms keeps the overhead negligible. Using { passive: true } on the scroll listener tells the browser it can scroll smoothly without waiting for the JavaScript handler.

IntersectionObserver is the preferred modern API for the same task. It observes element visibility off the main thread, has no layout-triggering cost, and provides configurable root margins and thresholds. The scroll-based approach remains useful as a fallback for legacy environments or when precise margin and threshold control is needed in a context where IntersectionObserver behaves differently.

Frequently Asked Questions

Is IntersectionObserver always the best choice?

IntersectionObserver is the modern standard and is available in all current browsers. The scroll-based approach is a fallback for legacy environments or when custom near-viewport margin logic is required.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement