Implement Lazy Image Loading Without IntersectionObserver
Advertisement
🧩 Scenario
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
| Approach | Main-thread cost | Browser support | Accuracy of timing |
|---|---|---|---|
| Scroll-based + getBoundingClientRect | Moderate (with throttle) | Universal | Good with offset margin |
| IntersectionObserver | Very low (off main thread) | Modern browsers | Excellent |
| Native loading="lazy" | Zero JS | Modern browsers | Browser-controlled |
What Interviewers Actually Check
- Whether you know
getBoundingClientRecttriggers 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
- How does
IntersectionObserveravoid the layout-triggering cost ofgetBoundingClientRect? - How would you lazy-load background images set via CSS
background-imagerather than<img>src? - What is the
loading="lazy"HTML attribute and when would you prefer it over a JavaScript solution? - 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?
- 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
getBoundingClientRecton every scroll event, which triggers layout reflow hundreds of times per second - Not removing images from the watched set after they load, causing
getBoundingClientRectto 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
getBoundingClientRectreturns 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.
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