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.
- 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.
- 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.
- 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.
- 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.
- 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.
The problem
2 min- Maya · Product managerOn phones the main photo shows up after 4.2 s. Mobile guests book half as often as desktop.
- Karim · CTOThe API answers in 80 ms. Lighthouse says 38/100. How?
- Lina · Senior engineer, your mentorYou drive this one. Where do you think the time goes?
The big idea
4 minA 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.
How it works
5 minturn "feels slow" into three numbers, judged at p75: the value 75% of real visits beat, so your slowest quarter counts.
- 1LCP
: when the biggest visible element is drawn. Good ≤ 2.5 s. Ours: 4.2 s.
- 2INP
: how long a tap takes to show a reaction. Good ≤ 200 ms. Ours: 380 ms.
- 3CLS
: how much content jumps. Good ≤ 0.1. Ours: 0.24.
- 4Trace the causes
The LCP element is the hero photo: 3.8 MB, 4000 px,
loading="lazy". The : 900 KB, admin charts included. - 5And 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.
Watch a decision being made
4 minYou 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.
- 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.
- 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.
- 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.
- 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.
In real life
3 minGmail: 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.
What goes wrong
3 minIt delays the LCP element until layout confirms it's visible.
Lazy-load below the fold only; hero eager, high priority.
A map or chart library adds hundreds of KB to every page.
Split heavy features into lazy chunks.
Effort spreads thin; the real bottleneck stays.
Worst metric at p75 → its element → fix → re-measure.
Trade-offs
3 min| Option | Fits when | You pay | Main risk |
|---|---|---|---|
| Code splitting | Heavy features few use (maps, admin) | Loading states | Delay on first open |
| Long CDN caching | Hashed assets, always | A purge strategy for HTML | Stale unhashed files |
| Budget in CI | Speed matters, several teams ship | Slower feature work | Exceptions nobody owns |
- Maya · Product managerI want a 140 KB review-photo carousel at the top of hotel pages. Ship Friday?
- Omar · On-call SREThat's 430 KB: the build fails. Put it below the rooms, loaded when it scrolls into view.
- Maya · Product managerBelow the rooms, half the guests never see it. Reviews sell rooms.
Your job as tech lead
4 minClaude 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.
- 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.
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.
- 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.
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.
Check yourself
2 minNext 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.
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.
Technical deep dive
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.
- 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.
- 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.
- 3Fix the hero
Serve AVIF at the right size with
srcset, setwidth/height, removelazy, addfetchpriority="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.
- 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.tsxconst HotelMap = dynamic(() => import("./hotel-map"), { ssr: false }); const { DatePicker } = await import("./date-picker"); // on first tapWhy: Less JavaScript to download, parse and hydrate means the main thread is free sooner, which helps both LCP and INP.
- 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.
- 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.
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| Symptom | Likely metric | First things to check |
|---|---|---|
| Hero photo appears late | LCP | Image size/format, lazy-loaded hero, render-blocking CSS/JS, client-side data fetch |
| Taps feel sluggish | INP | Long tasks (> 50 ms), heavy hydration, expensive re-renders, third-party scripts |
| Content jumps while loading | CLS | Images, ads, widgets without reserved space; late banners; font swaps |
| Slow on repeat visits too | All | Missing Cache-Control, files without content hashes |
Interactive simulation
Visual diagram
The critical path of a page load and where each vital is decided.
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.
Check your understanding
Practice ladder
Read a hotel page waterfall
Debugging Warm-upA throttled trace of the Lisbon city page on a mid-range phone:
| Time | Event |
|---|---|
| 0.0 s | Request starts |
| 0.3 s | HTML arrives, text visible |
| 0.3–1.8 s | 700 KB JavaScript downloads and runs (one task of 600 ms at 1.2 s) |
| 1.9 s | Hero image (2.4 MB, loading="lazy") starts downloading |
| 3.6 s | Hero image drawn |
| 3.9 s | A "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.
Finish the fix plan for the search page
Debugging PracticeField 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.
- Start from field data: INP is the only failing metric.
- Find the task behind it: ___
- Name the cause: the filter handler re-sorts and re-renders all 200 result cards in one task before anything is painted.
- Fix: ___
- Measure again in the field after a week.
Every visit feels like the first one
Debugging PracticeMaya: "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.
Chat widget on every page?
Decision StretchMaya 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…".
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.