Skip to main content
surviving the spike

Bundle Size, Code Splitting, and Time to Interactive

8 min read Chapter 32 of 66

Bundle Size, Code Splitting, and Time to Interactive

The Symptom

The rider app’s Lighthouse score is 38 on mobile. The performance panel shows a single monolithic JavaScript bundle at 2.3MB. On a simulated mid-range device with 3G throttling, Time to Interactive (TTI) is 11.4 seconds. The booking screen renders. The map loads. The “Request Ride” button appears. The rider taps it. Nothing happens for 3.2 seconds because the main thread is still parsing and compiling JavaScript for the trip history module, the driver analytics module, and a charting library that only the admin dashboard uses.

The Cause

Run webpack-bundle-analyzer against the rider app’s production build:

npx webpack-bundle-analyzer dist/stats.json

The output reveals the damage:

Bundle breakdown before optimization showing a 2.3MB monolithic bundle dominated by node_modules at 74%, with unnecessary dependencies like moment.js, chart.js, and the full analytics SDK highlighted in red

The treemap reveals the core problem: 751KB of unnecessary dependencies ship to every rider on every page load. Chart.js (210KB) is only used in the admin dashboard. Moment.js (290KB) bundles 120 locale files nobody asked for. The analytics SDK (180KB) is the full package when only two event calls are needed. Meanwhile, TripHistory and RideTracking are bundled eagerly despite being lazy-loadable routes the rider may never visit.

1.7MB of the 2.3MB bundle is node_modules. 580KB is mapbox-gl, which is needed on the booking screen. The remaining 1.12MB of dependencies includes code that the rider never needs on first load.

The cost of JavaScript is not download time alone. On a Samsung Galaxy A14 (Exynos 850, representative of riders in Southeast Asia and Latin America):

Operation           Desktop (M2 Mac)    Galaxy A14
Download (3G)       0.8s                0.8s
Parse               120ms               890ms
Compile             80ms                640ms
Execute             40ms                310ms
Total               1.04s               2.64s

Parse and compile run on the main thread. While the browser parses 2.3MB of JavaScript, the UI is frozen. The button renders but does not respond. The rider sees the app. The rider taps. The app ignores the tap. The rider taps harder.

The Baseline

Lighthouse CI captures the current state:

{
  "performance": 38,
  "first-contentful-paint": 3200,
  "largest-contentful-paint": 4800,
  "time-to-interactive": 11400,
  "total-blocking-time": 3800,
  "total-byte-weight": 2412000
}

Total Blocking Time (TBT) of 3,800ms means the main thread was blocked for 3.8 seconds during page load. That is 3.8 seconds of taps, scrolls, and interactions that the browser queued and ignored.

The Fix

Step 1: Kill the tree-shaking failures

lodash pulls in the entire library because of a bare import:

// BOTTLENECK: Imports entire lodash (71KB) for one function
import { debounce } from "lodash";
// SCALED: Cherry-pick import (4KB)
import debounce from "lodash/debounce";

Or replace it entirely:

// SCALED: Native implementation, 0KB added
function debounce<T extends (...args: any[]) => void>(
  fn: T,
  delay: number,
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout>;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

moment.js includes 120 locale files by default. The rider app uses English and Spanish:

// BOTTLENECK: moment with all locales (290KB)
import moment from "moment";
// SCALED: date-fns with tree-shakeable imports (8KB for these two functions)
import { format, formatDistanceToNow } from "date-fns";
import { es } from "date-fns/locale";

// Format trip date
const tripDate = format(new Date(trip.startedAt), "MMM d, yyyy");

// "5 minutes ago" for ETA
const eta = formatDistanceToNow(new Date(trip.estimatedArrival), {
  locale: es,
  addSuffix: true,
});

chart.js is imported in a shared utility file that the rider app bundles but never calls:

// BOTTLENECK: Shared utility imports chart.js for admin-only feature
// src/shared/analytics.ts
import { Chart } from 'chart.js';  // 210KB, rider app never uses this

export function trackEvent(name: string, data: Record<string, unknown>) {
  // ... event tracking logic that doesn't use Chart
}

export function renderAnalyticsChart(canvas: HTMLCanvasElement) {
  // Admin-only function
  new Chart(canvas, { /* ... */ });
}
// SCALED: Split the file. Rider app imports only what it needs.
// src/shared/analytics.ts (no chart.js dependency)
export function trackEvent(name: string, data: Record<string, unknown>) {
  // ... event tracking logic
}

// src/admin/charts.ts (admin bundle only)
import { Chart } from "chart.js";
export function renderAnalyticsChart(canvas: HTMLCanvasElement) {
  new Chart(canvas, {
    /* ... */
  });
}

After tree-shaking fixes, the bundle drops from 2.3MB to 1.6MB. Still too large.

Step 2: Route-based code splitting (React)

The rider app has three routes. Only the booking flow is needed on first load:

// BOTTLENECK: All routes in a single bundle
import BookingFlow from "./routes/BookingFlow";
import TripHistory from "./routes/TripHistory";
import RideTracking from "./routes/RideTracking";

function RiderApp() {
  return (
    <Routes>
      <Route path="/" element={<BookingFlow />} />
      <Route path="/trips" element={<TripHistory />} />
      <Route path="/ride/:id" element={<RideTracking />} />
    </Routes>
  );
}
// SCALED: Lazy-loaded routes with skeleton fallbacks
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import BookingFlowSkeleton from "./skeletons/BookingFlowSkeleton";
import TripHistorySkeleton from "./skeletons/TripHistorySkeleton";

const BookingFlow = lazy(() => import("./routes/BookingFlow"));
const TripHistory = lazy(() => import("./routes/TripHistory"));
const RideTracking = lazy(() => import("./routes/RideTracking"));

function RiderApp() {
  return (
    <Suspense fallback={<BookingFlowSkeleton />}>
      <Routes>
        <Route path="/" element={<BookingFlow />} />
        <Route
          path="/trips"
          element={
            <Suspense fallback={<TripHistorySkeleton />}>
              <TripHistory />
            </Suspense>
          }
        />
        <Route
          path="/ride/:id"
          element={
            <Suspense fallback={<div className="ride-loading" />}>
              <RideTracking />
            </Suspense>
          }
        />
      </Routes>
    </Suspense>
  );
}

The outer Suspense wraps the initial route. Each non-critical route gets its own Suspense boundary with a route-specific skeleton. When the rider navigates to Trip History, the skeleton renders immediately while the 180KB chunk downloads.

Step 3: Lazy modules (Angular driver dashboard)

The driver dashboard uses Angular modules. The analytics section is heavy and rarely visited:

// BOTTLENECK: Eagerly loaded analytics module
const routes: Routes = [
  { path: "", component: DashboardHomeComponent },
  { path: "analytics", component: AnalyticsComponent },
  { path: "earnings", component: EarningsComponent },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes),
    AnalyticsModule, // 340KB: charts, data tables, export functionality
    EarningsModule, // 120KB: payment history, tax documents
  ],
})
export class AppModule {}
// SCALED: Lazy-loaded feature modules
const routes: Routes = [
  { path: "", component: DashboardHomeComponent },
  {
    path: "analytics",
    loadChildren: () =>
      import("./analytics/analytics.module").then((m) => m.AnalyticsModule),
  },
  {
    path: "earnings",
    loadChildren: () =>
      import("./earnings/earnings.module").then((m) => m.EarningsModule),
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
})
export class AppModule {}

The dashboard home loads at 410KB instead of 870KB. Analytics and earnings load on navigation.

Step 4: Lighthouse CI gate

The build pipeline rejects deploys that regress performance:

# .github/workflows/lighthouse-ci.yml
name: Lighthouse CI
on: [pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci
      - run: npm run build

      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v11
        with:
          configPath: .lighthouserc.json
          uploadArtifacts: true
// .lighthouserc.json
{
  "ci": {
    "collect": {
      "url": ["http://localhost:3000/", "http://localhost:3000/trips"],
      "startServerCommand": "npm run preview",
      "numberOfRuns": 3,
      "settings": {
        "preset": "desktop",
        "throttling": {
          "cpuSlowdownMultiplier": 4
        }
      }
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.7 }],
        "interactive": ["error", { "maxNumericValue": 4000 }],
        "total-byte-weight": ["error", { "maxNumericValue": 1200000 }],
        "total-blocking-time": ["error", { "maxNumericValue": 600 }]
      }
    }
  }
}

The gate enforces: performance score >= 70, TTI < 4 seconds, total weight < 1.2MB, TBT < 600ms. A PR that adds a dependency pushing the bundle past 1.2MB fails CI before review.

The Proof

Bundle composition after all fixes:

Bundle breakdown after optimization showing initial load reduced to 890KB with lazy-loaded chunks loaded on demand

The initial bundle dropped from 2.3MB to 890KB — a 61% reduction. The rider downloads only what the booking flow needs: mapbox-gl, react-dom, and cherry-picked utilities. TripHistory, RideTracking, and the driver-only modules are split into lazy chunks that load on navigation. The result is a TTI of 3.2 seconds on simulated 3G, down from 11.4 seconds.

Lighthouse after fixes (simulated 4x CPU slowdown):

{
  "performance": 82,
  "first-contentful-paint": 1400,
  "largest-contentful-paint": 2100,
  "time-to-interactive": 3200,
  "total-blocking-time": 480,
  "total-byte-weight": 890000
}
MetricBeforeAfterDelta
Bundle size2.3MB890KB-61%
TTI (3G sim)11.4s3.2s-72%
TBT3,800ms480ms-87%
Lighthouse perf3882+116%

The 890KB bundle still includes 580KB of mapbox-gl, which is irreducible for the map-based booking flow. Server-side rendering could improve LCP further by rendering the initial layout before JavaScript loads. That is a different architectural decision with its own tradeoffs, outside the scope of this section.

The Lighthouse CI gate prevents regression. Every PR that touches frontend dependencies or route configuration runs against these thresholds. The number is the proof. The gate is the enforcement.