Skip to main content
Saved

Pattern Performance Advanced

Lazy Loading

With lazy loading, images and code aren’t downloaded until they’re needed, which cuts the initial load time and saves bandwidth.

Examples
React, Vue, Svelte and native web
Read
4 min

The problem

Your landing page might take 4 seconds to become interactive because the browser is downloading JavaScript for the admin dashboard, analytics, and settings, along with 47 images below the fold, even though users just want to see your hero section and click “Sign Up.”

The pattern

Your app can wait until users actually need a resource, such as the code for another route or an image further down the page, before it loads it.

Problem

I’ve profiled pages where 80% of the JavaScript bundle was code the average user never touches. The browser has to download, parse, and compile all that code before users can click anything. On mobile devices with slower CPUs, that parsing and compiling takes noticeably longer than on a desktop. Images have the same problem, because product shots below the fold use bandwidth and memory even though users might never scroll down far enough to see them.

Solution

For JavaScript, dynamic import() lets your bundler split your code into chunks that load independently. Route components load when users navigate to them, and heavy features such as charting libraries only load when users open the parts of your app that use them.

For images, loading="lazy" lets the browser choose when to load each image, and Intersection Observer gives you finer control.

For third-party scripts, async or defer stops them blocking page rendering. Many of them can load even later, from your own code after the user interacts with the page, since a chat widget doesn’t need to be ready before users can read your content.

Lazy loading works well alongside preloading. When a user hovers over a navigation link, your app can 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>
  );
}

Route-Based Code Splitting

Route boundaries are usually the best place to split, since each page then only loads 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

If you load features only when certain conditions are met, you can keep premium or admin code out of your 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

The browser’s built-in lazy loading works for most cases, and Intersection Observer gives you finer control:

<!-- Above the fold: load immediately, and at high priority if it's the LCP image -->
<img src="hero.jpg" alt="Hero" fetchpriority="high" width="800" height="600" />

<!-- Below the fold: let the browser wait until the user scrolls near them -->
<img src="image1.jpg" loading="lazy" alt="Image 1" width="400" height="300" />
<img src="image2.jpg" loading="lazy" alt="Image 2" width="400" height="300" />

Preloading on Hover

If the download starts when users hover, the route is ready sooner 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

Non-critical scripts with defer or async don’t block HTML parsing while the page loads:

<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

  • The initial bundle can shrink a lot, because users download only what they need for the first page.
  • Pages become interactive sooner because there’s less code to parse. Mobile users benefit most, because on a slower device the difference between 50KB and 500KB of JavaScript can be enough to make users give up and leave.
  • Users never download features they don’t use, so free users don’t get the premium code, and users who never visit the admin pages never download the admin bundle.
  • Images below the fold only load when users scroll near them, which saves bandwidth and memory.
  • Loading less up front can improve First Contentful Paint (FCP) and Largest Contentful Paint (LCP), which users notice and search rankings take into account.
  • Caching works better, because when you update a rarely used feature, only that chunk changes, and users keep the cached code for the rest of the app.

Tradeoffs

  • The first time users navigate to a lazy-loaded route, they might see a loading spinner, which can feel slower than a traditional SPA that has already downloaded everything.
  • You’ll need fallback UI at every code-split point to show while the chunk downloads.
  • Error handling gets more complex, because a chunk can fail to download, so you need retry logic and error boundaries throughout your app.
  • Debugging across multiple chunks is harder, because stack traces can span separate source maps.
  • Lazy-loaded images can cause layout shift unless you set a width and height to reserve their space.
  • Without preloading, the app can feel slow to respond when users click, which is why lazy loading is often paired with prefetching on hover.
  • If you split in the wrong places, shared dependencies can end up duplicated across chunks, which undoes the benefit, so you should check a bundle analyzer regularly.
  • Lazy loading doesn’t suit modals and drawers, because users expect them to appear straight away, and a spinner there feels slow.

Summary

Lazy loading leaves out code and images until users need them, so the first page loads faster, because the browser skips anything users might never see. It works well for route components and images below the fold, and if you preload on hover, the code is ready sooner when users do click.

See also

Next in Performance Memoization Caching the results of slow calculations lets your code skip recalculating them when the inputs haven't changed.

Newsletter

A monthly email
from Den Odell.

Behind-the-scenes thinking on frontend patterns, site updates, and more.

No spam. Unsubscribe anytime.