Skip to main content
surviving the spike

Core Web Vitals as SLOs and API Design for Frontend Performance

9 min read Chapter 33 of 66

Core Web Vitals as SLOs and API Design for Frontend Performance

The Symptom

The ride-hailing platform passes every backend SLO. Fare estimate p99 is 180ms. Driver location ingestion handles 15,000 updates per second. The trip history endpoint returns in 200ms. The Grafana dashboard is green across the board.

Google Search Console shows a different picture. The booking page has a “Poor” CWV assessment. 62% of real users experience LCP above 2.5 seconds. The fare estimate component causes a CLS of 0.34, triple the 0.1 threshold. The ride request button has an INP of 620ms on mobile, three times the 200ms target.

The backend team says the problem is the frontend. The frontend team says the problem is the API. Both are right.

The Cause

Each Core Web Vital failure maps to a specific interaction between the frontend rendering pipeline and the backend API design.

LCP: The booking screen map. The booking screen’s largest contentful element is the map showing nearby drivers. The rendering sequence:

1. HTML loads                           →  200ms
2. JavaScript bundle parses             →  800ms (after code splitting)
3. React renders BookingFlow component  →  100ms
4. Map component mounts                 →  150ms
5. API call: GET /api/drivers/nearby    →  400ms (network + server)
6. Map renders driver markers           →  120ms
                                  Total →  1,770ms (4G)
                                         →  4,800ms (3G, steps 1-2 are slower)

Steps 1-4 happen before the map has any data. The API call in step 5 blocks the map from rendering driver markers. On 3G, the JavaScript download and parse in steps 1-2 take 3 seconds, pushing LCP past 4.8 seconds.

CLS: The fare estimate layout shift. The fare estimate component renders in two phases:

// BOTTLENECK: Two-phase render causes layout shift
function FareEstimate({ pickup, dropoff }: FareEstimateProps) {
  const [fare, setFare] = useState<FareData | null>(null);

  useEffect(() => {
    fetchFareEstimate(pickup, dropoff).then(setFare);
  }, [pickup, dropoff]);

  if (!fare) return <FareEstimateSkeleton />;

  return (
    <div className="fare-estimate">
      <span className="base-fare">${fare.baseFare}</span>
      {fare.surgeMultiplier > 1.0 && (
        <span className="surge-badge">{fare.surgeMultiplier}x surge</span>
      )}
    </div>
  );
}

The skeleton renders at 48px height. When surge pricing is active, the surge badge adds 32px. The layout shifts by 32px, pushing the “Request Ride” button down. CLS registers 0.34 because the shifted element is large and the distance is significant relative to the viewport on mobile.

INP: The ride request button freeze. The click handler on the “Request Ride” button validates the payment method synchronously before dispatching the request:

// BOTTLENECK: Synchronous API call in click handler blocks main thread
async function handleRequestRide() {
  // This await blocks the main thread for 400ms
  const paymentValid = await fetch("/api/payments/validate", {
    method: "POST",
    body: JSON.stringify({ userId, paymentMethodId }),
  }).then((r) => r.json());

  if (!paymentValid.valid) {
    showPaymentError();
    return;
  }

  await fetch("/api/rides/request", {
    method: "POST",
    body: JSON.stringify({ pickup, dropoff, fareEstimateId }),
  });
}

The await does not literally block the main thread. But the click handler is async, and React batches the state update until the handler completes. The browser marks the interaction as “processing” until the next paint after the handler resolves. The 400ms network call to validate payment becomes 400ms+ of INP.

The Baseline

Real user metrics collected over one week via the web-vitals library, segmented by page:

Page                 LCP(p75)   CLS(p75)   INP(p75)   Pass Rate
/booking             4.8s       0.34       620ms      8%
/trips               2.1s       0.02       90ms       91%
/ride/:id            1.8s       0.08       140ms      94%

The booking page, where riders spend 80% of their time, passes CWV for 8% of sessions. The trip history and ride tracking pages are acceptable. The booking page is the problem.

The Fix

LCP: Preload data, eliminate the render-blocking API call

The API call for driver availability blocks map rendering. Move it to a preload:

// SCALED: Preload driver data before component mounts
// src/routes/BookingFlow/loader.ts
export async function bookingLoader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const lat = parseFloat(url.searchParams.get("lat") ?? "0");
  const lng = parseFloat(url.searchParams.get("lng") ?? "0");

  // Fires during route transition, before component renders
  const contextPromise = fetch(
    `/api/booking/context?lat=${lat}&lng=${lng}`,
  ).then((r) => r.json());

  return defer({ context: contextPromise });
}

// src/routes/BookingFlow/BookingFlow.tsx
function BookingFlow() {
  const { context } = useLoaderData() as { context: Promise<BookingContext> };

  return (
    <Suspense fallback={<MapSkeleton />}>
      <Await resolve={context}>
        {(data: BookingContext) => (
          <BookingMap
            drivers={data.nearbyDrivers}
            surgeMultiplier={data.surgeMultiplier}
            pickupEta={data.pickupEta}
          />
        )}
      </Await>
    </Suspense>
  );
}

The route loader fires the API call during navigation, overlapping with JavaScript chunk loading. The map component receives data as a resolved promise and renders immediately. LCP drops because the API call no longer serializes after the JavaScript parse.

The backend supports this with the aggregation endpoint from CH11:

// SCALED: Single aggregation endpoint replaces three sequential calls
@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(),
        tuple.getT2(),
        tuple.getT3()
    ));
}

Three sequential calls (driver availability, surge multiplier, pickup ETA) become one parallel call. Network round trips drop from three to one.

CLS: Reserve space for dynamic content

The fare estimate component must reserve space for the surge badge regardless of whether surge is active:

// SCALED: Fixed-height container eliminates layout shift
function FareEstimate({ pickup, dropoff }: FareEstimateProps) {
  const [fare, setFare] = useState<FareData | null>(null);

  useEffect(() => {
    fetchFareEstimate(pickup, dropoff).then(setFare);
  }, [pickup, dropoff]);

  return (
    <div className="fare-estimate" style={{ minHeight: "80px" }}>
      {fare ? (
        <>
          <span className="base-fare">${fare.baseFare}</span>
          <span
            className="surge-badge"
            style={{
              visibility: fare.surgeMultiplier > 1.0 ? "visible" : "hidden",
            }}
          >
            {fare.surgeMultiplier > 1.0
              ? `${fare.surgeMultiplier}x surge`
              : "\u00A0"}
          </span>
        </>
      ) : (
        <FareEstimateSkeleton height={80} />
      )}
    </div>
  );
}

The container is always 80px. The surge badge is always rendered but hidden when inactive. visibility: hidden reserves layout space without displaying content. CLS drops to near zero for this component.

INP: Optimistic UI, defer validation

The payment validation moves out of the critical interaction path:

// SCALED: Optimistic ride request with deferred validation
function handleRequestRide() {
  // Immediately update UI
  setRideState("requesting");

  // Fire ride request without blocking on payment validation
  requestRide({
    pickup,
    dropoff,
    fareEstimateId,
    paymentMethodId,
  });
}

// The API handles validation server-side
async function requestRide(params: RideRequestParams) {
  const response = await fetch("/api/rides/request", {
    method: "POST",
    body: JSON.stringify(params),
  });

  const result = await response.json();

  if (result.status === "payment_invalid") {
    setRideState("payment_error");
    return;
  }

  setRideState("matching");
}

The click handler calls setRideState('requesting') synchronously, which triggers a React render and updates the button to a loading state. The browser paints within 16ms. INP drops from 620ms to under 50ms. The payment validation still happens, but on the server side as part of the ride request flow, not as a blocking pre-check in the browser.

CDN cache: Content-hashed filenames with immutable headers

Static assets use content hashing in the build:

// webpack.config.js (relevant section)
module.exports = {
  output: {
    filename: "[name].[contenthash:8].js",
    chunkFilename: "[name].[contenthash:8].js",
    assetModuleFilename: "assets/[name].[contenthash:8][ext]",
  },
};

The CDN configuration serves hashed assets as immutable:

booking.a3f8c2d1.js    → Cache-Control: public, max-age=31536000, immutable
vendor.9b2e4f71.js     → Cache-Control: public, max-age=31536000, immutable
styles.c4d8e2a0.css    → Cache-Control: public, max-age=31536000, immutable
index.html             → Cache-Control: no-cache

The deployment sequence matters:

  1. Build with new content hashes
  2. Deploy new assets to CDN (old assets still exist)
  3. Deploy new index.html referencing new asset hashes
  4. Old assets expire after 24 hours via CDN purge policy

Step 2 before step 3 ensures no user loads an index.html that references assets not yet deployed. The 24-hour overlap means users with cached index.html can still load old assets while the transition completes.

Lighthouse CI gate in GitHub Actions

# .github/workflows/cwv-gate.yml
name: Core Web Vitals Gate
on:
  pull_request:
    paths:
      - "src/**"
      - "package.json"

jobs:
  cwv-check:
    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: Lighthouse CI
        uses: treosh/lighthouse-ci-action@v11
        with:
          urls: |
            http://localhost:3000/
          budgetPath: ./budgets.json
          uploadArtifacts: true
          temporaryPublicStorage: true
// budgets.json
[
  {
    "path": "/",
    "timings": [
      { "metric": "largest-contentful-paint", "budget": 2500 },
      { "metric": "cumulative-layout-shift", "budget": 100 },
      { "metric": "total-blocking-time", "budget": 300 }
    ],
    "resourceSizes": [
      { "resourceType": "script", "budget": 900 },
      { "resourceType": "total", "budget": 1400 }
    ]
  }
]

The budget enforces LCP < 2.5s, CLS < 0.1 (budget values are multiplied by 1000), TBT < 300ms, script size < 900KB, total size < 1.4MB. A PR that adds a dependency or changes the rendering pipeline runs against these budgets. Regressions fail the check.

The Proof

Core Web Vitals after all fixes, measured from production real user data over two weeks:

Page        Metric    Before    After     Target    Status
/booking    LCP       4.8s      1.9s      < 2.5s    PASS
/booking    CLS       0.34      0.03      < 0.1     PASS
/booking    INP       620ms     48ms      < 200ms   PASS
/trips      LCP       2.1s      1.8s      < 2.5s    PASS
/ride/:id   LCP       1.8s      1.6s      < 2.5s    PASS

Google Search Console CWV assessment for the booking page moved from “Poor” to “Good” within 28 days of the fix deployment (the assessment window).

The API changes that made this possible:

ChangeEffect
/api/booking/context aggregation3 sequential calls → 1 parallel call. 1,200ms → 400ms network time
Trip history sparse fieldsets64KB → 4KB payload for 20 trips
Server-side payment validationRemoved 400ms blocking call from click handler

The backend p99 did not change. The API still responds in under 200ms. The improvement is entirely in how the frontend uses that response: fewer calls, smaller payloads, no render-blocking data fetches, no layout shifts, no synchronous validation in click handlers.

These are backend decisions. The frontend code changed, but the decisions that made the frontend slow were API design decisions made by backend engineers who never opened Chrome DevTools. Core Web Vitals as SLOs make this visible. The Lighthouse CI gate makes it enforceable.