Import on Visibility: Load When Element Enters Viewport
Import on visibility is a practical lazy-loading pattern for JavaScript-heavy UI that users may never reach. Instead of shipping every widget on first load, you wait until an element becomes relevant to the current viewport and only then fetch the code.
Quick Decision Guide
Use Intersection Observer to watch a placeholder. When it approaches the viewport, dynamically import() the heavy module.
- Best for below-the-fold, optional, or expensive UI - rootMargin lets you start loading slightly before the element is visible - Always disconnect the observer after success or on cleanup - Strong candidates: comments, embeds, charts, chat widgets, recommendations
Interview signal: this pattern reduces initial JavaScript cost by moving optional code behind actual user scroll behavior.
Why This Pattern Exists
Not every component deserves to be in the initial bundle.
🔥 Insight
The performance win is not just “loading later.” The real win is protecting the critical path from code that is optional, late, or below the fold.
Good Use Cases
Weak Use Cases
Mental Model
> If a user might never scroll to it, the browser probably should not pay for it during startup.
Intersection Observer Basics
The Intersection Observer API lets you asynchronously observe when a target element intersects with the viewport or another scroll container.
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('Element is visible');
}
});
},
{
root: null,
rootMargin: '0px',
threshold: 0
}
);
const element = document.querySelector('.lazy-component');
observer.observe(element);
observer.disconnect();Important Options
root: the viewport by defaultrootMargin: expands or shrinks the trigger zonethreshold: how much of the target must intersect before the callback firesPractical Takeaway
For lazy-loading code, rootMargin is often more important than threshold. It lets you begin fetching before the user actually sees the component.
Basic Import-on-Visibility Pattern
const placeholder = document.querySelector('.comments-placeholder');
let hasLoaded = false;
const observer = new IntersectionObserver(async (entries) => {
for (const entry of entries) {
if (!entry.isIntersecting || hasLoaded) continue;
hasLoaded = true;
const { default: Comments } = await import('./Comments.js');
const mountNode = document.createElement('div');
placeholder.replaceWith(mountNode);
// Mount Comments here
// Example: ReactDOM.createRoot(mountNode).render(<Comments />)
observer.unobserve(entry.target);
observer.disconnect();
}
}, {
rootMargin: '100px'
});
observer.observe(placeholder);What Happens
1. placeholder HTML renders immediately
2. observer waits for that area to approach the viewport
3. code starts downloading only when likely needed
4. once loaded, the real component mounts
Why `rootMargin` Helps
Without margin, loading may start too late and the user can briefly see an empty placeholder.
With a positive margin like 100px or 200px, the browser starts fetching a little early for smoother UX.
React Hook Pattern
import { useState, useEffect, useRef } from 'react';
function useLoadOnVisibility(loader, rootMargin = '100px') {
const ref = useRef(null);
const [Component, setComponent] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!ref.current || Component || isLoading) return;
let cancelled = false;
const observer = new IntersectionObserver(
async ([entry]) => {
if (!entry.isIntersecting) return;
observer.disconnect();
setIsLoading(true);
try {
const mod = await loader();
if (!cancelled) {
setComponent(() => mod.default || mod);
}
} catch (err) {
if (!cancelled) {
setError(err);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
},
{ rootMargin }
);
observer.observe(ref.current);
return () => {
cancelled = true;
observer.disconnect();
};
}, [loader, rootMargin, Component, isLoading]);
return { ref, Component, isLoading, error };
}
function App() {
const { ref, Component, isLoading, error } = useLoadOnVisibility(
() => import('./HeavyComponent.js')
);
return (
<div>
<h1>My Page</h1>
<div ref={ref}>
{error && <div>Failed to load component</div>}
{isLoading && <div>Loading component...</div>}
{Component && <Component />}
{!Component && !isLoading && !error && (
<div className="placeholder">Scroll to load component</div>
)}
</div>
</div>
);
}Why This Pattern Works
Reusable Component Pattern
import { useEffect, useRef, useState } from 'react';
function LazyLoadOnVisible({
loader,
placeholder = <div>Loading...</div>,
rootMargin = '100px'
}) {
const ref = useRef(null);
const [Component, setComponent] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
if (!ref.current || Component || error) return;
let done = false;
const observer = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting || done) return;
done = true;
observer.disconnect();
try {
const mod = await loader();
setComponent(() => mod.default || mod);
} catch (err) {
setError(err);
}
}, { rootMargin });
observer.observe(ref.current);
return () => observer.disconnect();
}, [loader, rootMargin, Component, error]);
return (
<div ref={ref}>
{error ? <div>Failed to load</div> : Component ? <Component /> : placeholder}
</div>
);
}Good Use
<LazyLoadOnVisible
loader={() => import('./Comments.js')}
placeholder={<div>Scroll down to see comments</div>}
rootMargin="150px"
/>Interview Takeaway
A reusable abstraction is good only if it keeps the loading trigger, fallback, and cleanup behavior explicit.
Advanced Patterns
Multiple Lazy Regions
function Page() {
return (
<div>
<Hero />
<MainContent />
<LazyLoadOnVisible loader={() => import('./Comments.js')} />
<LazyLoadOnVisible loader={() => import('./RelatedArticles.js')} />
<LazyLoadOnVisible loader={() => import('./Newsletter.js')} />
</div>
);
}Preload Slightly Before Visibility
const { ref, Component } = useLoadOnVisibility(
() => import('./HeavyWidget.js'),
'200px'
);Larger rootMargin starts fetching sooner. This improves smoothness but may also load code the user never reaches.
Parallel Follow-up Imports
If one visible region requires several heavy modules together, start them in parallel:
const [charts, utils] = await Promise.all([
import('./charts.js'),
import('./chart-utils.js')
]);Trade-off
Visibility-triggered loading improves startup, but pushes some latency into scroll-time interaction. That is a good trade only when the component is genuinely non-critical.
Real-World Examples
Comments Section
function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<LazyLoadOnVisible
loader={() => import('./Comments.js')}
placeholder={<div>💬 Comments will load when you scroll here</div>}
rootMargin="150px"
/>
</article>
);
}Chat Widget
function Layout({ children }) {
return (
<div>
<Header />
<main>{children}</main>
<Footer />
<LazyLoadOnVisible
loader={() => import('./ChatWidget.js')}
placeholder={<div>💬 Live chat</div>}
rootMargin="200px"
/>
</div>
);
}Social Embeds
function ArticleWithEmbeds() {
return (
<article>
<h1>Article Title</h1>
<p>Content...</p>
<LazyLoadOnVisible
loader={() => import('./TwitterEmbed.js')}
placeholder={<div>🐦 Twitter embed will load here</div>}
/>
<LazyLoadOnVisible
loader={() => import('./InstagramEmbed.js')}
placeholder={<div>📸 Instagram embed will load here</div>}
/>
</article>
);
}Performance Impact and Trade-offs
Benefits
Costs
🔥 Insight
The goal is not to delay everything. The goal is to keep the startup path reserved for what the user needs first.
Strong Candidates
Weak Candidates
Best Practices
1. Pick a Sensible `rootMargin`
{ rootMargin: '200px', threshold: 0 } // earlier
{ rootMargin: '50px', threshold: 0 } // balanced
{ rootMargin: '0px', threshold: 0.5 } // later, more conservative2. Always Clean Up
useEffect(() => {
const observer = new IntersectionObserver(callback);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);3. Load Once
if (entry.isIntersecting && !hasLoaded) {
hasLoaded = true;
loadComponent();
observer.disconnect();
}4. Provide Meaningful Placeholder UI
<div
ref={ref}
role="region"
aria-label="Comments section"
aria-busy={isLoading}
>
{Component ? <Component /> : <div>Comments will load when visible</div>}
</div>5. Test Observer Logic Explicitly
global.IntersectionObserver = class IntersectionObserver {
constructor(callback) {
this.callback = callback;
}
observe() {
this.callback([{ isIntersecting: true }]);
}
disconnect() {}
unobserve() {}
};6. Prefer Native Lazy Features Where Available
For images and iframes, prefer native lazy loading before building custom JavaScript observer logic. Save this pattern for JavaScript-heavy UI, not assets the browser already knows how to lazy-load efficiently.
Key Takeaways
import()rootMargin to start loading slightly before the component is visible