A

Code Splitting & Loading Patterns

performancecode-splittinglazy-loadingprefetchpreloaddynamic-import

Code Splitting & Loading Patterns

Modern JavaScript applications can easily balloon to megabytes of code—most of which users don't need immediately. Code splitting breaks your bundle into smaller chunks loaded on demand. Combined with lazy loading and resource hints, you can dramatically reduce initial load time while keeping the full application available.

Code Splitting Fundamentals

Code splitting works by identifying natural boundaries in your code—routes, features, or heavy dependencies—and creating separate bundles for each. The browser downloads only what's needed for the current page.

Route-Based Splitting

The most impactful split: each route gets its own bundle. Most frameworks handle this automatically.

Astro (automatic per-page splitting):

---
// src/pages/dashboard.astro
// This page's JavaScript is only loaded on /dashboard
---
<Layout>
  <DashboardComponent client:load />
</Layout>

Next.js (automatic with the pages or app router):

// app/dashboard/page.tsx
// Automatically code-split by route
export default function DashboardPage() {
  return <Dashboard />;
}

React Router with lazy loading:

// src/App.tsx
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 Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Component-Based Splitting

Split at the component level for features that aren't always needed:

// Only load the chart library when the dashboard is visible
const Analytics = lazy(() => import('./components/Analytics'));

function Dashboard({ showAnalytics }) {
  return (
    <div>
      <MainContent />
      {showAnalytics && (
        <Suspense fallback={<Skeleton height="400px" />}>
          <Analytics />
        </Suspense>
      )}
    </div>
  );
}

Manual Chunk Configuration

For fine-grained control, configure your bundler to create specific chunks:

Vite/Rollup:

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Vendor libraries in their own cacheable chunk
          'vendor-react': ['react', 'react-dom'],
          'vendor-charts': ['chart.js', 'd3'],
          // Large features separated
          'feature-editor': [
            './src/components/Editor.tsx',
            './src/lib/editor-utils.ts'
          ],
        },
      },
    },
  },
});

Webpack:

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
        charts: {
          test: /[\\/]node_modules[\\/](chart\.js|d3)[\\/]/,
          name: 'charts',
          chunks: 'all',
          priority: 10,
        },
      },
    },
  },
};

Dynamic Imports

The import() expression is the foundation of code splitting. It returns a promise that resolves to the module:

// Load module on demand
async function showRichEditor() {
  const { RichEditor } = await import('./components/RichEditor');
  renderEditor(RichEditor);
}

// Conditional loading based on feature flags
async function loadFeature(name) {
  switch (name) {
    case 'analytics':
      return import('./features/analytics');
    case 'admin':
      return import('./features/admin');
    default:
      throw new Error(`Unknown feature: ${name}`);
  }
}

Named Exports with Dynamic Imports

// The module
// src/utils/formatters.ts
export function formatDate(d) { /* ... */ }
export function formatCurrency(n) { /* ... */ }

// Dynamic import with destructuring
const { formatDate } = await import('./utils/formatters');

// Or use the namespace
const formatters = await import('./utils/formatters');
formatters.formatDate(new Date());

Magic Comments (Webpack)

Webpack supports magic comments to control chunk behavior:

// Named chunk (appears in build output)
const Admin = lazy(() => import(
  /* webpackChunkName: "admin" */
  './pages/Admin'
));

// Prefetch: load during idle time (low priority)
const Settings = lazy(() => import(
  /* webpackChunkName: "settings" */
  /* webpackPrefetch: true */
  './pages/Settings'
));

// Preload: load immediately (high priority)
const Dashboard = lazy(() => import(
  /* webpackChunkName: "dashboard" */
  /* webpackPreload: true */
  './pages/Dashboard'
));

Vite doesn't use magic comments but achieves similar results with explicit preloading (covered below).

Lazy Loading

Lazy loading defers resource loading until the resource is actually needed. This applies to images, components, and third-party scripts.

Native Image Lazy Loading

The simplest optimization—just add an attribute:

<!-- Images below the fold load when approaching viewport -->
<img src="/photo.jpg" alt="Photo" loading="lazy" width="800" height="600">

<!-- Hero images should NOT be lazy loaded -->
<img src="/hero.jpg" alt="Hero" fetchpriority="high" width="1200" height="600">

Browser support is excellent (95%+). Images with loading="lazy" start loading when they're within the viewport threshold (typically 1250-2500px, depending on connection speed).

Gotcha: Never lazy load above-the-fold images. This delays your LCP element.

Intersection Observer for Components

Load heavy components only when they scroll into view:

// hooks/useLazyLoad.ts
import { useEffect, useRef, useState } from 'react';

export function useLazyLoad<T extends HTMLElement>(
  rootMargin = '200px' // Start loading 200px before visible
) {
  const ref = useRef<T>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin }
    );

    observer.observe(element);
    return () => observer.disconnect();
  }, [rootMargin]);

  return { ref, isVisible };
}

Usage:

function CommentSection() {
  const { ref, isVisible } = useLazyLoad<HTMLDivElement>();

  return (
    <div ref={ref}>
      {isVisible ? (
        <Comments />
      ) : (
        <Skeleton height="300px" />
      )}
    </div>
  );
}

Lazy Loading Iframes

Iframes (YouTube embeds, maps) are heavy. Use the native loading attribute or a facade pattern:

<!-- Native lazy loading for iframes -->
<iframe
  src="https://www.youtube.com/embed/VIDEO_ID"
  loading="lazy"
  width="560"
  height="315"
  title="Video"
></iframe>

Facade Pattern: Show a static image until the user interacts:

// components/YouTubeFacade.tsx
import { useState } from 'react';

interface Props {
  videoId: string;
  title: string;
}

export function YouTubeFacade({ videoId, title }: Props) {
  const [clicked, setClicked] = useState(false);

  if (clicked) {
    return (
      <iframe
        src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
        width="560"
        height="315"
        title={title}
        allow="autoplay; encrypted-media"
        allowFullScreen
      />
    );
  }

  return (
    <button
      onClick={() => setClicked(true)}
      style={{
        backgroundImage: `url(https://i.ytimg.com/vi/${videoId}/hqdefault.jpg)`,
        width: 560,
        height: 315,
        border: 'none',
        cursor: 'pointer',
        position: 'relative',
      }}
      aria-label={`Play: ${title}`}
    >
      <PlayIcon />
    </button>
  );
}

This can save 500KB+ per embed until the user actually wants to play.

Resource Hints

Resource hints tell the browser about resources you'll need, allowing it to prepare in advance.

preload: Critical Resources Now

Use preload for resources needed for the current page but not discovered early in the HTML:

<head>
  <!-- Fonts discovered late (in CSS) -->
  <link
    rel="preload"
    href="/fonts/inter-var.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  >

  <!-- Hero image (LCP candidate) -->
  <link
    rel="preload"
    href="/images/hero.webp"
    as="image"
    fetchpriority="high"
  >

  <!-- Critical CSS if loaded externally -->
  <link rel="preload" href="/critical.css" as="style">
</head>

Key attributes:

  • as: Resource type (script, style, image, font, fetch, etc.)
  • type: MIME type (important for fonts)
  • crossorigin: Required for fonts and when CORS is needed

Gotcha: Don't over-preload. Each preload competes for bandwidth. Preload only truly critical resources (2-3 max).

prefetch: Resources for Next Navigation

Use prefetch for resources likely needed on future pages:

<!-- When user hovers nav link to /dashboard -->
<link rel="prefetch" href="/dashboard.js">
<link rel="prefetch" href="/api/dashboard-data" as="fetch">

Prefetch is low priority and happens during idle time. It won't slow down the current page.

Route prefetching in frameworks:

// Next.js - Automatic prefetching on Link visibility
import Link from 'next/link';

<Link href="/dashboard">Dashboard</Link>

// Disable if needed
<Link href="/dashboard" prefetch={false}>Dashboard</Link>
// React Router - Manual prefetching
import { useNavigate } from 'react-router-dom';

function NavLink({ to, children }) {
  const navigate = useNavigate();

  const handleMouseEnter = () => {
    // Prefetch route on hover
    import(`./pages/${to.slice(1)}.tsx`);
  };

  return (
    <a
      href={to}
      onMouseEnter={handleMouseEnter}
      onClick={(e) => {
        e.preventDefault();
        navigate(to);
      }}
    >
      {children}
    </a>
  );
}

preconnect: Early Connection Setup

Use preconnect to establish connections to external origins before they're needed:

<head>
  <!-- Set up connection to CDN -->
  <link rel="preconnect" href="https://cdn.example.com">

  <!-- Google Fonts needs two connections -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

  <!-- API server -->
  <link rel="preconnect" href="https://api.example.com">
</head>

Each connection requires DNS lookup, TCP handshake, and TLS negotiation—200-500ms on slow connections. Preconnect eliminates this latency.

Limit to 2-4 origins: Too many preconnects waste resources on connections you might not use.

dns-prefetch: DNS-Only Warmup

A lighter alternative to preconnect—only resolves DNS:

<link rel="dns-prefetch" href="https://analytics.example.com">

Use for origins you'll connect to, but not immediately. Browser support is wider than preconnect.

modulepreload: ES Module Preloading

For ES modules, use modulepreload instead of preload:

<link rel="modulepreload" href="/app.js">
<link rel="modulepreload" href="/utils.js">

This tells the browser to fetch, parse, and compile the module ahead of time.

Priority Hints

The fetchpriority attribute lets you influence resource loading priority:

<!-- High priority: LCP image -->
<img src="/hero.webp" fetchpriority="high" alt="Hero">

<!-- Low priority: below-fold images -->
<img src="/footer-decoration.webp" fetchpriority="low" loading="lazy" alt="">

<!-- High priority: critical script -->
<script src="/app.js" fetchpriority="high"></script>

<!-- Low priority: analytics -->
<script src="/analytics.js" fetchpriority="low" defer></script>

Values: high, low, auto (default)

Use fetchpriority="high" for:

  • LCP images
  • Critical above-the-fold content
  • Fonts if not preloaded

Use fetchpriority="low" for:

  • Below-fold images
  • Non-critical third-party scripts
  • Decorative content

Script Loading Strategies

async vs defer

<!-- Blocking: Halts HTML parsing until downloaded and executed -->
<script src="/app.js"></script>

<!-- Async: Downloads in parallel, executes ASAP (interrupts parsing) -->
<script src="/analytics.js" async></script>

<!-- Defer: Downloads in parallel, executes after HTML parsing, in order -->
<script src="/app.js" defer></script>

When to use each:

Strategy Use Case
No attribute Almost never (legacy requirement)
async Independent scripts (analytics, ads)
defer Application code (needs DOM, execution order matters)

defer is the default recommendation for application scripts:

<head>
  <!-- These download in parallel, execute in order after parsing -->
  <script src="/vendor.js" defer></script>
  <script src="/app.js" defer></script>
</head>

Module Scripts

ES modules are deferred by default:

<!-- Automatically deferred -->
<script type="module" src="/app.js"></script>

<!-- Async still works if you need it -->
<script type="module" src="/analytics.js" async></script>

Third-Party Script Patterns

Third-party scripts (analytics, chat, ads) often hurt performance. Control when they load:

// Load analytics after page is interactive
if (document.readyState === 'complete') {
  loadAnalytics();
} else {
  window.addEventListener('load', loadAnalytics);
}

function loadAnalytics() {
  const script = document.createElement('script');
  script.src = 'https://analytics.example.com/script.js';
  script.async = true;
  document.body.appendChild(script);
}

Or with requestIdleCallback for truly low-priority scripts:

function loadWhenIdle(src) {
  const load = () => {
    const script = document.createElement('script');
    script.src = src;
    document.body.appendChild(script);
  };

  if ('requestIdleCallback' in window) {
    requestIdleCallback(load, { timeout: 5000 });
  } else {
    setTimeout(load, 2000);
  }
}

loadWhenIdle('https://chat.example.com/widget.js');

Practical Patterns

Preloading on User Intent

Load resources when users signal intent:

function ProductList({ products }) {
  const prefetchProduct = (id: string) => {
    // Prefetch product page bundle
    import(`./pages/Product`);
    // Prefetch product data
    fetch(`/api/products/${id}`);
  };

  return (
    <ul>
      {products.map(product => (
        <li
          key={product.id}
          onMouseEnter={() => prefetchProduct(product.id)}
          onTouchStart={() => prefetchProduct(product.id)}
        >
          <Link href={`/products/${product.id}`}>
            {product.name}
          </Link>
        </li>
      ))}
    </ul>
  );
}

Progressive Component Loading

Load a lightweight version first, then upgrade:

function DataVisualization({ data }) {
  const [Chart, setChart] = useState(null);

  useEffect(() => {
    // Load heavy chart library in background
    import('./components/FullChart').then(mod => {
      setChart(() => mod.default);
    });
  }, []);

  // Show simple table first
  if (!Chart) {
    return <SimpleTable data={data} />;
  }

  // Upgrade to full chart when loaded
  return <Chart data={data} />;
}

Critical CSS Inlining

Inline CSS required for above-the-fold content, load the rest asynchronously:

<head>
  <!-- Critical CSS inline -->
  <style>
    /* Only styles needed for first render */
    header { display: flex; ... }
    .hero { height: 100vh; ... }
  </style>

  <!-- Full stylesheet loaded asynchronously -->
  <link
    rel="preload"
    href="/styles.css"
    as="style"
    onload="this.rel='stylesheet'"
  >
  <noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>

Tools like critical can extract critical CSS automatically:

npx critical https://example.com --base dist --inline --minify

Bundle Splitting Strategies

Entry Points by Route (Recommended)

Create separate entry points for major routes:

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        main: 'src/main.tsx',
        admin: 'src/admin.tsx',
      },
    },
  },
});

Vendor Splitting

Keep frequently-changing app code separate from stable dependencies:

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // Further split large deps
            if (id.includes('react')) return 'vendor-react';
            if (id.includes('lodash')) return 'vendor-lodash';
            return 'vendor';
          }
        },
      },
    },
  },
});

Granular Chunks

For large apps, split by feature area:

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'feature-auth': ['./src/features/auth/index.ts'],
          'feature-dashboard': ['./src/features/dashboard/index.ts'],
          'feature-settings': ['./src/features/settings/index.ts'],
        },
      },
    },
  },
});

Common Pitfalls

Over-Splitting

Too many chunks increase HTTP overhead:

// ❌ 50 tiny chunks = 50 HTTP requests
manualChunks(id) {
  if (id.includes('node_modules')) {
    return id.split('node_modules/')[1].split('/')[0];
  }
}

// ✅ Reasonable groupings
manualChunks: {
  vendor: ['react', 'react-dom', 'react-router'],
  ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
}

With HTTP/2, many small files are fine, but there's still parsing overhead. Aim for 5-15 chunks for most apps.

Loading Waterfalls

Sequential async loading creates waterfalls:

// ❌ Waterfall: each awaits the previous
const Dashboard = lazy(() => import('./Dashboard'));
// Dashboard imports Charts, which imports D3...
// 3 round trips: Dashboard.js → Charts.js → D3.js

// ✅ Parallel: load dependencies together
const Dashboard = lazy(() =>
  Promise.all([
    import('./Dashboard'),
    import('d3'),
    import('chart.js'),
  ]).then(([mod]) => mod)
);

Blocking the Main Thread During Load

Large JavaScript bundles block parsing:

// 500KB bundle blocks main thread during parse
<script src="/massive-bundle.js" defer></script>

// Solution: Split into smaller chunks that parse in idle time

Use the Coverage tab in DevTools to find unused code that could be split out.

See Also

  • Core Web Vitals - How loading strategies affect LCP and INP
  • Asset Optimization - Image and font loading optimization
  • Vite - Modern bundler with excellent code splitting defaults
  • Webpack - Advanced splitting configuration
  • React - lazy() and Suspense patterns
  • Next.js - Automatic route-based splitting

Last updated: March 23, 2026