Skip to main content
Saved

Lazy Loading

Category Performance

Level Advanced

Examples

React, Vue, Svelte, Native web

Defer loading of non-critical resources until they're needed.

In this article
  1. Problem
  2. Solution
  3. Example
  4. Component Lazy Loading
  5. Route-Based Code Splitting
  6. Conditional Feature Loading
  7. Image Lazy Loading
  8. Preloading on Hover
  9. Lazy Third-Party Scripts
  10. Benefits
  11. Tradeoffs
  12. Summary
  13. See also

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, that parsing and compiling takes noticeably longer than on a 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

The fix is to wait until users need a resource before loading it.

For JavaScript, dynamic import() splits your bundle into chunks that load independently. Route components load when users navigate to them, and heavy features like charting libraries wait until users open the parts of your app that use them.

For images, loading="lazy" lets the browser handle the timing, and Intersection Observer gives finer control.

For third-party scripts, async or defer stops them blocking page rendering. Many of them can wait even longer and load programmatically after user interaction, since that 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, the 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 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:

<!-- 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

Starting the download when users hover cuts the wait they see 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 stops them blocking HTML parsing 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

  • The initial bundle can shrink a lot, because 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, since on a slower device the difference between 50KB and 500KB can decide 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.
  • Loading less up front can improve FCP and LCP, which users notice and search rankings take into account.
  • 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 code-split point needs fallback UI to show 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 can feel sluggish, which is why lazy loading usually goes together with hover-triggered prefetching.
  • Wrong split boundaries can duplicate shared dependencies across chunks, undoing the benefits, so it’s worth checking a bundle analyzer regularly.
  • Modals and drawers users expect to appear instantly don’t mix well with lazy loading: users want an immediate response, not a spinner.

Summary

Lazy loading defers resources until needed, improving initial performance by skipping code and images users may never see. It suits route components, below-fold images, and heavy features, and preloading on hover cuts the wait when users do need the deferred resources.

See also

Newsletter

A Monthly Email
from Den Odell

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

No spam. Unsubscribe anytime.