Lazy Loading in SPAs — Loading Components and Routes on Demand
In this tutorial, you will learn about Lazy Loading in SPAs. We cover key concepts, practical examples, and best practices to help you master this topic.
Lazy loading defers loading non-critical SPA components and routes until needed, reducing initial bundle size and improving Time to Interactive for complex applications.
What You'll Learn
By the end of this tutorial, you will understand how lazy loading works in SPAs, how to implement it with React.lazy and Suspense, how to handle loading states, and best practices for route-based splitting.
Why It Matters
The biggest performance problem in SPAs is the initial JavaScript bundle. A large bundle delays Time to Interactive by 1+ seconds per 100KB. Lazy loading cuts initial bundle size in half or more, making the app load faster while preserving all functionality.
Real-World Use
A dashboard SPA with 50+ pages used lazy loading to split the bundle. The initial load dropped from 3.2 seconds to 1.1 seconds. Users saw the login page instantly. The admin panel loaded only when an admin logged in, and rarely-used features loaded on first click.
Lazy Loading Flow
Lazy Loading in SPA
Initial load: only critical code downloaded
(app shell, login page, shared components)
↓
User navigates to /dashboard
↓
SPA loads Dashboard component dynamically
(import('./pages/Dashboard.js'))
↓
Browser downloads Dashboard chunk
↓
Loading indicator shown during download
↓
Dashboard component rendered once loaded
↓
Other routes remain as separate chunks
loaded only when navigated to
Think of lazy loading like a library versus a personal book collection. Instead of buying every book (downloading all code upfront), you borrow books one at a time (load as needed). Your bookshelf (initial bundle) stays small, and you only carry what you are currently reading.
React.lazy and Suspense
import { lazy, Suspense } from 'react';
// Lazy load components
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Users = lazy(() => import('./pages/Users'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));
const Analytics = lazy(() => import('./pages/Analytics'));
// Loading component
function PageLoading() {
return (
<div className="page-loading">
<div className="spinner">Loading page...</div>
</div>
);
}
function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageLoading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Component-Level Lazy Loading
import { lazy, Suspense, useState } from 'react';
// Lazy load heavy components
const Chart = lazy(() => import('./Chart'));
const DataTable = lazy(() => import('./DataTable'));
const MapView = lazy(() => import('./MapView'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
const [showMap, setShowMap] = useState(false);
return (
<div>
<h1>Dashboard</h1>
{showChart ? (
<Suspense fallback={<div>Loading chart...</div>}>
<Chart />
</Suspense>
) : (
<button onClick={() => setShowChart(true)}>Load Chart</button>
)}
{showMap ? (
<Suspense fallback={<div>Loading map...</div>}>
<MapView />
</Suspense>
) : (
<button onClick={() => setShowMap(true)}>Load Map</button>
)}
<Suspense fallback={<div>Loading table...</div>}>
<DataTable />
</Suspense>
</div>
);
}
Conditional Lazy Loading
import { lazy, Suspense } from 'react';
const AdminPanel = lazy(() => import('./AdminPanel'));
const Editor = lazy(() => import('./Editor'));
function App({ user }) {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
{user.role === 'admin' && <AdminPanel />}
{user.permissions.includes('edit') && (
<Editor documentId={user.currentDoc} />
)}
</Suspense>
</div>
);
}
Preloading Lazy Routes
import { lazy, Suspense, useEffect } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
function Home() {
// Preload dashboard when user is on home page
useEffect(() => {
// Start loading dashboard in background
const preload = import('./pages/Dashboard');
preload.then(() => {
console.log('Dashboard preloaded');
});
}, []);
return (
<div>
<h1>Home Page</h1>
<p>Dashboard is being preloaded in the background</p>
</div>
);
}
Measuring Lazy Loading Impact
// Measure chunk load time
async function measureChunkLoad(chunkName, importFn) {
const start = performance.now();
try {
const module = await importFn();
const loadTime = performance.now() - start;
console.log(`Chunk "${chunkName}" loaded in ${loadTime.toFixed(0)}ms`);
return module;
} catch (error) {
console.error(`Failed to load chunk "${chunkName}":`, error);
throw error;
}
}
// Usage in lazy load
const Dashboard = lazy(() =>
measureChunkLoad('Dashboard', () => import('./pages/Dashboard'))
);
Common Mistakes
- Wrapping every component in lazy(). Lazy loading adds overhead. Use it for route-level or significant component splits. Tiny components add network requests for minimal benefit.
- Forgetting Suspense boundaries. Without Suspense, React throws when a lazy component is loading. Always wrap lazy components in Suspense with a fallback.
- Loading states that cause layout shift. The Suspense fallback should match the lazy component's size to prevent Cumulative Layout Shift (CLS).
- Not preloading likely-next routes. If most users go from Home to Dashboard, preload Dashboard while they read the home page.
- Splitting into too many small chunks. Each chunk has HTTP overhead. Aim for chunks of 20-50KB (compressed). Too many micro-chunks hurt performance.
Practice Questions
- How does React.lazy work with dynamic imports?
- What is the role of Suspense in lazy loading?
- Why should you lazy load at the route level rather than component level?
- How do you preload lazy routes in the background?
- What are the costs of lazy loading (network requests, latency)?
Challenge: Implement route-based lazy loading in a React SPA with 8 routes. Show different loading indicators for each route. Preload the most likely next route (from analytics) while the user reads the current page. Measure and log chunk load times.
FAQ
Mini Project
Build a React SPA with 10 pages. Implement route-based lazy loading for all pages. Add Suspense boundaries with tailored loading skeletons for each route. Preload the 2 most likely next routes after each navigation. Measure and display the initial bundle size and chunk load times.
What's Next
You learned lazy loading. Now dive into code splitting strategies to further optimize your SPA bundles.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro