Skip to content

Lazy Loading in SPAs — Loading Components and Routes on Demand

DodaTech Updated 2026-06-28 5 min read

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

  1. 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.
  2. Forgetting Suspense boundaries. Without Suspense, React throws when a lazy component is loading. Always wrap lazy components in Suspense with a fallback.
  3. Loading states that cause layout shift. The Suspense fallback should match the lazy component's size to prevent Cumulative Layout Shift (CLS).
  4. Not preloading likely-next routes. If most users go from Home to Dashboard, preload Dashboard while they read the home page.
  5. 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

  1. How does React.lazy work with dynamic imports?
  2. What is the role of Suspense in lazy loading?
  3. Why should you lazy load at the route level rather than component level?
  4. How do you preload lazy routes in the background?
  5. 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

Does lazy loading work with SSR?

Yes. Next.js and Remix support lazy loading with Suspense. However, SSR with lazy loading requires careful configuration to avoid hydration issues.

What is the ideal chunk size?

Aim for 20-50KB (gzipped). Smaller chunks increase HTTP overhead. Larger chunks delay loading specific features. Use bundle analyzer tools to optimize.

Does lazy loading affect SEO?

Lazy loading does not affect SEO directly. Crawlers do not execute JavaScript, so lazy-loaded content is not indexed. Use SSR for SEO-critical pages.

Can I lazy load images in SPAs?

Yes. Use loading=lazy for images and Intersection Observer for custom lazy loading. This is separate from code lazy loading but follows the same principle.

How do I handle errors in lazy loading?

Use Error Boundaries to catch failed imports. If a chunk fails to load (network error), the error boundary displays a fallback UI with a retry button.

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