AI & ML

Mastering Lazy Loading: Boost Performance in React and Next.js Applications

· 5 min read

Oversized JavaScript bundles drag down application performance. When the browser must parse and execute too much code upfront, users face longer wait times before they can interact with the page. Search engines penalize slow sites with lower rankings.

Lazy loading addresses this by breaking your code into smaller pieces and fetching them only when required.

This guide covers lazy loading techniques in React and Next.js. You'll learn how to apply React.lazy, next/dynamic, and Suspense, with practical examples ready to adapt for your projects.

Table of Contents

What is Lazy Loading?

Lazy loading defers code execution until the moment it's actually needed. Rather than shipping your entire application in one bundle, you split it into discrete chunks. The browser fetches each chunk only when a user navigates to a specific route or triggers a particular feature.

Key advantages:

  • Faster initial load: A smaller entry bundle reduces time to interactive
  • Better Core Web Vitals: Enhances metrics like Largest Contentful Paint and Total Blocking Time
  • Reduced bandwidth consumption: Users download only the code they actually use

React implements this through dynamic imports paired with React.lazy(), while Next.js offers next/dynamic for additional server-side rendering capabilities.

Prerequisites

To follow this guide, you'll need:

  • Working knowledge of React fundamentals (components, hooks, state management)
  • Node.js version 18 or newer
  • A React project (Create React App or Vite) or Next.js application for the framework-specific examples

For React examples, any modern setup works. For Next.js sections, use the App Router (Next.js 13+).

How to Use React.lazy for Code Splitting

React.lazy() transforms a component into a dynamically imported module. React fetches that component only when it first renders.

The function accepts a callback that returns a dynamic import() statement. The target module must export the component as default.

Basic implementation:

import { lazy } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));
const AdminDashboard = lazy(() => import('./AdminDashboard'));

function App() {
  return (
    <div>
      <h1>My App</h1>
      <HeavyChart />
      <AdminDashboard />
    </div>
  );
}

For named exports, map them to a default export:

const ComponentWithNamedExport = lazy(() =>
  import('./MyComponent').then((module) => ({
    default: module.NamedComponent,
  }))
);

You can label chunks for easier identification in browser dev tools:

const HeavyChart = lazy(() =>
  import(/* webpackChunkName: "heavy-chart" */ './HeavyChart')
);

Using React.lazy() alone isn't sufficient. Wrap lazy components in Suspense to define what displays during the loading phase.

How to Use Suspense with React.lazy

Suspense displays fallback UI while its children load. It integrates with React.lazy() to manage loading states for dynamically imported components.

Wrap lazy components in Suspense and specify a fallback:

import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));
const AdminDashboard = lazy(() => import('./AdminDashboard'));

function App() {
  return (
    <div>
      <h1>My App</h1>
      <Suspense fallback={<div>Loading chart...</div>}>
        <HeavyChart />
      </Suspense>
      <Suspense fallback={<div>Loading dashboard...</div>}>
        <AdminDashboard />
      </Suspense>
    </div>
  );
}

A single Suspense boundary can wrap multiple lazy components:

<Suspense fallback={<div>Loading...</div>}>
  <HeavyChart />
  <AdminDashboard />
</Suspense>

Custom fallback components improve the user experience:

function LoadingSpinner() {
  return (
    <div className="loading-container">
      <div className="spinner" />
      <p>Loading...</p>
    </div>
  );
}

<Suspense fallback={<LoadingSpinner />}>
  <HeavyChart />
</Suspense>

How to Handle Errors with Error Boundaries

React.lazy() and Suspense don't catch loading failures like network errors or missing chunks. Error Boundaries handle these scenarios.

Error Boundaries are class components that implement componentDidCatch or static getDerivedStateFromError to intercept errors in their child tree and render fallback UI.

Simple Error Boundary implementation:

import { Component } from 'react';

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <div>Something went wrong.</div>;
    }
    return this.props.children;
  }
}

Combine Error Boundary with Suspense:

import { lazy, Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';

const HeavyChart = lazy(() => import('./HeavyChart'));

function App() {
  return (
    <ErrorBoundary fallback={<div>Failed to load chart. Please try again.</div>}>
      <Suspense fallback={<div>Loading chart...</div>}>
        <HeavyChart />
      </Suspense>
    </ErrorBoundary>
  );
}

When chunk loading fails, the Error Boundary catches it and displays your fallback instead of leaving users with a broken interface.

How to Use next/dynamic in Next.js

Next.js provides next/dynamic, which builds on React.lazy() and Suspense with additional options for server-side rendering.

Basic usage:

'use client';
import dynamic from 'next/dynamic';

const ComponentA = dynamic(() => import('../components/A'));
const ComponentB = dynamic(() => import('../components/B'));

export default function Page() {
  return (
    <div>
      <ComponentA />
      <ComponentB />
    </div>
  );
}

Custom Loading UI

The loading option specifies a placeholder during component load:

const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
  loading: () => <p>Loading chart...</p>,
});

Disable Server-Side Rendering

Components that depend on browser-only APIs (like window) need ssr: false:

const ClientOnlyMap = dynamic(() => import('../components/Map'), {
  ssr: false,
  loading: () => <p>Loading map...</p>,
});

Note: ssr: false only works in Client Components marked with 'use client'.

Load on Demand

Conditionally load components based on user interaction:

'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';

const Modal = dynamic(() => import('../components/Modal'), {
  loading: () => <p>Opening modal...</p>,
});

export default function Page() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      <button onClick={() => setShowModal(true)}>Open Modal</button>
      {showModal && <Modal onClose={() => setShowModal(false)} />}
    </div>
  );
}

Named Exports

Extract named exports from the import:

const Hello = dynamic(() =>
  import('../components/hello').then((mod) => mod.Hello)
);

Using Suspense with next/dynamic

In React 18+, enable suspense: true to delegate to a parent Suspense boundary:

const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
  suspense: true,
});

// In your component:
<Suspense fallback={<div>Loading...</div>}>
  <HeavyChart />
</Suspense>

Important: suspense: true is incompatible with ssr: false and the loading option. Use the Suspense fallback instead.

React.lazy vs next/dynamic: When to Use Each

Feature React.lazy + Suspense next/dynamic
Framework Any React app (Create React App, Vite, etc.) Next.js only
Server-Side Rendering Not supported Supported by default
Disable SSR N/A ssr: false option
Loading UI Suspense fallback prop Built-in loading option
Error handling Requires Error Boundary Requires Error Boundary
Named exports Manual .then() mapping Same .then() pattern
Suspense mode Always uses Suspense Optional via suspense: true

When to Use React.lazy

  • You're building a standard React application without Next.js
  • Your project uses Create React App, Vite, or custom Webpack configuration
  • You don't need Server-Side Rendering

  • You want a simple, framework-agnostic approach

When to Use next/dynamic

  • You're building a Next.js app

  • You need SSR for some components but want to disable it selectively for others

  • You want built-in loading placeholders without manually wiring up Suspense

  • You want Next.js-specific optimizations and sensible defaults out of the box

Real-World Examples

Example 1: Route-Based Code Splitting in React

Split your app by route so each page's code is fetched only when the user navigates to it:

// App.jsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import ErrorBoundary from './ErrorBoundary';

const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <ErrorBoundary fallback={<div>Failed to load page.</div>}>
        <Suspense fallback={<div>Loading page...</div>}>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/settings" element={<Settings />} />
          </Routes>
        </Suspense>
      </ErrorBoundary>
    </BrowserRouter>
  );
}

Example 2: Lazy Loading a Heavy Chart Library in Next.js

Defer loading a chart library until the user explicitly opens the analytics section — keeping the initial bundle lean:

// app/analytics/page.jsx
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('../components/Chart'), {
  ssr: false,
  loading: () => (
    <div className="chart-skeleton">
      <div className="skeleton-bar" />
      <div className="skeleton-bar" />
      <div className="skeleton-bar" />
    </div>
  ),
});

export default function AnalyticsPage() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <h1>Analytics</h1>
      <button onClick={() => setShowChart(true)}>Load Chart</button>
      {showChart && <Chart />}
    </div>
  );
}

Example 3: Lazy Loading a Modal

Load a modal component only when the user triggers it — there's no reason to ship that code upfront:

// React (with React.lazy)
import { lazy, Suspense, useState } from 'react';

const Modal = lazy(() => import('./Modal'));

function ProductPage() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      <button onClick={() => setShowModal(true)}>Add to Cart</button>
      {showModal && (
        <Suspense fallback={null}>
          <Modal onClose={() => setShowModal(false)} />
        </Suspense>
      )}
    </div>
  );
}
// Next.js (with next/dynamic)
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';

const Modal = dynamic(() => import('./Modal'), {
  loading: () => null,
});

export default function ProductPage() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      <button onClick={() => setShowModal(true)}>Add to Cart</button>
      {showModal && <Modal onClose={() => setShowModal(false)} />}
    </div>
  );
}

Example 4: Lazy Loading External Libraries

Import a third-party library on demand — in this case, loading fuse.js only when the user begins a search:

'use client';
import { useState } from 'react';

const names = ['Alice', 'Bob', 'Charlie', 'Diana'];

export default function SearchPage() {
  const [results, setResults] = useState([]);
  const [query, setQuery] = useState('');

  const handleSearch = async (value) => {
    setQuery(value);

    if (!value) {
      setResults([]);
      return;
    }

    // Load fuse.js only when the user actually searches
    const Fuse = (await import('fuse.js')).default;
    const fuse = new Fuse(names);
    setResults(fuse.search(value));
  };

  return (
    <div>
      <input
        type="text"
        placeholder="Search..."
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
      />
      <ul>
        {results.map((result) => (
          <li key={result.refIndex}>{result.item}</li>
        ))}
      </ul>
    </div>
  );
}

Conclusion

Lazy loading is one of the most practical tools in a frontend developer's performance toolkit. It shrinks your initial bundle by deferring code until it's actually needed. Here's a quick recap of the key primitives:

  • React.lazy() — The standard choice for plain React apps. Requires a default export and pairs with dynamic import().

  • Suspense — Wrap lazy components in Suspense and supply a fallback to handle the loading state gracefully.

  • Error Boundaries — Catch chunk load failures and surface a user-friendly error UI instead of a blank screen.

  • next/dynamic — The Next.js equivalent, with the added benefit of SSR control and built-in loading state support.

The decision is straightforward: use React.lazy for React-only projects and next/dynamic for Next.js. In both cases, pair your lazy components with Suspense and Error Boundaries for a robust, production-ready setup.

A good starting point: identify your heaviest components — charts, modals, admin panels — and lazy load them first. Measure your bundle size and Core Web Vitals before and after to quantify the impact.