Bundle Size, Code Splitting, and Time to Interactive
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:
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:
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
}
| Metric | Before | After | Delta |
|---|---|---|---|
| Bundle size | 2.3MB | 890KB | -61% |
| TTI (3G sim) | 11.4s | 3.2s | -72% |
| TBT | 3,800ms | 480ms | -87% |
| Lighthouse perf | 38 | 82 | +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.