The Browser Is a Client You Cannot Optimize: Bundle Size and Core Web Vitals
The Browser Is a Client You Cannot Optimize
The Symptom
The ride-hailing backend handles 12,000 requests per second. The p99 on the fare estimate endpoint is 180ms after the Redis caching work from Chapter 5. The SRE team declares victory. Then the product manager forwards a screenshot from a rider in Jakarta: the booking screen took 9.2 seconds to become interactive on a Samsung Galaxy A14.
The backend is fast. The browser is slow. The 2.3MB JavaScript bundle that ships to every rider takes 4.1 seconds to parse and compile on that mid-range phone before a single API call fires. The backend optimization was real. The user never felt it.
The Cause
Frontend performance is a backend problem that manifests in the browser. The team controls three things that determine whether a rider can tap “Request Ride” within 3 seconds of opening the app:
-
How much JavaScript ships. Every dependency, every route, every utility function that lands in the initial bundle adds parse and compile time on the device. The backend team chose to embed a full analytics SDK. The frontend team imported all of lodash for one function. Nobody measured the cost.
-
How the API responds. The trip history endpoint returns full trip objects with driver ratings, route geometry, payment breakdowns, and support ticket references. The rider list view shows pickup, dropoff, date, and fare. The other 14 fields parse as JSON, allocate memory, and get discarded by the rendering layer. The booking screen makes three sequential API calls for data that could be one.
-
How static assets cache. The CDN serves the main bundle with
Cache-Control: no-cachebecause a deploy six months ago shipped a bug and the team wanted to “make sure users get the latest version.” Every return visit downloads 2.3MB again.
These three problems compound. The bundle is too large. The API sends too much data. The CDN does not cache. The rider on a 3G connection in a surge-pricing zone waits 9.2 seconds and switches to the competitor.
The Baseline
Core Web Vitals provide the measurement framework. Google defines three metrics that map to user experience:
| Metric | What it measures | Target | Ride-hailing current |
|---|---|---|---|
| LCP (Largest Contentful Paint) | When the main content renders | < 2.5s | 4.8s (booking map) |
| CLS (Cumulative Layout Shift) | Visual stability during load | < 0.1 | 0.34 (fare estimate shift) |
| INP (Interaction to Next Paint) | Responsiveness to user input | < 200ms | 620ms (request ride button) |
Every metric fails. The backend is sub-200ms. The frontend is broken.
The web-vitals library captures these in production:
// src/vitals.ts
import { onLCP, onCLS, onINP } from "web-vitals";
function sendToAnalytics(metric: { name: string; value: number; id: string }) {
navigator.sendBeacon(
"/api/vitals",
JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
page: window.location.pathname,
connection: (navigator as any).connection?.effectiveType ?? "unknown",
deviceMemory: (navigator as any).deviceMemory ?? 0,
timestamp: Date.now(),
}),
);
}
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
Real user data, segmented by connection type, reveals the gap between lab testing and production:
Connection Type LCP(p75) CLS(p75) INP(p75)
4G 2.1s 0.34 280ms
3G 6.8s 0.34 620ms
2G 14.2s 0.34 1100ms
CLS is connection-independent because it is a layout problem, not a network problem. LCP and INP scale with connection speed because they depend on bundle download and parse time.
The Fix
Three interventions, each covered in detail in the sub-sections that follow.
Code splitting breaks the 2.3MB bundle into route-based chunks. The booking flow loads 890KB. Trip history loads on navigation. The driver dashboard loads its analytics module lazily.
React:
// SCALED: Route-based code splitting for rider app
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
const BookingFlow = lazy(() => import("./routes/BookingFlow"));
const TripHistory = lazy(() => import("./routes/TripHistory"));
const RideTracking = lazy(() => import("./routes/RideTracking"));
function RiderApp() {
return (
<Suspense fallback={<LoadingSkeleton />}>
<Routes>
<Route path="/" element={<BookingFlow />} />
<Route path="/trips" element={<TripHistory />} />
<Route path="/ride/:id" element={<RideTracking />} />
</Routes>
</Suspense>
);
}
Angular:
// SCALED: Lazy modules for driver dashboard
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),
},
];
API payload optimization reduces over-fetching. The trip history endpoint gets a sparse fieldset parameter:
// SCALED: Sparse fieldset for trip list
@GetMapping("/api/trips/history")
public Flux<TripSummary> getTripHistory(
@RequestHeader("X-User-Id") String userId,
@RequestParam(defaultValue = "summary") String fields) {
if ("summary".equals(fields)) {
return tripRepository.findSummariesByUserId(userId);
// Returns: id, pickup, dropoff, date, fare (5 fields, ~200 bytes)
}
return tripRepository.findFullByUserId(userId);
// Returns: 19 fields, ~3.2KB per trip
}
The booking screen’s three sequential API calls collapse into one aggregation endpoint:
// SCALED: Aggregation endpoint for booking screen
@GetMapping("/api/booking/context")
public Mono<BookingContext> getBookingContext(
@RequestParam double lat,
@RequestParam double lng) {
return Mono.zip(
driverService.findNearby(lat, lng, 5),
surgeService.getCurrentMultiplier(lat, lng),
etaService.estimatePickup(lat, lng)
).map(tuple -> new BookingContext(
tuple.getT1(), // nearby drivers
tuple.getT2(), // surge multiplier
tuple.getT3() // pickup ETA
));
}
CDN cache with content-hashed filenames eliminates re-downloads on return visits:
# SCALED: CDN cache configuration for static assets
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
}
location /index.html {
add_header Cache-Control "no-cache";
# HTML is never cached long-term; it references hashed asset URLs
}
The webpack build produces filenames like booking.a3f8c2.js. When code changes, the hash changes, and the browser fetches the new file. When code does not change, the browser uses the cached version for up to one year. The HTML file, which references these hashed filenames, is the only file that is not cached.
The Proof
After all three interventions, the Locust baseline re-run targets the API payload changes:
Endpoint Before(p99) After(p99) Payload Before Payload After
/api/trips/history 8,400ms 1,200ms 64KB (20 trips) 4KB (20 trips)
/api/booking/context N/A 140ms N/A (3 calls) 2.1KB (1 call)
/api/drivers/nearby 2,100ms 2,100ms unchanged unchanged
The trip history payload dropped from 3.2KB per trip to 200 bytes per trip. For a page showing 20 trips, that is 64KB down to 4KB. On 3G, that difference is 1.8 seconds of transfer time alone.
Core Web Vitals after the fixes, measured from real users over one week:
Connection Type LCP(p75) CLS(p75) INP(p75)
4G 1.2s 0.04 120ms
3G 2.8s 0.04 190ms
2G 5.1s 0.04 340ms
LCP on 4G dropped from 4.8s to 1.2s. CLS dropped from 0.34 to 0.04. INP on 3G dropped from 620ms to 190ms. The 4G and 3G segments now pass all three Core Web Vitals thresholds.
The 2G segment still fails LCP and INP. For users on 2G connections, the bundle is still too large even at 890KB. That is a product decision: serve a stripped-down version, or accept the gap. The data makes the decision possible. Without measurement, the team would not know the gap exists.
Chapter 11-S1 covers bundle analysis and code splitting in detail. Chapter 11-S2 covers Core Web Vitals as SLOs and API design patterns that reduce frontend rendering cost.