Learning Guides
Menu

Lazy Loading & Code Splitting

11 min readFrontend Patterns & Concepts

Lazy Loading & Code Splitting

Modern web applications can grow to megabytes of JavaScript. Users shouldn't wait for code they don't need yet. Lazy loading and code splitting are essential techniques for delivering fast, responsive experiences.

Why Lazy Load?

The Cost of Large Bundles

PLAINTEXT
Bundle Size Impact:
┌─────────────────────────────────────────────────┐
│ Parse Time (3G mobile)                          │
│ 1MB JS ≈ 4-5 seconds just to parse              │
│ Plus: Download time + Compile time + Execute    │
├─────────────────────────────────────────────────┤
│ User Impact                                     │
│ 1s delay = 7% conversion drop                   │
│ 3s load time = 53% bounce rate                  │
└─────────────────────────────────────────────────┘

What to Lazy Load

JAVASCRIPT
// Prime candidates for lazy loading:
 
// 1. Routes not immediately visible
const AdminPanel = lazy(() => import("./AdminPanel"));
 
// 2. Below-the-fold content
const Comments = lazy(() => import("./Comments"));
 
// 3. Conditionally rendered components
const Modal = lazy(() => import("./Modal"));
 
// 4. Heavy libraries
const Chart = lazy(() => import("./Chart")); // Includes chart.js
 
// 5. Features behind feature flags
const BetaFeature = lazy(() =>
  featureFlags.beta ? import("./BetaFeature") : import("./Placeholder"),
);

Native Lazy Loading

Images and Iframes

HTML
<!-- Native browser lazy loading -->
<img src="hero.jpg" loading="eager" />
<!-- Default: load immediately -->
<img src="footer.jpg" loading="lazy" />
<!-- Defer until near viewport -->
 
<iframe src="video.html" loading="lazy"></iframe>
 
<!-- With fallback dimensions to prevent layout shift -->
<img
  src="photo.jpg"
  loading="lazy"
  width="800"
  height="600"
  alt="Description"
/>

Intersection Observer Fallback

JAVASCRIPT
class LazyLoader {
  constructor() {
    // Check for native support
    this.supportsNative = "loading" in HTMLImageElement.prototype;
 
    if (!this.supportsNative) {
      this.setupObserver();
    }
  }
 
  setupObserver() {
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            this.loadImage(entry.target);
            this.observer.unobserve(entry.target);
          }
        });
      },
      { rootMargin: "100px" },
    );
 
    // Observe images without native support
    document.querySelectorAll("img[data-src]").forEach((img) => {
      this.observer.observe(img);
    });
  }
 
  loadImage(img) {
    img.src = img.dataset.src;
    if (img.dataset.srcset) {
      img.srcset = img.dataset.srcset;
    }
    img.removeAttribute("data-src");
  }
}

JavaScript Code Splitting

Dynamic Imports

JAVASCRIPT
// Static import (bundled together)
import { heavyFunction } from "./heavyModule";
 
// Dynamic import (separate chunk)
const loadHeavyModule = async () => {
  const { heavyFunction } = await import("./heavyModule");
  return heavyFunction();
};
 
// Conditional loading
button.addEventListener("click", async () => {
  // Only loads when user clicks
  const { processData } = await import("./dataProcessor");
  processData(data);
});

Webpack Magic Comments

JAVASCRIPT
// Named chunks for debugging
const Editor = () =>
  import(
    /* webpackChunkName: "editor" */
    "./Editor"
  );
 
// Prefetch (low priority, load during idle)
const Settings = () =>
  import(
    /* webpackChunkName: "settings" */
    /* webpackPrefetch: true */
    "./Settings"
  );
 
// Preload (high priority, load immediately)
const Dashboard = () =>
  import(
    /* webpackChunkName: "dashboard" */
    /* webpackPreload: true */
    "./Dashboard"
  );
 
// Disable prefetch/preload
const AdminOnly = () =>
  import(
    /* webpackPrefetch: false */
    "./AdminPanel"
  );

Route-Based Splitting

JAVASCRIPT
// React Router example
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
 
// Each route is a separate chunk
const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Product = lazy(() => import("./pages/Product"));
const Cart = lazy(() => import("./pages/Cart"));
const Checkout = lazy(() => import("./pages/Checkout"));
const Account = lazy(() => import("./pages/Account"));
 
function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/products" element={<Products />} />
        <Route path="/products/:id" element={<Product />} />
        <Route path="/cart" element={<Cart />} />
        <Route path="/checkout" element={<Checkout />} />
        <Route path="/account/*" element={<Account />} />
      </Routes>
    </Suspense>
  );
}

React.lazy and Suspense

Basic Usage

JAVASCRIPT
import { lazy, Suspense } from "react";
 
// Must use default export
const HeavyComponent = lazy(() => import("./HeavyComponent"));
 
function App() {
  return (
    <div>
      <Suspense fallback={<Loading />}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

Named Export Workaround

JAVASCRIPT
// If module uses named exports
// heavyModule.js
export const HeavyComponent = () => {
  /* ... */
};
 
// Lazy load with re-export
const HeavyComponent = lazy(() =>
  import("./heavyModule").then((module) => ({
    default: module.HeavyComponent,
  })),
);

Error Handling

JAVASCRIPT
import { lazy, Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
 
const DynamicComponent = lazy(() => import("./DynamicComponent"));
 
function App() {
  return (
    <ErrorBoundary
      fallback={<Error message="Failed to load component" />}
      onError={(error) => {
        console.error("Chunk load failed:", error);
        // Report to error tracking
      }}
    >
      <Suspense fallback={<Loading />}>
        <DynamicComponent />
      </Suspense>
    </ErrorBoundary>
  );
}
 
// Custom error boundary for retry
class RetryErrorBoundary extends React.Component {
  state = { hasError: false, error: null };
 
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
 
  retry = () => {
    this.setState({ hasError: false, error: null });
  };
 
  render() {
    if (this.state.hasError) {
      return (
        <div>
          <p>Failed to load. Please try again.</p>
          <button onClick={this.retry}>Retry</button>
        </div>
      );
    }
 
    return this.props.children;
  }
}

Nested Suspense Boundaries

JAVASCRIPT
function Dashboard() {
  return (
    <div className="dashboard">
      <Header /> {/* Not lazy - critical */}
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar /> {/* Lazy loaded */}
      </Suspense>
      <main>
        <Suspense fallback={<ChartSkeleton />}>
          <Charts /> {/* Heavy charts library */}
        </Suspense>
 
        <Suspense fallback={<TableSkeleton />}>
          <DataTable /> {/* Lazy with own loading */}
        </Suspense>
      </main>
    </div>
  );
}

Loading Strategies

Preloading Critical Chunks

JAVASCRIPT
// Preload on hover (likely to click)
function NavigationLink({ to, children }) {
  const preload = () => {
    const component = routeComponents[to];
    if (component?.preload) {
      component.preload();
    }
  };
 
  return (
    <Link to={to} onMouseEnter={preload} onFocus={preload}>
      {children}
    </Link>
  );
}
 
// Create preloadable lazy components
function lazyWithPreload(factory) {
  const Component = lazy(factory);
  Component.preload = factory;
  return Component;
}
 
const Settings = lazyWithPreload(() => import("./Settings"));

Progressive Loading

JAVASCRIPT
// Load critical content first, enhance progressively
function Article({ id }) {
  const [article, setArticle] = useState(null);
 
  useEffect(() => {
    // 1. Load article content immediately
    fetchArticle(id).then(setArticle);
 
    // 2. Preload comments (likely to scroll down)
    const commentsPromise = import("./Comments");
 
    // 3. Preload share tools (might use)
    const timer = setTimeout(() => {
      import("./ShareTools");
    }, 2000);
 
    return () => clearTimeout(timer);
  }, [id]);
 
  if (!article) return <ArticleSkeleton />;
 
  return (
    <article>
      <h1>{article.title}</h1>
      <div>{article.content}</div>
 
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments articleId={id} />
      </Suspense>
    </article>
  );
}

Component-Level Code Splitting

JAVASCRIPT
// Split by component complexity
const SimpleModal = ({ children, ...props }) => (
  <div className="modal">{children}</div>
);
 
const RichModal = lazy(() => import("./RichModal"));
 
function Modal({ variant = "simple", ...props }) {
  if (variant === "simple") {
    return <SimpleModal {...props} />;
  }
 
  return (
    <Suspense fallback={<SimpleModal {...props} />}>
      <RichModal {...props} />
    </Suspense>
  );
}

Library Splitting

Heavy Dependencies

JAVASCRIPT
// Chart libraries are huge - load on demand
const ChartComponent = lazy(async () => {
  // Load chart library first
  await import("chart.js/auto");
  // Then the component that uses it
  return import("./Chart");
});
 
// Date library splitting
async function formatDate(date, format) {
  // Only load date-fns when needed
  const { format: formatFn } = await import("date-fns");
  return formatFn(date, format);
}
 
// Markdown with syntax highlighting
const MarkdownRenderer = lazy(async () => {
  // Parallel load
  const [{ marked }, { highlight }] = await Promise.all([
    import("marked"),
    import("highlight.js"),
  ]);
 
  // Configure
  marked.setOptions({
    highlight: (code, lang) =>
      highlight.highlight(code, { language: lang }).value,
  });
 
  return import("./MarkdownRenderer");
});

Tree Shaking with Dynamic Imports

JAVASCRIPT
// ❌ Imports entire lodash
import _ from "lodash";
_.debounce(fn, 100);
 
// ✅ Tree-shakeable
import debounce from "lodash/debounce";
debounce(fn, 100);
 
// ✅ Dynamic import of specific function
async function handleResize() {
  const { debounce } = await import("lodash-es/debounce");
  return debounce(updateLayout, 100);
}

Image Optimization

Responsive Images

HTML
<!-- Multiple sizes for different viewports -->
<img
  srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
  sizes="
    (max-width: 400px) 400px,
    (max-width: 800px) 800px,
    1200px
  "
  src="hero-800.jpg"
  alt="Hero image"
  loading="lazy"
/>
 
<!-- Art direction with picture -->
<picture>
  <source
    media="(max-width: 600px)"
    srcset="hero-mobile.webp"
    type="image/webp"
  />
  <source media="(max-width: 600px)" srcset="hero-mobile.jpg" />
  <source srcset="hero-desktop.webp" type="image/webp" />
  <img src="hero-desktop.jpg" alt="Hero" loading="lazy" />
</picture>

Blur-Up Technique

JAVASCRIPT
function ProgressiveImage({ src, placeholder, alt }) {
  const [isLoaded, setIsLoaded] = useState(false);
  const [currentSrc, setCurrentSrc] = useState(placeholder);
 
  useEffect(() => {
    const img = new Image();
    img.src = src;
    img.onload = () => {
      setCurrentSrc(src);
      setIsLoaded(true);
    };
  }, [src]);
 
  return (
    <img
      src={currentSrc}
      alt={alt}
      className={`progressive-image ${isLoaded ? "loaded" : "loading"}`}
      style={{
        filter: isLoaded ? "none" : "blur(20px)",
        transition: "filter 0.3s ease-out",
      }}
    />
  );
}
 
// Usage with tiny placeholder
<ProgressiveImage
  placeholder="data:image/jpeg;base64,/9j/4AAQSkZJ..." // 20px blurred
  src="full-image.jpg"
  alt="Photo"
/>;

Script Loading Strategies

Script Attributes

HTML
<!-- Normal: blocks parsing -->
<script src="app.js"></script>
 
<!-- Async: downloads parallel, executes immediately when ready -->
<!-- Order not guaranteed -->
<script async src="analytics.js"></script>
<script async src="ads.js"></script>
 
<!-- Defer: downloads parallel, executes after parsing -->
<!-- Order preserved -->
<script defer src="app.js"></script>
<script defer src="secondary.js"></script>
 
<!-- Module: deferred by default -->
<script type="module" src="app.mjs"></script>

Dynamic Script Loading

JAVASCRIPT
function loadScript(src, options = {}) {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = src;
    script.async = options.async ?? true;
 
    if (options.module) {
      script.type = "module";
    }
 
    script.onload = resolve;
    script.onerror = reject;
 
    document.head.appendChild(script);
  });
}
 
// Load third-party on interaction
async function initChat() {
  await loadScript("https://chat-widget.com/widget.js");
  ChatWidget.init({ apiKey: "xxx" });
}
 
chatButton.addEventListener("click", initChat, { once: true });

Resource Hints

HTML
<head>
  <!-- DNS prefetch for external domains -->
  <link rel="dns-prefetch" href="https://api.example.com" />
 
  <!-- Preconnect: DNS + TCP + TLS -->
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://cdn.example.com" crossorigin />
 
  <!-- Prefetch: low-priority future resource -->
  <link rel="prefetch" href="/next-page.js" />
 
  <!-- Preload: high-priority current page resource -->
  <link rel="preload" href="/critical.css" as="style" />
  <link rel="preload" href="/hero.webp" as="image" />
  <link rel="preload" href="/fonts/custom.woff2" as="font" crossorigin />
 
  <!-- Modulepreload for ES modules -->
  <link rel="modulepreload" href="/app.mjs" />
</head>

Performance Metrics

Measuring Load Performance

JAVASCRIPT
// Measure chunk load time
function lazyWithMetrics(factory, chunkName) {
  return lazy(async () => {
    const start = performance.now();
 
    try {
      const module = await factory();
 
      const duration = performance.now() - start;
      console.log(`Chunk "${chunkName}" loaded in ${duration}ms`);
 
      // Report to analytics
      performance.measure(`chunk-${chunkName}`, {
        start,
        duration,
      });
 
      return module;
    } catch (error) {
      console.error(`Failed to load chunk "${chunkName}"`, error);
      throw error;
    }
  });
}
 
const Dashboard = lazyWithMetrics(() => import("./Dashboard"), "dashboard");

Bundle Analysis

JAVASCRIPT
// package.json
{
  "scripts": {
    "analyze": "webpack-bundle-analyzer stats.json",
    "build:stats": "webpack --json > stats.json"
  }
}
 
// Next.js
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});
 
module.exports = withBundleAnalyzer({
  // config
});
 
// Run: ANALYZE=true npm run build

Common Patterns

Conditional Feature Loading

JAVASCRIPT
// Load features based on user/environment
async function initApp() {
  const features = await fetch("/api/features").then((r) => r.json());
 
  if (features.analytics) {
    const { initAnalytics } = await import("./analytics");
    initAnalytics();
  }
 
  if (features.chat) {
    const { initChat } = await import("./chat");
    initChat();
  }
 
  if (features.aiAssistant) {
    const { AIAssistant } = await import("./aiAssistant");
    new AIAssistant();
  }
}
 
// Load admin tools only for admins
function AdminTools() {
  const { user } = useAuth();
 
  if (!user?.isAdmin) return null;
 
  return (
    <Suspense fallback={null}>
      <LazyAdminTools />
    </Suspense>
  );
}

Parallel Loading

JAVASCRIPT
// Load multiple chunks in parallel
async function loadDashboard() {
  const [{ Charts }, { DataGrid }, { Widgets }] = await Promise.all([
    import("./Charts"),
    import("./DataGrid"),
    import("./Widgets"),
  ]);
 
  return { Charts, DataGrid, Widgets };
}
 
// React component with parallel lazy
function Dashboard() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <ParallelLoader
        components={{
          charts: () => import("./Charts"),
          grid: () => import("./DataGrid"),
          widgets: () => import("./Widgets"),
        }}
        render={({ Charts, DataGrid, Widgets }) => (
          <div className="dashboard">
            <Charts />
            <DataGrid />
            <Widgets />
          </div>
        )}
      />
    </Suspense>
  );
}

Summary

TechniqueWhen to UseImpact
Route splittingMulti-page appsMajor - reduces initial bundle
Component splittingHeavy/conditional componentsMedium - defers non-critical
Library splittingLarge dependenciesMajor - reduces bundle size
Native lazy loadingImages, iframesEasy win - browser handles it
PrefetchingPredictable navigationImproves perceived performance
PreloadingCritical resourcesFaster initial render

Key Principles:

  1. Split at natural boundaries (routes, features)
  2. Measure before and after - don't guess
  3. Provide good loading states (skeletons, not spinners)
  4. Handle errors gracefully with retry options
  5. Prefetch what users are likely to need next
  6. Keep critical path lean