Frontend Bundle Optimization: Code Splitting, Tree Shaking & Lazy Loading
Performance16 min read

Frontend Bundle Optimization: Code Splitting, Tree Shaking & Lazy Loading

A deep, practical guide to shrinking your JavaScript bundle. Learn how to measure, tree shake, code split, lazy load, replace heavy dependencies, and enforce budgets in CI with real Next.js and React examples.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

Jul 24, 2026
#Performance#Bundle Size#Code Splitting#Tree Shaking#Next.js#React#Web Vitals

Frontend bundle optimization is the practice of shipping the smallest amount of JavaScript needed to render what the user is actually looking at, and deferring everything else. It matters because every kilobyte of JavaScript must be downloaded, parsed, compiled, and executed on the single main thread before a page becomes interactive - 300 KB of JavaScript can block the main thread for hundreds of milliseconds, while a 300 KB image decodes off-thread. The core techniques are tree shaking (dead-code elimination for ES modules), code splitting with dynamic import() so each route or heavy widget loads on demand, lazy loading below-the-fold assets, and replacing heavy dependencies such as Moment.js with lighter alternatives like dayjs at roughly 2 KB gzipped. This guide walks through how to measure a bundle with analyzer tools, apply each technique in React and Next.js, and enforce size budgets in CI.

Why Does Bundle Size Matter?

Bundle size directly drives two of the metrics users and search engines care about most: Largest Contentful Paint (LCP) and Interaction to Next Paint (INP), plus Time to Interactive (TTI). JavaScript is uniquely expensive because, unlike an image of the same byte size, it must be parsed and executed on the single main thread. A 300 KB image decodes off-thread and paints; 300 KB of JavaScript can block the main thread for hundreds of milliseconds while the CPU compiles and runs it.

The chain of cost from a bloated bundle:

  • Longer download time, especially on slow 3G/4G and constrained data plans
  • More main-thread parse and compile time, which delays hydration in SSR frameworks
  • Higher INP because event handlers queue behind long tasks
  • Delayed LCP when render-blocking scripts sit in the critical path
  • Worse SEO, since Core Web Vitals are a Google ranking signal
  • Higher bounce rates: conversion drops measurably with every extra second of load time

The fastest code is the code you never ship. Before optimizing how a dependency loads, ask whether it needs to be in the bundle at all.

How Do You Measure Bundle Size?

Optimizing without measuring is guesswork. Start by generating a visual map of what is inside your bundle so you can target the biggest offenders. Three tools cover almost every setup: source-map-explorer for any bundler that emits source maps, @next/bundle-analyzer for Next.js, and rollup-plugin-visualizer for Vite and Rollup projects.

source-map-explorer

bash
# Works with any build that emits source maps
npm install --save-dev source-map-explorer

# Build with source maps enabled, then analyze
npm run build
npx source-map-explorer 'dist/assets/*.js'

# Or output an interactive HTML treemap
npx source-map-explorer 'dist/assets/*.js' --html report.html

@next/bundle-analyzer

js
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
};

module.exports = withBundleAnalyzer(nextConfig);

// package.json script:
//   "analyze": "ANALYZE=true next build"
// Running it opens client.html and nodejs.html treemaps in the browser.

rollup-plugin-visualizer (Vite / Rollup)

typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      filename: 'stats.html',
      template: 'treemap', // 'sunburst' | 'network' also useful
      gzipSize: true,
      brotliSize: true,
    }),
  ],
});

When you read a treemap, focus on the gzip or brotli size rather than the raw size, because that is what actually travels over the wire. Look for three red flags: a single dependency taking a disproportionate slice, the same library bundled twice under different versions, and code that clearly belongs to a route the user has not visited yet.

How Does Tree Shaking Actually Work?

Tree shaking is dead-code elimination for ES modules. Because ESM imports and exports are statically analyzable, a bundler can determine which exports are actually used and drop the rest. Two conditions must hold for it to work: the code must be authored as ES modules (not CommonJS), and the bundler must know the module has no side effects it needs to preserve.

typescript
// GOOD: named import from an ESM package lets the bundler drop unused code
import { debounce } from 'lodash-es';

// BAD: default import of the whole CommonJS build pulls in everything
import _ from 'lodash';
_.debounce(fn, 200);

// Also fine: cherry-pick the single function as its own package
import debounce from 'lodash.debounce';

The sideEffects field in package.json is how a library tells bundlers which files are safe to prune. Setting it to false is a promise that importing any module from the package has no side effects (like registering a global or injecting CSS) beyond its exports. If some files do have side effects, list them explicitly instead of disabling tree shaking wholesale.

js
// package.json of a library you author
{
  "name": "@taha/ui",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  // Everything is pure EXCEPT these CSS imports, which must be kept
  "sideEffects": [
    "*.css",
    "./dist/polyfills.js"
  ]
}

Practical tree-shaking checklist:

  • Prefer packages that publish an ESM build (a "module" or "exports" field)
  • Use named imports; avoid namespace imports like import * as X when you only need one export
  • Mark your own packages sideEffects: false when true, or list the exceptions
  • Avoid re-export barrels that eagerly pull in unrelated modules
  • Watch for CommonJS-only dependencies; they often defeat tree shaking entirely

How Does Code Splitting with Dynamic Imports Work?

Code splitting breaks one large bundle into smaller chunks that load on demand. The primitive is the dynamic import() expression, which returns a promise and signals to the bundler that everything reachable from that module should go into a separate chunk. React wraps this in React.lazy and Suspense so a component can be loaded lazily while a fallback shows.

tsx
import { lazy, Suspense, useState } from 'react';

// This chunk is only fetched when <Chart /> first renders
const Chart = lazy(() => import('./Chart'));

export function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  return (
    <section>
      <button onClick={() => setShowChart(true)}>Show analytics</button>

      {showChart && (
        <Suspense fallback={<div className="skeleton h-64" />}>
          <Chart />
        </Suspense>
      )}
    </section>
  );
}

A common mistake is to lazy load a component but still import a heavy dependency eagerly at the top of the parent file. If the dependency is only used inside the lazy component, it must be imported inside that module so it lands in the split chunk, not the parent bundle.

Route-Based vs Component-Based Splitting

Route-based splitting is the highest-leverage first step: each route becomes its own chunk, so visiting the home page never downloads the settings page code. Component-based splitting is finer grained and targets heavy widgets within a route, things like a rich text editor, a data grid, a map, or a charting library that only some users open.

tsx
// Route-based splitting with React Router
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router-dom';

const Home = lazy(() => import('./routes/Home'));
const Settings = lazy(() => import('./routes/Settings'));
const Reports = lazy(() => import('./routes/Reports'));

const withSuspense = (Component: React.ComponentType) => (
  <Suspense fallback={<PageSkeleton />}>
    <Component />
  </Suspense>
);

export const router = createBrowserRouter([
  { path: '/', element: withSuspense(Home) },
  { path: '/settings', element: withSuspense(Settings) },
  { path: '/reports', element: withSuspense(Reports) },
]);

A refinement is to prefetch the next likely chunk before the user needs it, for example when they hover a link or when the browser is idle. This keeps the interaction snappy without paying the download cost upfront.

tsx
// Prefetch a lazy route on hover so navigation feels instant
const importSettings = () => import('./routes/Settings');
const Settings = lazy(importSettings);

function NavLink() {
  return (
    <a href="/settings" onMouseEnter={() => importSettings()}>
      Settings
    </a>
  );
}

How Should You Defer Third-Party Scripts?

Analytics, chat widgets, A/B testing SDKs, and tag managers are frequently the largest chunk of main-thread time even though they contribute nothing to first render. Load them with defer or async, and in Next.js use the Script component with an explicit strategy so they never block hydration.

tsx
import Script from 'next/script';

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}

      {/* Runs after the page is interactive; ideal for analytics */}
      <Script
        src="https://cdn.example.com/analytics.js"
        strategy="afterInteractive"
      />

      {/* Offloaded to a web worker via Partytown; zero main-thread cost */}
      <Script src="https://cdn.example.com/heavy-tag.js" strategy="worker" />

      {/* Loads during idle time; good for chat widgets */}
      <Script src="https://cdn.example.com/chat.js" strategy="lazyOnload" />
    </>
  );
}

Which Heavy Dependencies Should You Replace?

Often the fastest win is not splitting a dependency but replacing it with something smaller or removing it entirely. Date and utility libraries are the classic offenders. Moment.js ships large locale files and is not tree-shakeable; migrating to date-fns (modular, tree-shakeable) or dayjs (roughly 2 KB gzipped) can save tens of kilobytes.

typescript
// BEFORE: moment pulls in the whole library plus locales (~70 KB gzip)
import moment from 'moment';
const label = moment(date).format('MMM D, YYYY');

// AFTER: dayjs is ~2 KB gzip, same API surface for common cases
import dayjs from 'dayjs';
const label = dayjs(date).format('MMM D, YYYY');

// Or, for zero dependencies, use the native Intl API
const label = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: 'numeric',
  year: 'numeric',
}).format(date);

High-impact dependency swaps to consider:

  • moment -> dayjs, date-fns, or the native Intl.DateTimeFormat API
  • lodash -> lodash-es with named imports, or individual lodash.* packages, or native ES methods
  • axios -> the native fetch API for simple requests
  • uuid -> crypto.randomUUID() available in modern browsers and Node
  • Full icon libraries -> per-icon imports (e.g. lucide-react) so only used icons ship
  • Heavy animation libraries -> CSS transitions or the Web Animations API when the effect is simple

How Do You Optimize Images and Fonts?

Images and fonts are not JavaScript, but they dominate total page weight and directly affect LCP and CLS. Serve modern formats (WebP or AVIF), size images responsively, and lazy load anything below the fold. For fonts, self-host, subset to the characters you need, preload the critical face, and use font-display: swap to avoid invisible text.

tsx
import Image from 'next/image';
import localFont from 'next/font/local';
import { Inter } from 'next/font/google';

// Google font, automatically self-hosted and subset at build time
const inter = Inter({ subsets: ['latin'], display: 'swap' });

// Self-hosted variable font with preload for the critical face
const brand = localFont({
  src: './fonts/Brand-Variable.woff2',
  display: 'swap',
  preload: true,
});

export function Hero() {
  return (
    <div className={inter.className}>
      {/* next/image handles AVIF/WebP, responsive srcset, and lazy loading */}
      <Image
        src="/hero.jpg"
        alt="Product hero"
        width={1200}
        height={600}
        priority // opt the LCP image out of lazy loading
      />
    </div>
  );
}

Caching and Long-Term Hashing

Splitting your bundle also improves caching. When each chunk has a content hash in its filename, unchanged chunks stay cached across deploys while only the changed chunk is re-downloaded. Serve hashed assets with an immutable, long max-age cache header, and keep vendor code in a separate chunk so a change to your app code does not invalidate the framework bundle.

js
// vite.config.js: split stable vendor code into its own hashed chunk
export default {
  build: {
    rollupOptions: {
      output: {
        // Content-hashed filenames enable immutable caching
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/[name].[hash].js',
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('react')) return 'react-vendor';
            return 'vendor';
          }
        },
      },
    },
  },
};

// Serve hashed files with an immutable cache header:
//   Cache-Control: public, max-age=31536000, immutable

Next.js App Router Specifics

The Next.js App Router does a lot of this automatically. Server Components keep their code on the server and never ship to the client, which is the ultimate form of bundle reduction. Each route segment is code-split by default, and shared code is hoisted into common chunks. Your job is to keep the "use client" boundary as small as possible and lazy load heavy client-only widgets with next/dynamic.

tsx
import dynamic from 'next/dynamic';

// Client-only heavy widget, excluded from SSR and its own chunk
const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), {
  ssr: false,
  loading: () => <div className="skeleton h-96" />,
});

// A Server Component: this file's imports never reach the browser
export default async function ArticlePage({ id }: { id: string }) {
  const article = await getArticle(id); // runs on the server

  return (
    <article>
      <h1>{article.title}</h1>
      <p>{article.body}</p>
      {/* Only the editor ships client JS, loaded on demand */}
      <RichTextEditor initialValue={article.draft} />
    </article>
  );
}

Next.js bundle levers worth knowing:

  • Keep components as Server Components unless they need state, effects, or browser APIs
  • Use next/dynamic with ssr: false for client-only libraries (editors, maps, charts)
  • Use next/font to self-host and subset fonts with zero layout shift
  • Use next/image for automatic AVIF/WebP, responsive sizing, and lazy loading
  • Set experimental.optimizePackageImports for large libraries to auto-transform barrel imports
  • Inspect the per-route "First Load JS" column in the build output to spot regressions

How Do You Enforce Bundle Budgets in CI?

Optimization is not a one-time task. Without a guardrail, bundle size creeps back up one convenient import at a time. Define a performance budget and enforce it in CI so a pull request that blows past the limit fails the build. Tools like size-limit make this a few lines of configuration.

js
// .size-limit.js
module.exports = [
  {
    name: 'Main bundle (gzip)',
    path: '.next/static/chunks/main-*.js',
    limit: '90 KB',
  },
  {
    name: 'App entry (gzip)',
    path: '.next/static/chunks/app/**/*.js',
    limit: '160 KB',
  },
];
bash
# package.json
#   "scripts": { "size": "size-limit" }

# In CI (e.g. GitHub Actions) after the build step:
npm run build
npm run size   # exits non-zero if any budget is exceeded

# Lighthouse CI is a good complement for asserting on real Web Vitals:
npx @lhci/cli autorun \
  --assert.assertions.total-byte-weight=error

A budget you do not enforce is a wish. Wire the check into CI and let the pipeline, not code review, hold the line.

Key Takeaways

What to remember when optimizing a frontend bundle:

  • JavaScript is the most expensive byte on the page because it blocks the main thread; measure gzip/brotli size, not raw size
  • Always analyze first with source-map-explorer, @next/bundle-analyzer, or rollup-plugin-visualizer before changing anything
  • Tree shaking needs ESM plus honest sideEffects metadata and named imports to actually remove dead code
  • Route-based code splitting is the biggest early win; add component-based splitting for heavy widgets
  • Defer third-party scripts with async/defer or next/script strategies so they never block hydration
  • Replacing heavy dependencies (moment, lodash, axios) with lighter or native alternatives often beats splitting them
  • Optimize images and fonts with modern formats, next/image, and next/font; caching benefits from content-hashed chunks
  • Lock in the gains with a size budget enforced in CI so the bundle cannot silently regress

Bundle optimization compounds: a smaller main bundle hydrates faster, a deferred script frees the main thread for interactions, and an enforced budget keeps every future pull request honest. Treat performance as a feature with an owner and a budget, and your users, on every device and network, will feel the difference.

Share this article

Related Articles