How to Lazy Load Images and Components in JS
Learn how to lazy load images and components in JavaScript. Covers blur-up placeholders, responsive image lazy loading, progressive JPEG loading, skeleton screens, conditional component loading, and a complete lazy media framework.
Lazy loading images and components requires more than just delaying requests. This guide covers advanced patterns like blur-up placeholders, responsive srcset lazy loading, skeleton screens, and conditional component hydration.
For general lazy loading patterns and data loading, see Lazy Loading in JavaScript: Complete Tutorial.
Blur-Up Image Pattern
class BlurUpImage {
constructor(img, options = {}) {
this.img = img;
this.fullSrc = img.dataset.src;
this.thumbSrc = img.dataset.thumb;
this.transitionDuration = options.transition || 400;
}
init() {
// Set up blur filter on the container
const wrapper = document.createElement("div");
wrapper.className = "blur-up-wrapper";
wrapper.style.cssText = `
position: relative;
overflow: hidden;
display: inline-block;
`;
this.img.parentNode.insertBefore(wrapper, this.img);
wrapper.appendChild(this.img);
// Load tiny thumbnail first (1-2KB)
if (this.thumbSrc) {
this.img.src = this.thumbSrc;
this.img.style.filter = "blur(20px)";
this.img.style.transform = "scale(1.1)";
this.img.style.transition = `filter ${this.transitionDuration}ms, transform ${this.transitionDuration}ms`;
}
return this;
}
load() {
return new Promise((resolve, reject) => {
const fullImg = new Image();
fullImg.onload = () => {
this.img.src = this.fullSrc;
this.img.style.filter = "blur(0)";
this.img.style.transform = "scale(1)";
setTimeout(() => {
this.img.style.transition = "";
}, this.transitionDuration);
resolve(this.img);
};
fullImg.onerror = reject;
fullImg.src = this.fullSrc;
});
}
}
// Automatic blur-up with IntersectionObserver
function setupBlurUpImages() {
const images = document.querySelectorAll("img[data-thumb]");
const blurImages = [];
images.forEach((img) => {
const blurUp = new BlurUpImage(img).init();
blurImages.push(blurUp);
});
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const idx = [...images].indexOf(entry.target);
if (idx >= 0) {
blurImages[idx].load();
observer.unobserve(entry.target);
}
}
});
},
{ rootMargin: "200px" }
);
images.forEach((img) => observer.observe(img));
}Responsive Lazy Loading with Srcset
class ResponsiveLazyImage {
constructor(img) {
this.img = img;
this.sizes = img.dataset.sizes;
this.srcset = img.dataset.srcset;
this.fallbackSrc = img.dataset.src;
}
load() {
if (this.srcset) {
this.img.srcset = this.srcset;
}
if (this.sizes) {
this.img.sizes = this.sizes;
}
if (this.fallbackSrc) {
this.img.src = this.fallbackSrc;
}
this.img.classList.add("loaded");
}
static observeAll(selector = "img[data-srcset]", rootMargin = "250px") {
const images = document.querySelectorAll(selector);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
new ResponsiveLazyImage(entry.target).load();
observer.unobserve(entry.target);
}
});
},
{ rootMargin }
);
images.forEach((img) => observer.observe(img));
return observer;
}
}
// HTML usage:
// <img
// data-src="photo-800.jpg"
// data-srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w"
// data-sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
// width="800" height="600"
// alt="Landscape photo"
// >
ResponsiveLazyImage.observeAll();Skeleton Screen Component
class SkeletonScreen {
static create(config) {
const skeleton = document.createElement("div");
skeleton.className = "skeleton-screen";
skeleton.setAttribute("aria-hidden", "true");
skeleton.setAttribute("role", "presentation");
config.forEach((block) => {
const el = document.createElement("div");
el.className = `skeleton-${block.type}`;
switch (block.type) {
case "text":
el.style.cssText = `
height: 16px;
width: ${block.width || "100%"};
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
margin-bottom: ${block.margin || "8px"};
`;
break;
case "circle":
el.style.cssText = `
width: ${block.size || "48px"};
height: ${block.size || "48px"};
border-radius: 50%;
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
`;
break;
case "rect":
el.style.cssText = `
width: ${block.width || "100%"};
height: ${block.height || "200px"};
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: ${block.radius || "8px"};
margin-bottom: ${block.margin || "12px"};
`;
break;
}
skeleton.appendChild(el);
});
return skeleton;
}
static card() {
return SkeletonScreen.create([
{ type: "rect", height: "180px" },
{ type: "text", width: "70%" },
{ type: "text", width: "100%" },
{ type: "text", width: "40%" },
]);
}
static profile() {
return SkeletonScreen.create([
{ type: "circle", size: "64px" },
{ type: "text", width: "150px" },
{ type: "text", width: "100px" },
]);
}
}
// Inject shimmer animation
const style = document.createElement("style");
style.textContent = `
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`;
document.head.appendChild(style);Conditional Component Loading
class ConditionalLoader {
constructor() {
this.conditions = new Map();
}
when(condition, loader) {
this.conditions.set(condition, loader);
return this;
}
async evaluate() {
const results = {};
for (const [condition, loader] of this.conditions) {
const shouldLoad = typeof condition === "function"
? await condition()
: condition;
if (shouldLoad) {
try {
results[loader.name || "module"] = await loader();
} catch (error) {
console.error("Conditional load failed:", error);
}
}
}
return results;
}
}
// Load components based on conditions
const loader = new ConditionalLoader();
loader
.when(
() => window.innerWidth >= 1024,
() => import("./components/DesktopSidebar")
)
.when(
() => window.innerWidth < 768,
() => import("./components/MobileNav")
)
.when(
() => document.querySelector("[data-chart]") !== null,
() => import("./components/ChartWidget")
)
.when(
() => navigator.geolocation !== undefined,
() => import("./components/LocationWidget")
);
loader.evaluate().then((modules) => {
Object.values(modules).forEach((mod) => {
const Component = mod.default || mod;
if (typeof Component.init === "function") {
Component.init();
}
});
});Lazy Media Framework
class LazyMediaFramework {
constructor(options = {}) {
this.rootMargin = options.rootMargin || "300px";
this.imageObserver = null;
this.componentObserver = null;
this.loadedCount = 0;
this.totalTracked = 0;
this.onProgress = options.onProgress || null;
this.init();
}
init() {
this.imageObserver = new IntersectionObserver(
(entries) => this.handleImages(entries),
{ rootMargin: this.rootMargin }
);
this.componentObserver = new IntersectionObserver(
(entries) => this.handleComponents(entries),
{ rootMargin: "100px" }
);
}
handleImages(entries) {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
const strategy = img.dataset.strategy || "swap";
switch (strategy) {
case "blur":
new BlurUpImage(img).init().load();
break;
case "responsive":
new ResponsiveLazyImage(img).load();
break;
case "swap":
default:
if (img.dataset.srcset) img.srcset = img.dataset.srcset;
if (img.dataset.sizes) img.sizes = img.dataset.sizes;
if (img.dataset.src) img.src = img.dataset.src;
img.classList.add("loaded");
}
this.imageObserver.unobserve(img);
this.reportProgress();
});
}
handleComponents(entries) {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const container = entry.target;
const modulePath = container.dataset.component;
if (modulePath) {
const skeleton = SkeletonScreen.card();
container.appendChild(skeleton);
import(/* @vite-ignore */ modulePath)
.then((mod) => {
container.innerHTML = "";
const Component = mod.default || mod;
if (typeof Component === "function") {
Component(container);
}
})
.catch(() => {
container.innerHTML = "<p>Failed to load</p>";
});
}
this.componentObserver.unobserve(container);
this.reportProgress();
});
}
observeImages(selector = "img[data-src]") {
const images = document.querySelectorAll(selector);
this.totalTracked += images.length;
images.forEach((img) => this.imageObserver.observe(img));
return this;
}
observeComponents(selector = "[data-component]") {
const components = document.querySelectorAll(selector);
this.totalTracked += components.length;
components.forEach((el) => this.componentObserver.observe(el));
return this;
}
reportProgress() {
this.loadedCount++;
if (this.onProgress) {
this.onProgress({
loaded: this.loadedCount,
total: this.totalTracked,
percent: Math.round((this.loadedCount / this.totalTracked) * 100),
});
}
}
destroy() {
this.imageObserver.disconnect();
this.componentObserver.disconnect();
}
}
// Usage
const framework = new LazyMediaFramework({
rootMargin: "400px",
onProgress: ({ loaded, total, percent }) => {
console.log(`Loaded ${loaded}/${total} (${percent}%)`);
},
});
framework.observeImages().observeComponents();| Technique | Visual Quality | Load Speed | Implementation |
|---|---|---|---|
| Simple src swap | Jarring pop-in | Fastest | Easiest |
| Blur-up thumbnail | Smooth transition | Medium | Moderate |
| Skeleton screen | Consistent layout | N/A (placeholder) | Moderate |
| LQIP (Low Quality) | Progressive reveal | Medium | Complex |
| Dominant color | Minimal flash | Fast | Moderate |
Rune AI
Key Insights
- Blur-up thumbnails provide immediate visual context: Load a tiny 1-2KB thumbnail with CSS blur, then transition to the full image for a smooth reveal effect
- Always prevent layout shift with explicit dimensions: Set width/height attributes or CSS aspect-ratio on image containers to reserve space before lazy images load
- Responsive srcset with lazy loading serves optimal sizes: Combine data-srcset with IntersectionObserver to load the right image size for the viewport without downloading oversized assets
- Skeleton screens maintain perceived structure: Use shimmer animation placeholders that match the final component layout to prevent jarring content reflows
- Conditional loading eliminates unused code: Check viewport size, DOM state, and device capabilities before importing components that may never render
Frequently Asked Questions
What is the performance difference between blur-up and skeleton screens?
How do I prevent layout shift (CLS) with lazy loaded images?
Should I lazy load images on mobile differently than desktop?
How do I handle lazy loading in a virtual scroll or recycled list?
When should I use conditional component loading?
Conclusion
Lazy loading images and components with the right visual strategy transforms perceived performance. Blur-up placeholders provide context during loading. Skeleton screens maintain layout structure. Responsive lazy loading with srcset serves optimal image sizes. Conditional component loading eliminates unused JavaScript. For general lazy loading patterns, see Lazy Loading in JavaScript: Complete Tutorial. For scroll-based infinite loading, see Implementing Infinite Scroll with JS Observers.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.