Lazy Loading
Problem
Initial page load downloads megabytes of JavaScript that users may never use, including entire admin panels, analytics dashboards, settings pages, and rarely-accessed features that sit dormant in the bundle. Critical first-page interactions like clicking the sign-up button or scrolling the hero section are delayed by seconds because the browser is busy parsing and compiling code for features the user isn’t viewing, while time-to-interactive suffers dramatically, especially on mobile devices with limited CPU and network bandwidth where parsing JavaScript is 2-3x slower than desktop.
Large images below the fold consume bandwidth and memory even though users may never scroll to them. Heavy third-party widgets like chat support, analytics scripts, or social media embeds block the main thread during initial load, while users on slow connections or metered data plans pay for downloading resources they never access. Search engine crawlers see poor performance metrics from bloated initial payloads, hurting SEO rankings.
Solution
Defer loading of non-critical resources until they’re needed, loading components, routes, and images only when users interact with or scroll to them.
Use dynamic import() to split code into separate chunks that load on demand. Load images as they enter the viewport using loading="lazy" attribute or Intersection Observer API. Defer third-party scripts until after initial page load using async or defer attributes.
Load route components only when users navigate to those routes, not upfront. Conditionally load features behind feature flags or user permissions so free-tier users don’t download premium features.
This reduces initial payload and speeds up first render by prioritizing what users see and interact with immediately. Combine with preloading hints to fetch likely-needed resources during idle time so they’re ready when users need them.
Example
This example demonstrates lazy loading an admin panel component so its code is only downloaded when it’s actually rendered, not during the initial page load.
React with Lazy Loading
import { lazy, Suspense } from 'react';
// Lazy load the AdminPanel - code is only fetched when needed
const AdminPanel = lazy(() => import('./AdminPanel'));
const Settings = lazy(() => import('./Settings'));
const Analytics = lazy(() => import('./Analytics'));
function App() {
return (
<div>
{/* Main content loads immediately */}
<Header />
<MainContent />
{/* Suspense shows a fallback while AdminPanel is loading */}
<Suspense fallback={<Loading />}>
{/* AdminPanel code downloads when this component renders */}
<AdminPanel />
</Suspense>
</div>
);
}
Route-Based Code Splitting
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Each route loads its component only when navigated to
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Admin = lazy(() => import('./pages/Admin'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Conditional Feature Loading
function Dashboard({ user }) {
const [AnalyticsPanel, setAnalyticsPanel] = useState(null);
useEffect(() => {
// Only load analytics for premium users
if (user.isPremium) {
import('./AnalyticsPanel').then(module => {
setAnalyticsPanel(() => module.default);
});
}
}, [user.isPremium]);
return (
<div>
<h1>Dashboard</h1>
{user.isPremium && AnalyticsPanel && <AnalyticsPanel />}
</div>
);
}
Image Lazy Loading
<!-- Native lazy loading (modern browsers) -->
<img
src="hero.jpg"
alt="Hero image"
loading="lazy"
width="800"
height="600"
/>
<!-- Multiple images below the fold -->
<img src="image1.jpg" loading="lazy" alt="Image 1" />
<img src="image2.jpg" loading="lazy" alt="Image 2" />
<img src="image3.jpg" loading="lazy" alt="Image 3" />
Intersection Observer for Images
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// Load the actual image
img.src = img.dataset.src;
img.classList.remove('lazy');
// Stop observing this image
observer.unobserve(img);
}
});
});
// Observe all lazy images
document.querySelectorAll('img.lazy').forEach(img => {
imageObserver.observe(img);
});
<!-- Placeholder with data-src for actual image -->
<img
class="lazy"
src="placeholder.jpg"
data-src="actual-image.jpg"
alt="Lazy loaded image"
/>
Vue Async Components
<template>
<Suspense>
<!-- Component code downloads when this renders -->
<AsyncAdminPanel />
<template #fallback>
<Loading />
</template>
</Suspense>
</template>
<script>
import { defineAsyncComponent } from 'vue';
export default {
components: {
// Lazy load the AdminPanel - code is only fetched when needed
AsyncAdminPanel: defineAsyncComponent(() =>
import('./AdminPanel.vue')
),
// With loading component and error handling
AsyncSettings: defineAsyncComponent({
loader: () => import('./Settings.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // Show loading after 200ms
timeout: 10000 // Error after 10s
})
}
};
</script>
Svelte Dynamic Import
<script>
let AdminPanel;
let loading = false;
let error = null;
// Lazy load component when button is clicked
async function loadAdminPanel() {
loading = true;
try {
const module = await import('./AdminPanel.svelte');
AdminPanel = module.default;
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
</script>
<button on:click={loadAdminPanel}>Load Admin Panel</button>
{#if loading}
<Loading />
{:else if error}
<Error message={error} />
{:else if AdminPanel}
<svelte:component this={AdminPanel} />
{/if}
Web Component Lazy Loading
class LazyComponent extends HTMLElement {
async connectedCallback() {
// Show loading state
this.innerHTML = '<div class="loading">Loading...</div>';
try {
// Lazy load component module
const { AdminPanel } = await import('./admin-panel.js');
// Create and append the component
const panel = new AdminPanel();
this.innerHTML = '';
this.appendChild(panel);
} catch (error) {
this.innerHTML = `<div class="error">Failed to load: ${error.message}</div>`;
}
}
}
customElements.define('lazy-component', LazyComponent);
Preloading Strategy
function ProductCard({ product }) {
const prefetchDetails = () => {
// Preload product details when user hovers
import('./ProductDetails');
};
return (
<div onMouseEnter={prefetchDetails}>
<h3>{product.name}</h3>
<Link to={`/product/${product.id}`}>View Details</Link>
</div>
);
}
Lazy Load Third-Party Scripts
<!-- Defer non-critical third-party scripts -->
<script src="analytics.js" defer></script>
<script src="chat-widget.js" async></script>
<!-- Load on user interaction -->
<script>
let analyticsLoaded = false;
function loadAnalytics() {
if (analyticsLoaded) return;
const script = document.createElement('script');
script.src = 'https://analytics.example.com/script.js';
script.async = true;
document.head.appendChild(script);
analyticsLoaded = true;
}
// Load analytics after user interaction
document.addEventListener('click', loadAnalytics, { once: true });
</script>
Lazy Loading with Error Boundaries
function SafeLazyLoad({ component: Component }) {
return (
<ErrorBoundary fallback={<ComponentError />}>
<Suspense fallback={<ComponentLoader />}>
<Component />
</Suspense>
</ErrorBoundary>
);
}
// Usage
const HeavyChart = lazy(() => import('./HeavyChart'));
function Dashboard() {
return (
<div>
<SafeLazyLoad component={HeavyChart} />
</div>
);
}
Progressive Image Loading
function ProgressiveImage({ src, placeholder }) {
const [currentSrc, setCurrentSrc] = useState(placeholder);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Load high-res image
const img = new Image();
img.src = src;
img.onload = () => {
setCurrentSrc(src);
setLoading(false);
};
}, [src]);
return (
<img
src={currentSrc}
alt=""
style={{ filter: loading ? 'blur(10px)' : 'none' }}
/>
);
}
Responsive Image Lazy Loading
<!-- Lazy load with srcset for different screen sizes -->
<img
srcset="
small.jpg 400w,
medium.jpg 800w,
large.jpg 1200w
"
sizes="(max-width: 400px) 400px, (max-width: 800px) 800px, 1200px"
src="medium.jpg"
alt="Responsive image"
loading="lazy"
/>
Benefits
- Dramatically reduces initial bundle size by loading code only when needed, improving Core Web Vitals metrics like First Contentful Paint and Largest Contentful Paint.
- Speeds up time-to-interactive by prioritizing critical rendering path - users can interact with the page faster because the browser isn’t busy parsing unused code.
- Improves perceived performance by making key features available faster - users see content immediately rather than waiting for a single massive bundle.
- Saves bandwidth for users who never access certain features or routes, particularly important for mobile users on metered connections or slow networks.
- Reduces memory consumption by not loading unused components, benefiting low-end devices with limited RAM.
- Enables splitting by user role or permissions so free-tier users don’t download premium features they can’t access.
- Improves cache efficiency since updates to rarely-used features don’t invalidate the entire bundle, allowing core features to stay cached.
Tradeoffs
- Introduces loading states and spinners when lazy resources are first accessed, creating brief delays that can feel sluggish if not handled well with skeleton screens or optimistic UI.
- Adds complexity with Suspense boundaries and error handling for failed loads - network errors during chunk loading require retry logic and error boundaries.
- Can hurt user experience if not combined with preloading or prefetching - clicking a link that triggers a lazy load feels slower than instant navigation with everything pre-loaded.
- Makes it harder to test and debug since code is split across multiple chunks with separate source maps, complicating stack traces and error reporting.
- Requires careful splitting strategy to avoid creating too many small chunks (increasing HTTP overhead) or too few large chunks (defeating the purpose of lazy loading).
- Lazy loaded images can cause layout shift if dimensions aren’t specified, hurting Cumulative Layout Shift scores and causing content to jump as images load.
- Dynamic imports return promises that must be handled correctly - forgetting await or proper error handling creates hard-to-debug race conditions.
- Browser back/forward navigation can feel slow if previously visited lazy-loaded routes need to re-fetch chunks that weren’t cached properly.
- Code splitting at the wrong boundaries can cause the same dependencies to be duplicated across multiple chunks, increasing total bundle size despite splitting.
- Testing lazy loaded components requires mocking dynamic imports which is awkward in most test frameworks and can lead to false confidence if mocks don’t match production behavior.
- Analytics and error tracking become complex when components load asynchronously - determining which code version caused an error requires tracking chunk versions.
- Native
loading="lazy"has limited browser support (though widely available in modern browsers), requiring Intersection Observer polyfills for older browsers. - Intersection Observer-based lazy loading requires maintaining observer instances and cleanup logic to avoid memory leaks when components unmount.
- Aggressive lazy loading can backfire on fast connections where network latency for multiple small chunks exceeds the time to download one larger bundle.
- Route-based code splitting works well for page transitions but less well for modals or drawers where users expect instant interaction without loading delays.