Skip to main content
fast frontend

JavaScript and TypeScript Bundle Optimization

7 min read Chapter 7 of 33

JavaScript and TypeScript Bundle Optimization

The Weight Your Users Download

JavaScript is the most expensive resource a browser processes per byte. An image of 200KB downloads and decodes largely off the main thread. A JavaScript file of 200KB downloads, parses, compiles, and executes on the main thread, blocking rendering and interaction until each step completes.

On the e-commerce platform, the initial JavaScript audit showed:

  • Total JavaScript transferred: 420KB gzipped (1.4MB uncompressed)
  • Main bundle: 185KB gzipped
  • Vendor chunk: 142KB gzipped
  • Route-specific chunks: 93KB gzipped total across all routes
  • Parse time on Moto G Power (4x throttle): 1,200ms
  • Evaluation time: 680ms

The 1,200ms parse time is time the main thread is occupied parsing JavaScript syntax. No rendering happens during this window. The 680ms evaluation time is the execution of module-level code, global state initialization, and top-level function definitions. Together, 1,880ms of main thread time is consumed before the first user-facing JavaScript runs.

Every kilobyte removed from the JavaScript bundle reduces parse and evaluation time roughly linearly on CPU-constrained devices. The relationship is not perfectly linear because V8’s streaming parser overlaps parsing with download, and code complexity affects parse cost per byte. But as a working approximation: 10KB gzipped JavaScript adds ~30ms of main thread time on a mid-tier mobile device.

The CI bundle size gate from Chapter 2 prevents this number from growing. This chapter reduces it.

Bundle Composition Breakdown

The chart shows where the 420KB of JavaScript comes from. The vendor chunk and the main bundle together account for 78% of the total. The vendor chunk is the first optimization target because it contains third-party code that can be deduplicated, tree-shaken, or replaced with lighter alternatives. The main bundle is the second target because it contains application code shipped to all routes, including code that most routes never execute.

Code Splitting by Route

The e-commerce platform ships a single-page application where every route’s JavaScript is bundled into the main entry point. A user visiting the homepage downloads the checkout validation logic. A user on the product page downloads the inventory dashboard charting library.

Route-based code splitting ensures users download only the JavaScript their current route requires.

// SLOW: Static imports load everything upfront
import { HomePage } from "./pages/HomePage";
import { ProductListing } from "./pages/ProductListing";
import { ProductDetail } from "./pages/ProductDetail";
import { Checkout } from "./pages/Checkout";
import { Dashboard } from "./pages/Dashboard";

const routes = [
  { path: "/", component: HomePage },
  { path: "/category/:slug", component: ProductListing },
  { path: "/product/:id", component: ProductDetail },
  { path: "/checkout", component: Checkout },
  { path: "/dashboard", component: Dashboard },
];
// FAST: Dynamic imports create separate chunks per route
import { lazy, Suspense } from 'react';
import type { ComponentType } from 'react';

const HomePage = lazy(() => import('./pages/HomePage'));
const ProductListing = lazy(() => import('./pages/ProductListing'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const Checkout = lazy(() => import('./pages/Checkout'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

interface RouteConfig {
  path: string;
  component: React.LazyExoticComponent<ComponentType>;
}

const routes: RouteConfig[] = [
  { path: '/', component: HomePage },
  { path: '/category/:slug', component: ProductListing },
  { path: '/product/:id', component: ProductDetail },
  { path: '/checkout', component: Checkout },
  { path: '/dashboard', component: Dashboard },
];

function App(): JSX.Element {
  return (
    <Suspense fallback={<div className="loading-skeleton" />}>
      <Router routes={routes} />
    </Suspense>
  );
}

With Vite, each import() expression creates a separate chunk. The Vite build output:

dist/assets/
  index-a1b2c3.js         78 kB  (shared runtime + shell)
  HomePage-d4e5f6.js      22 kB
  ProductListing-g7h8i9.js 34 kB
  ProductDetail-j0k1l2.js  28 kB
  Checkout-m3n4o5.js       48 kB
  Dashboard-p6q7r8.js     180 kB
  vendor-s9t0u1.js        112 kB  (shared dependencies)

The homepage user now downloads 78KB (index) + 22KB (HomePage) + 112KB (vendor) = 212KB instead of 420KB. A 49% reduction. The dashboard user still downloads more, but only the dashboard user.

Tree Shaking: What Gets Eliminated and What Does Not

Tree shaking removes unused exports from the bundle. Vite and Webpack both perform tree shaking during production builds, but the effectiveness depends on how code is authored and imported.

Tree shaking works on ES module import/export statements because they are statically analyzable. The bundler can determine at build time which exports are used and which are dead code.

Tree shaking fails on:

  1. Side effects in module scope: If a module executes code at the top level (registers event listeners, mutates global state, writes to window), the bundler cannot remove it regardless of whether its exports are used.

  2. CommonJS require(): Dynamic require calls are not statically analyzable. The bundler includes the entire module.

  3. Barrel files that re-export everything: An index.ts that does export * from './module-a' forces the bundler to include the entire transitive dependency graph of module-a unless every module in the chain is side-effect-free.

The barrel file problem is pervasive in TypeScript codebases:

// SLOW: Barrel file imports pull in the entire module graph
// utils/index.ts
export * from "./date-utils"; // 8KB - date-fns wrapper
export * from "./currency-utils"; // 3KB - Intl.NumberFormat helpers
export * from "./validation"; // 12KB - Zod schemas
export * from "./analytics"; // 15KB - analytics SDK wrapper

// In a component:
import { formatPrice } from "@/utils";
// Intended: import only formatPrice (200 bytes)
// Actual: the bundler includes all of utils/index.ts transitive deps
// because analytics.ts has a top-level side effect
// FAST: Direct imports bypass the barrel file
import { formatPrice } from "@/utils/currency-utils";
// Only currency-utils.ts and its dependencies are included

Configure sideEffects in package.json to tell the bundler which files are safe to tree-shake:

{
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.ts",
    "./src/analytics/init.ts"
  ]
}

Every file not listed in sideEffects is considered side-effect-free. The bundler can safely remove unused exports from these files. Files with legitimate side effects (CSS imports, polyfills, analytics initialization) must be listed explicitly.

The Vite configuration for aggressive tree shaking:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  build: {
    target: "es2022",
    minify: "terser",
    terserOptions: {
      compress: {
        passes: 2,
        pure_getters: true,
        unsafe_comps: true,
      },
    },
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ["react", "react-dom", "react-router-dom"],
          charts: ["recharts"],
        },
      },
      treeshake: {
        moduleSideEffects: "no-external",
        propertyReadSideEffects: false,
      },
    },
  },
});

The manualChunks configuration separates vendor dependencies into predictable chunks. react, react-dom, and react-router-dom are loaded on every page, so they belong in a shared vendor chunk that benefits from caching across routes. recharts is only used on the dashboard, so it gets its own chunk that only dashboard users download.

After tree shaking configuration and barrel file elimination:

BundleBeforeAfterReduction
Main entry185KB78KB-58%
Vendor142KB112KB-21%
Route chunks93KB82KB-12%
Total420KB272KB-35%

The main entry saw the largest reduction because barrel file imports were pulling unused modules into the entry chunk. Vendor reduction came from tree shaking unused exports from lodash-es (switching from lodash to lodash-es was a prerequisite, since lodash uses CommonJS and cannot be tree-shaken).

The CI bundle size gate from Chapter 2 locks in these gains. Any subsequent PR that inadvertently reintroduces a barrel file import or adds a side-effectful dependency will trigger a size regression warning.