Lazy Loading
Problem
Your landing page takes 4 seconds to become interactive because the browser is downloading JavaScript for the admin dashboard, analytics, settings, and 47 below-fold images—even though users just want to see your hero section and click “Sign Up.”
I’ve profiled pages where 80% of the JavaScript bundle was code the average user never touches. All that code must download, parse, and compile before users can click anything. On mobile devices with slower CPUs, parsing takes 2-3x longer than desktop. The same applies to images: those 20 product shots below the fold consume bandwidth and memory for content users might never scroll to.
Solution
Don’t load resources until users actually need them.
For JavaScript, use dynamic import() to split your bundle into independently-loadable chunks. Load route components when users navigate to them, and defer heavy features like charting libraries until users open the parts of your app that use them.
For images, add loading="lazy" and let the browser handle timing, or use Intersection Observer for finer control.
For third-party scripts, use async or defer to prevent blocking page render—or better yet, load them programmatically after user interaction since that chat widget doesn’t need to be ready before users can read your content.
Pair lazy loading with preloading for the best experience: when a user hovers over a navigation link, start loading that route’s code before they click.
Example
Component Lazy Loading
import { lazy, Suspense } from 'react';
const AdminPanel = lazy(() => import('./AdminPanel'));
function App() {
return (
<div>
<Header />
<MainContent />
<Suspense fallback={<Loading />}>
<AdminPanel />
</Suspense>
</div>
);
} <template>
<Suspense>
<AsyncAdminPanel />
<template #fallback>
<Loading />
</template>
</Suspense>
</template>
<script>
import { defineAsyncComponent } from 'vue';
export default {
components: {
AsyncAdminPanel: defineAsyncComponent(() =>
import('./AdminPanel.vue')
)
}
};
</script> <script>
let AdminPanel;
async function loadAdminPanel() {
const module = await import('./AdminPanel.svelte');
AdminPanel = module.default;
}
</script>
<button on:click={loadAdminPanel}>Load Admin Panel</button>
{#if AdminPanel}
<svelte:component this={AdminPanel} />
{/if} class LazyComponent extends HTMLElement {
async connectedCallback() {
this.innerHTML = '<div class="loading">Loading...</div>';
const { AdminPanel } = await import('./admin-panel.js');
this.innerHTML = '';
this.appendChild(new AdminPanel());
}
}
customElements.define('lazy-component', LazyComponent); Route-Based Code Splitting
Splitting at route boundaries is the most impactful optimization since each page loads only its own code:
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
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="/dashboard" element={<Dashboard />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Conditional Feature Loading
Loading features only when conditions are met keeps premium or admin code out of standard bundles:
function Dashboard({ user }) {
const [AnalyticsPanel, setAnalyticsPanel] = useState(null);
useEffect(() => {
if (user.isPremium) {
import('./AnalyticsPanel').then(m => setAnalyticsPanel(() => m.default));
}
}, [user.isPremium]);
return (
<div>
<h1>Dashboard</h1>
{AnalyticsPanel && <AnalyticsPanel />}
</div>
);
}
Image Lazy Loading
Native browser lazy loading handles most cases; Intersection Observer provides finer control:
<img src="hero.jpg" alt="Hero" loading="lazy" width="800" height="600" />
<img src="image1.jpg" loading="lazy" alt="Image 1" />
<img src="image2.jpg" loading="lazy" alt="Image 2" /> const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.src = entry.target.dataset.src;
obs.unobserve(entry.target);
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
// HTML: <img data-src="actual.jpg" alt="Lazy image" /> Preloading on Hover
Starting the download when users hover eliminates perceived latency when they click:
function ProductCard({ product }) {
return (
<div onMouseEnter={() => import('./ProductDetails')}>
<h3>{product.name}</h3>
<Link to={`/product/${product.id}`}>View Details</Link>
</div>
);
}
Lazy Third-Party Scripts
Deferring non-critical scripts prevents them from blocking the main thread during page load:
<script src="analytics.js" defer></script>
<script src="chat-widget.js" async></script>
<script>
function loadAnalytics() {
const script = document.createElement('script');
script.src = 'https://analytics.example.com/script.js';
document.head.appendChild(script);
}
document.addEventListener('click', loadAnalytics, { once: true });
</script>
Benefits
- Initial bundle shrinks dramatically—users download only what they need for the first page.
- Time-to-interactive drops because there’s less code to parse. Mobile users benefit most—the difference between 50KB and 500KB often decides whether users wait or leave.
- Users who don’t need certain features never pay for them: free users skip premium code, and users who never visit admin never download the admin bundle.
- Below-fold images load on-demand, saving bandwidth and memory until users scroll to them.
- Core Web Vitals improve across the board—better FCP, LCP, and CLS scores mean happier users and better search rankings.
- Cache efficiency improves because updates to rarely-used features don’t invalidate the cache for common code paths.
Tradeoffs
- First navigation to lazy-loaded routes shows a loading spinner, which can feel slower than a traditional SPA where everything is already downloaded.
- Every lazy boundary needs a loading state—each code-split point requires fallback UI while the chunk downloads.
- Error handling grows more complex: network failures require retry logic and error boundaries throughout your app.
- Debugging across multiple chunks is harder since stack traces span separate source maps.
- Lazy images cause layout shift unless you specify explicit width and height to reserve space.
- Without preloading, clicks feel sluggish—pair lazy loading with hover-triggered prefetching.
- Wrong split boundaries can duplicate shared dependencies across chunks, undoing the benefits. Check your bundle analyzer regularly.
- Modals and drawers users expect to appear instantly don’t mix well with lazy loading—users want immediate response, not spinners.
Summary
Lazy loading defers resources until needed, improving initial performance by skipping code and images users may never see. Apply it to route components, below-fold images, and heavy features—then combine with preloading on hover to eliminate perceived latency when users do need the deferred resources.