Saved
Frontend Pattern

Lazy Loading

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

Difficulty Advanced

By Den Odell

Lazy Loading

Problem

Initial page load downloads megabytes of JavaScript that users may never use, including entire admin panels, analytics dashboards, settings pages, and rarely-accessed features that sit dormant in the bundle. Critical first-page interactions like clicking the sign-up button or scrolling the hero section are delayed by seconds because the browser is busy parsing and compiling code for features the user isn’t viewing, while time-to-interactive suffers dramatically, especially on mobile devices with limited CPU and network bandwidth where parsing JavaScript is 2-3x slower than desktop.

Large images below the fold consume bandwidth and memory even though users may never scroll to them. Heavy third-party widgets like chat support, analytics scripts, or social media embeds block the main thread during initial load, while users on slow connections or metered data plans pay for downloading resources they never access. Search engine crawlers see poor performance metrics from bloated initial payloads, hurting SEO rankings.

Solution

Defer loading of non-critical resources until they’re needed, loading components, routes, and images only when users interact with or scroll to them.

Use dynamic import() to split code into separate chunks that load on demand. Load images as they enter the viewport using loading="lazy" attribute or Intersection Observer API. Defer third-party scripts until after initial page load using async or defer attributes.

Load route components only when users navigate to those routes, not upfront. Conditionally load features behind feature flags or user permissions so free-tier users don’t download premium features.

This reduces initial payload and speeds up first render by prioritizing what users see and interact with immediately. Combine with preloading hints to fetch likely-needed resources during idle time so they’re ready when users need them.

Example

This example demonstrates lazy loading an admin panel component so its code is only downloaded when it’s actually rendered, not during the initial page load.

React with Lazy Loading

import { lazy, Suspense } from 'react';

// Lazy load the AdminPanel - code is only fetched when needed
const AdminPanel = lazy(() => import('./AdminPanel'));
const Settings = lazy(() => import('./Settings'));
const Analytics = lazy(() => import('./Analytics'));

function App() {
  return (
    <div>
      {/* Main content loads immediately */}
      <Header />
      <MainContent />
      
      {/* Suspense shows a fallback while AdminPanel is loading */}
      <Suspense fallback={<Loading />}>
        {/* AdminPanel code downloads when this component renders */}
        <AdminPanel />
      </Suspense>
    </div>
  );
}

Route-Based Code Splitting

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Each route loads its component only when navigated to
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
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="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/admin" element={<Admin />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Conditional Feature Loading

function Dashboard({ user }) {
  const [AnalyticsPanel, setAnalyticsPanel] = useState(null);

  useEffect(() => {
    // Only load analytics for premium users
    if (user.isPremium) {
      import('./AnalyticsPanel').then(module => {
        setAnalyticsPanel(() => module.default);
      });
    }
  }, [user.isPremium]);

  return (
    <div>
      <h1>Dashboard</h1>
      {user.isPremium && AnalyticsPanel && <AnalyticsPanel />}
    </div>
  );
}

Image Lazy Loading

<!-- Native lazy loading (modern browsers) -->
<img
  src="hero.jpg"
  alt="Hero image"
  loading="lazy"
  width="800"
  height="600"
/>

<!-- Multiple images below the fold -->
<img src="image1.jpg" loading="lazy" alt="Image 1" />
<img src="image2.jpg" loading="lazy" alt="Image 2" />
<img src="image3.jpg" loading="lazy" alt="Image 3" />

Intersection Observer for Images

const imageObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      // Load the actual image
      img.src = img.dataset.src;
      img.classList.remove('lazy');
      // Stop observing this image
      observer.unobserve(img);
    }
  });
});

// Observe all lazy images
document.querySelectorAll('img.lazy').forEach(img => {
  imageObserver.observe(img);
});
<!-- Placeholder with data-src for actual image -->
<img
  class="lazy"
  src="placeholder.jpg"
  data-src="actual-image.jpg"
  alt="Lazy loaded image"
/>

Vue Async Components

<template>
  <Suspense>
    <!-- Component code downloads when this renders -->
    <AsyncAdminPanel />
    <template #fallback>
      <Loading />
    </template>
  </Suspense>
</template>

<script>
import { defineAsyncComponent } from 'vue';

export default {
  components: {
    // Lazy load the AdminPanel - code is only fetched when needed
    AsyncAdminPanel: defineAsyncComponent(() =>
      import('./AdminPanel.vue')
    ),
    
    // With loading component and error handling
    AsyncSettings: defineAsyncComponent({
      loader: () => import('./Settings.vue'),
      loadingComponent: LoadingSpinner,
      errorComponent: ErrorDisplay,
      delay: 200, // Show loading after 200ms
      timeout: 10000 // Error after 10s
    })
  }
};
</script>

Svelte Dynamic Import

<script>
  let AdminPanel;
  let loading = false;
  let error = null;

  // Lazy load component when button is clicked
  async function loadAdminPanel() {
    loading = true;
    try {
      const module = await import('./AdminPanel.svelte');
      AdminPanel = module.default;
    } catch (e) {
      error = e.message;
    } finally {
      loading = false;
    }
  }
</script>

<button on:click={loadAdminPanel}>Load Admin Panel</button>

{#if loading}
  <Loading />
{:else if error}
  <Error message={error} />
{:else if AdminPanel}
  <svelte:component this={AdminPanel} />
{/if}

Web Component Lazy Loading

class LazyComponent extends HTMLElement {
  async connectedCallback() {
    // Show loading state
    this.innerHTML = '<div class="loading">Loading...</div>';

    try {
      // Lazy load component module
      const { AdminPanel } = await import('./admin-panel.js');

      // Create and append the component
      const panel = new AdminPanel();
      this.innerHTML = '';
      this.appendChild(panel);
    } catch (error) {
      this.innerHTML = `<div class="error">Failed to load: ${error.message}</div>`;
    }
  }
}

customElements.define('lazy-component', LazyComponent);

Preloading Strategy

function ProductCard({ product }) {
  const prefetchDetails = () => {
    // Preload product details when user hovers
    import('./ProductDetails');
  };

  return (
    <div onMouseEnter={prefetchDetails}>
      <h3>{product.name}</h3>
      <Link to={`/product/${product.id}`}>View Details</Link>
    </div>
  );
}

Lazy Load Third-Party Scripts

<!-- Defer non-critical third-party scripts -->
<script src="analytics.js" defer></script>
<script src="chat-widget.js" async></script>

<!-- Load on user interaction -->
<script>
  let analyticsLoaded = false;
  
  function loadAnalytics() {
    if (analyticsLoaded) return;
    
    const script = document.createElement('script');
    script.src = 'https://analytics.example.com/script.js';
    script.async = true;
    document.head.appendChild(script);
    
    analyticsLoaded = true;
  }
  
  // Load analytics after user interaction
  document.addEventListener('click', loadAnalytics, { once: true });
</script>

Lazy Loading with Error Boundaries

function SafeLazyLoad({ component: Component }) {
  return (
    <ErrorBoundary fallback={<ComponentError />}>
      <Suspense fallback={<ComponentLoader />}>
        <Component />
      </Suspense>
    </ErrorBoundary>
  );
}

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

function Dashboard() {
  return (
    <div>
      <SafeLazyLoad component={HeavyChart} />
    </div>
  );
}

Progressive Image Loading

function ProgressiveImage({ src, placeholder }) {
  const [currentSrc, setCurrentSrc] = useState(placeholder);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Load high-res image
    const img = new Image();
    img.src = src;
    img.onload = () => {
      setCurrentSrc(src);
      setLoading(false);
    };
  }, [src]);

  return (
    <img
      src={currentSrc}
      alt=""
      style={{ filter: loading ? 'blur(10px)' : 'none' }}
    />
  );
}

Responsive Image Lazy Loading

<!-- Lazy load with srcset for different screen sizes -->
<img
  srcset="
    small.jpg 400w,
    medium.jpg 800w,
    large.jpg 1200w
  "
  sizes="(max-width: 400px) 400px, (max-width: 800px) 800px, 1200px"
  src="medium.jpg"
  alt="Responsive image"
  loading="lazy"
/>

Benefits

  • Dramatically reduces initial bundle size by loading code only when needed, improving Core Web Vitals metrics like First Contentful Paint and Largest Contentful Paint.
  • Speeds up time-to-interactive by prioritizing critical rendering path - users can interact with the page faster because the browser isn’t busy parsing unused code.
  • Improves perceived performance by making key features available faster - users see content immediately rather than waiting for a single massive bundle.
  • Saves bandwidth for users who never access certain features or routes, particularly important for mobile users on metered connections or slow networks.
  • Reduces memory consumption by not loading unused components, benefiting low-end devices with limited RAM.
  • Enables splitting by user role or permissions so free-tier users don’t download premium features they can’t access.
  • Improves cache efficiency since updates to rarely-used features don’t invalidate the entire bundle, allowing core features to stay cached.

Tradeoffs

  • Introduces loading states and spinners when lazy resources are first accessed, creating brief delays that can feel sluggish if not handled well with skeleton screens or optimistic UI.
  • Adds complexity with Suspense boundaries and error handling for failed loads - network errors during chunk loading require retry logic and error boundaries.
  • Can hurt user experience if not combined with preloading or prefetching - clicking a link that triggers a lazy load feels slower than instant navigation with everything pre-loaded.
  • Makes it harder to test and debug since code is split across multiple chunks with separate source maps, complicating stack traces and error reporting.
  • Requires careful splitting strategy to avoid creating too many small chunks (increasing HTTP overhead) or too few large chunks (defeating the purpose of lazy loading).
  • Lazy loaded images can cause layout shift if dimensions aren’t specified, hurting Cumulative Layout Shift scores and causing content to jump as images load.
  • Dynamic imports return promises that must be handled correctly - forgetting await or proper error handling creates hard-to-debug race conditions.
  • Browser back/forward navigation can feel slow if previously visited lazy-loaded routes need to re-fetch chunks that weren’t cached properly.
  • Code splitting at the wrong boundaries can cause the same dependencies to be duplicated across multiple chunks, increasing total bundle size despite splitting.
  • Testing lazy loaded components requires mocking dynamic imports which is awkward in most test frameworks and can lead to false confidence if mocks don’t match production behavior.
  • Analytics and error tracking become complex when components load asynchronously - determining which code version caused an error requires tracking chunk versions.
  • Native loading="lazy" has limited browser support (though widely available in modern browsers), requiring Intersection Observer polyfills for older browsers.
  • Intersection Observer-based lazy loading requires maintaining observer instances and cleanup logic to avoid memory leaks when components unmount.
  • Aggressive lazy loading can backfire on fast connections where network latency for multiple small chunks exceeds the time to download one larger bundle.
  • Route-based code splitting works well for page transitions but less well for modals or drawers where users expect instant interaction without loading delays.
Stay Updated

Get New Patterns
in Your Inbox

Join thousands of developers receiving regular insights on frontend architecture patterns

No spam. Unsubscribe anytime.