Mastering Native Image Lazy Loading with Vanilla JavaScript
It's easy to fuss over minified CSS and deferred scripts while ignoring the thing that actually weighs the page down: the images. If a page loads every image up front — including the dozen the visitor will never scroll to — you're spending bandwidth on nothing and pushing back the moment your main content appears. Lazy loading fixes that, and most of the time you don't even need JavaScript to do it.
Key Takeaways
- Eager loading heavily penalizes performance and Core Web Vitals.
- HTML's native
loading="lazy"attribute handles most lazy-loading needs without any JavaScript. - Providing explicit image dimensions completely prevents layout shifting (CLS).
- For legacy support and advanced UI behaviors, the native Intersection Observer API provides highly performant lazy loading.
The Problem with Eager Loading
By default, a browser tries to fetch everything in the markup right away. Put 50 product photos on a page and it'll open requests for all 50 at once, even the ones sitting three screens below the fold. Those requests compete for bandwidth with the content the visitor is actually looking at, so the top of the page renders later than it should.
The result is a page that feels slow, burns more bandwidth than it needs to, and scores worse on the load-speed metrics Google watches. Lazy loading is the fix: defer the off-screen images until they're about to be seen.
Phase 1: The Native HTML loading="lazy" Attribute
The simplest approach needs no JavaScript at all. Every modern browser supports the loading attribute on <img> and <iframe> tags — one word in your markup and you're done.
<!-- Native Lazy Loading Example -->
<img
src="optimized-hero-image.webp"
alt="Developer coding on laptop"
width="800"
height="600"
loading="lazy"
/>
By adding loading="lazy", you instruct the browser to defer fetching the image until it crosses a calculated distance threshold from the user's viewport.
Don't skip the width and height
Notice the width and height in the snippet above. Until a deferred image downloads, the browser has no idea how tall it is. Leave the dimensions off and it reserves no space; then the image arrives mid-scroll and shoves everything below it down the page. That jump is exactly what Cumulative Layout Shift (CLS) measures. Set width and height and the browser holds the right-sized gap from the start.
Phase 2: The Vanilla JS Intersection Observer API
Native lazy loading covers the common case, but sometimes you want more control — fading an image in as it appears, loading a CSS background image, or handling a browser too old to support the attribute. You don't need jQuery for any of that. The Intersection Observer API is built into the browser and does the job in a few lines.
What it does is watch whether an element has crossed into the viewport and tell you when it has. The checks happen off the main thread, so observing a few hundred images doesn't bog down scrolling the way the old scroll-event listeners used to.
Step 1: Update Your Markup
Instead of putting the image URL in the src attribute (which triggers an immediate download), we place it in a custom data-src attribute. We use a tiny, 10px placeholder for the initial load.
<img
class="lazy-image blur-up"
src="tiny-placeholder-10px.jpg"
data-src="high-res-image.webp"
alt="Detailed architecture"
width="1200"
height="800"
/>
Step 2: The Vanilla JS Logic
Here is the pure JavaScript code to observe these elements and swap the src only when they approach the viewport:
document.addEventListener("DOMContentLoaded", function() {
let lazyImages = [].slice.call(document.querySelectorAll("img.lazy-image"));
if ("IntersectionObserver" in window) {
let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
entries.forEach(function(entry) {
// If the image is in the viewport
if (entry.isIntersecting) {
let lazyImage = entry.target;
// Swap the data-src to the actual src
lazyImage.src = lazyImage.dataset.src;
// Optional: remove a blur class for a smooth CSS transition
lazyImage.classList.remove("blur-up");
lazyImage.classList.add("loaded");
// Stop observing once loaded to save memory
lazyImageObserver.unobserve(lazyImage);
}
});
}, {
// Load the image 50px before it enters the screen
rootMargin: "0px 0px 50px 0px"
});
lazyImages.forEach(function(lazyImage) {
lazyImageObserver.observe(lazyImage);
});
} else {
// Fallback for extremely old browsers
lazyImages.forEach(function(lazyImage) {
lazyImage.src = lazyImage.dataset.src;
});
}
});
The WebP Connection
Lazy loading decides when an image downloads. It doesn't change how big that image is. So the two techniques solve different halves of the same problem and work best together: defer the off-screen images, and make sure each one is as small as it can reasonably be. Google's figures put WebP a good chunk smaller than the equivalent JPEG, so converting first means that when a deferred image finally loads, it's a lighter download too. Lazy loading alone won't save you if every image is a 3 MB JPEG.
Convert before you defer
Lazy loading helps most when the images are already small. Run yours through the WebP converter first — it works right in your browser, no upload.
Optimize Images NowAbout WebPMagic
WebPMagic is an independent project focused on image optimization and web performance. These guides are researched and edited to give developers clear, practical, and accurate information for building faster websites, with tips drawn from hands-on use of our own WebP conversion tool. Found an error or have a suggestion? Let us know via our contact page.