Skip to content
Episode 17 · Fast API, slow page

Frontend performance and Core Web Vitals

Monday, 9:40: the hotel page API answers in 80 ms, yet on a mid-range phone the main photo appears after 4.2 s and Lighthouse scores the page 38 out of 100. Mobile guests book half as often as desktop guests.

Your API answers in 50 ms, yet the page feels slow. Here's where the rest of the time goes, and the three numbers Google uses to measure it.

30 min intermediateCore Web Vitals: LCP, INP, CLSBundle size and code splittingImage formats, sizing and lazy loadingCaching static assets with hashed filenames and CDNsCDN edge cachingLatency
  1. Problem

    A hotel search page has a fast API (~80 ms), yet on a mid-range phone the hero photo appears after 4 s, taps on "Book" lag, and the page jumps while loading. Mobile conversion is half of desktop.

  2. Why the simple system fails

    Teams test on fast laptops and office Wi-Fi, ship one huge JavaScript bundle and full-size photos, and look only at server response time. The real cost is on the user's device and network.

  3. Engineering concept

    Measure what users feel: loading (LCP), responsiveness (INP) and stability (CLS), at the 75th percentile of real visits. Then reduce bytes, round trips and main-thread work on the critical path.

  4. Technology

    Code splitting and lazy loading, modern image formats with responsive sizes, server/static rendering, hashed static assets with long Cache-Control on a CDN, reserved space for media, real-user monitoring and Lighthouse.

  5. Trade-offs

    Every optimisation adds build complexity or caching risk; aggressive lazy loading can delay what matters; budgets constrain feature work. Optimise the measured bottleneck, not everything.

01

The problem

2 min
Monday, 9:40. Hotel pages are ISR now; the text appears at 0.4 s.
  1. Maya · Product manager
    On phones the main photo shows up after 4.2 s. Mobile guests book half as often as desktop.
  2. Karim · CTO
    The API answers in 80 ms. Lighthouse says 38/100. How?
  3. Lina · Senior engineer, your mentor
    You drive this one. Where do you think the time goes?
02

The big idea

4 min
Where 4.2 seconds go
The big idea

A fast API can hide behind a slow page. Measure what guests feel, on their phones.

Server speed and page speed are different measurements. Our desk is fast; the stay is slow.

03

How it works

5 min

turn "feels slow" into three numbers, judged at p75: the value 75% of real visits beat, so your slowest quarter counts.

From a complaint to a cause
  1. 1
    LCP

    : when the biggest visible element is drawn. Good ≤ 2.5 s. Ours: 4.2 s.

  2. 2
    INP

    : how long a tap takes to show a reaction. Good ≤ 200 ms. Ours: 380 ms.

  3. 3
    CLS

    : how much content jumps. Good ≤ 0.1. Ours: 0.24.

  4. 4
    Trace the causes

    The LCP element is the hero photo: 3.8 MB, 4000 px, loading="lazy". The : 900 KB, admin charts included.

  5. 5
    And the rest

    "Book" runs a 350 ms . Photos lack width and height; a cookie banner is pushed in at the top.

The levers are always four: fewer bytes, fewer round trips, less main-thread work, space reserved before content arrives. The biggest slice is clear: a 3.8 MB photo for a 400-pixel screen, which the browser starts late because it's lazy.

04

Watch a decision being made

4 min
Worked example

You fix the hotel page, Lina checks

Field p75: LCP 4.2 s, INP 380 ms, CLS 0.24, with an 80 ms API. Bring all three under target without a rewrite.

  1. 1Start from field data, name the LCP element

    LCP is furthest from target. A throttled trace: the hero <img>, starting at 1.9 s, finishing at 4.2 s.

    Why: Lab runs on a laptop look fine. LCP is always one element; fixing anything else barely moves it.

  2. 2Fix the hero

    AVIF at the right size, dimensions set, no lazy, high priority.

    html
    <img src="/img/alfama-800.avif"
      srcset="/img/alfama-480.avif 480w, /img/alfama-800.avif 800w"
      sizes="100vw" width="800" height="533"
      fetchpriority="high" alt="Hotel Alfama terrace over the river">

    Why: 3.8 MB becomes 160 KB, and the download starts as soon as the HTML is read.

  3. 3Split the bundle, shorten the long task

    Admin charts leave the guest bundle; map and date picker load on demand: 900 → ~300 KB. "Book" paints the panel first, then does heavy work in pieces.

    Why: Less JavaScript frees the main thread; INP waits for the next paint, so paint first.

  4. 4Reserve space, measure in the field

    Dimensions on every image, the cookie banner's slot at the bottom. Two weeks later: LCP 1.6 s, INP 150 ms, CLS 0.03.

    Why: Only field data proves it worked for guests.

The whole thing in one line: Worst metric → its element → fix → measure on real phones. No server change needed.
05

In real life

3 min
How much to invest in the first load

Gmail: accepts a loading screen

  • Shows a loading bar on first open while a large app starts
  • People keep it open for hours, so the start is paid once

Fits an app people open on purpose and keep open.

Pinterest: rebuilt its mobile web for speed

  • Has publicly described rebuilding its mobile site with far less JavaScript
  • Many visits are first visits from search or shared links
  • Reported more engagement after

Fits sites where a first visit from a link must feel instant.

06

What goes wrong

3 min
Lazy-loading the hero image

It delays the LCP element until layout confirms it's visible.

Instead

Lazy-load below the fold only; hero eager, high priority.

Importing a whole library for one feature

A map or chart library adds hundreds of KB to every page.

Instead

Split heavy features into lazy chunks.

Optimising everything at once

Effort spreads thin; the real bottleneck stays.

Instead

Worst metric at p75 → its element → fix → re-measure.

07

Trade-offs

3 min
OptionFits whenYou payMain risk
Code splittingHeavy features few use (maps, admin)Loading statesDelay on first open
Long CDN cachingHashed assets, alwaysA purge strategy for HTMLStale unhashed files
Budget in CISpeed matters, several teams shipSlower feature workExceptions nobody owns
Thursday, 11:00. The hotel route's 300 KB JavaScript budget is at 290 KB.
  1. Maya · Product manager
    I want a 140 KB review-photo carousel at the top of hotel pages. Ship Friday?
  2. Omar · On-call SRE
    That's 430 KB: the build fails. Put it below the rooms, loaded when it scrolls into view.
  3. Maya · Product manager
    Below the rooms, half the guests never see it. Reviews sell rooms.
08

Your job as tech lead

4 min

Claude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.

Decisions you own
  • Which numbers count: field data at p75 (LCP, INP, CLS), not a Lighthouse score on a developer laptop.
  • The budgets: LCP under 2.5 s, INP under 200 ms, CLS under 0.1, at most 300 KB of JavaScript on first load.
  • Which features earn their weight on the page, and what third-party widgets may do to the layout.
Tell Claude Code
StayHub's hotel page, mobile p75: LCP 4.2 s, INP 380 ms, CLS 0.24; API 80 ms. The LCP element is a 3.8 MB 4000 px hero JPEG with loading="lazy". JS is one 900 KB bundle with the map, admin charts and date picker. In order: serve the hero resized as AVIF, eager, high priority; load map and date picker on use and drop admin charts; give images width and height and reserve the cookie banner's space. Keep the design. Plan first; report a 4G trace and bundle sizes before and after.
Check before you accept
  • The hero request is AVIF, a few hundred KB at most, not lazy, requested early.
  • Initial JavaScript near 300 KB, with map and date picker in separate chunks.
  • Real-user data a few days after release confirms it; lab numbers are a forecast.
Red flags
  • loading="lazy" on every image, the hero included.
  • A better desktop Lighthouse score while mobile field numbers haven't moved.
  • Backend caching when the API takes 80 ms.
09

Check yourself

2 min
Q1/3Debugging
Server logs show the search API answers in 60 ms at p95, but field data shows LCP of 4.5 s on mobile for this client-side rendered page. Where is the time most likely going?
How confident are you?

Next episode: a guest who uses a screen reader writes in: she reaches the hotel page, but the "Book" button is silent and she can't finish a booking.

Finished?

Mark it complete to update your skill map.

Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
10

Technical deep dive

From 4.2 s to 1.6 s
Worked example

You fix the hotel page, Lina checks

On real phones (p75) the hotel page has LCP 4.2 s, INP 380 ms and CLS 0.24, while the API takes 80 ms. Bring all three under the targets (2.5 s, 200 ms, 0.1) without a rewrite.

  1. 1Start from field data

    Read p75 on mobile from real-user monitoring: LCP 4.2 s, INP 380 ms, CLS 0.24. LCP is furthest from target.

    Why: Lab runs on your laptop can look fine. Field data is what guests feel, and it tells you which metric to attack first.

  2. 2Name the LCP element

    A throttled lab trace shows the LCP element is the hero <img>: 3.8 MB, 4000 px, loading="lazy", starting at 1.9 s and finishing at 4.2 s.

    Why: LCP is always one element. Fixing anything else barely moves it.

  3. 3Fix the hero

    Serve AVIF at the right size with srcset, set width/height, remove lazy, add fetchpriority="high".

    html
    <img src="/img/alfama-800.avif"
      srcset="/img/alfama-480.avif 480w, /img/alfama-800.avif 800w"
      sizes="100vw" width="800" height="533"
      fetchpriority="high" alt="Hotel Alfama terrace over the river">

    Why: 3.8 MB becomes 160 KB on a phone, and the download starts as soon as the HTML is read instead of after layout.

  4. 4Split the bundle

    Remove admin charts from the guest bundle, and load the map and date picker on demand with dynamic import(). Initial JS: 900 KB → about 300 KB.

    tsx
    const HotelMap = dynamic(() => import("./hotel-map"), { ssr: false });
    const { DatePicker } = await import("./date-picker"); // on first tap

    Why: Less JavaScript to download, parse and hydrate means the main thread is free sooner, which helps both LCP and INP.

  5. 5Shorten the long task

    The "Book" tap ran a 350 ms task (re-filtering all rooms and re-rendering). Show the booking panel first, then do the heavy work in smaller pieces.

    Why: INP measures the wait until the next paint. Painting a response first, then working, keeps taps under 200 ms.

  6. 6Reserve space, then measure in the field

    Add dimensions to every image and reserve the cookie banner's slot at the bottom. Two weeks later, p75: LCP 1.6 s, INP 150 ms, CLS 0.03.

    Why: Field data needs real visits to update. Only then do you know it worked for guests, not just for your trace.

The whole thing in one line: You went from field data to the worst metric, to its element, fixed the hero image, split the bundle, broke up the long task and reserved space: LCP 4.2 → 1.6 s, INP 380 → 150 ms, CLS 0.24 → 0.03, with no server change.
http
GET /_next/static/chunks/app.3f9c2a.js
→ Cache-Control: public, max-age=31536000, immutable

GET /hotels/42
→ Cache-Control: public, s-maxage=600, stale-while-revalidate=300
Files with a content hash in their name can be cached for a year: a changed file gets a new name. Pages are cached briefly.
SymptomLikely metricFirst things to check
Hero photo appears lateLCPImage size/format, lazy-loaded hero, render-blocking CSS/JS, client-side data fetch
Taps feel sluggishINPLong tasks (> 50 ms), heavy hydration, expensive re-renders, third-party scripts
Content jumps while loadingCLSImages, ads, widgets without reserved space; late banners; font swaps
Slow on repeat visits tooAllMissing Cache-Control, files without content hashes
Map the complaint to a metric, then to a cause.
11

Interactive simulation

12

Visual diagram

Click a component

The critical path of a page load and where each vital is decided.

13

Analogy

  • Checking into a hotelHow long until you get your room keyPage performanceLCP: main content appears
  • Checking into a hotelThe receptionist busy with a giant formPage performanceINP: a long task delays your tap
  • Checking into a hotelThe queue rope moved while you waitPage performanceCLS: you tap the wrong button
  • Checking into a hotelHanding over one page, not the manualPage performanceCode splitting
  • Checking into a hotelWelcome packs pre-printed in every branchPage performanceCaching on a CDN

Where the analogy breaks: a guest waits once. A web page is judged on every visit, on every kind of phone and network, so you look at the slow end, not the average.

14

Check your understanding

Q1/3Scenario
A results page shows 20 hotel thumbnails, each a 1.5 MB original JPEG. After resizing to display size and converting to AVIF, each is 60 KB. Roughly how many MB of image data does one page view save?
MB
How confident are you?
15

Practice ladder

Step 1 · Trace

Read a hotel page waterfall

Debugging Warm-up

A throttled trace of the Lisbon city page on a mid-range phone:

TimeEvent
0.0 sRequest starts
0.3 sHTML arrives, text visible
0.3–1.8 s700 KB JavaScript downloads and runs (one task of 600 ms at 1.2 s)
1.9 sHero image (2.4 MB, loading="lazy") starts downloading
3.6 sHero image drawn
3.9 sA "Free cancellation" banner is inserted at the top, pushing content down 80 px

Which element sets LCP, and what's its value? Which events hurt INP and CLS? Name the first fix for each.

Step 2 · Complete

Finish the fix plan for the search page

Debugging Practice

Field data for the search results page, p75 mobile: LCP 2.1 s (fine), INP 520 ms, CLS 0.02 (fine). Tapping the "Pool" filter freezes the page for half a second.

Your plan has two gaps. Fill in steps 2 and 4.

  1. Start from field data: INP is the only failing metric.
  2. Find the task behind it: ___
  3. Name the cause: the filter handler re-sorts and re-renders all 200 result cards in one task before anything is painted.
  4. Fix: ___
  5. Measure again in the field after a week.
Step 3 · Solve

Every visit feels like the first one

Debugging Practice

Maya: "Returning guests say StayHub is just as slow on their second visit." Omar checks the response headers for the hotel page's files:

GET /static/app.js        → Cache-Control: no-cache
GET /static/styles.css    → Cache-Control: no-cache
GET /img/alfama-800.avif  → (no Cache-Control header)

The file names never change between deploys. Explain why repeat visits are slow and propose a fix that's safe when we deploy new code.

Step 4 · Decide

Chat widget on every page?

Decision Stretch

Maya wants to replace the old chat widget with a new vendor's live chat on every page. The vendor's script is 220 KB and runs a 150 ms task on load. Hotel pages are now at LCP 1.6 s and INP 150 ms (p75 mobile), with a 300 KB JS budget currently at 290 KB.

Options: - A: load the vendor script on every page, as the vendor recommends. - B: show our own small "Chat with us" button (a few KB); load the vendor script only when a guest taps it.

Write a short decision: "We chose X over Y because…, accepting the risk of…".

16

Explain it yourself

Explain to a product manager why the page is slow on phones even though "the backend is fast", and what you'd do first.

0 words