ЁЯПл The SchoolтА║ЁЯПОя╕П PerformanceтА║ЁЯУ▒ рдзрдбрд╛ 11 тАФ Web performance: рдкрд╛рд▓рдХрд╛рдВрдЪреНрдпрд╛ phone рд╡рд░рдЪреЗ рдирд┐рдХрд╛рд▓рд╛рдЪреЗ page
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯУ▒ рдзрдбрд╛ 11 тАФ Web performance: рдкрд╛рд▓рдХрд╛рдВрдЪреНрдпрд╛ phone рд╡рд░рдЪреЗ рдирд┐рдХрд╛рд▓рд╛рдЪреЗ page

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 12 рдкреИрдХреА рдзрдбрд╛ 11 ┬╖ рдорд╛рдЧреЗ: lesson-10-database-queries ┬╖ рдкреБрдвреЗ: lesson-12-load-testing-budgets


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбреЗ 01тАУ10, рдЖрдгрд┐ performance рдЪрд╛ рддреЛ рднрд╛рдЧ рдЬреЛ user рд▓рд╛ рдкреНрд░рддреНрдпрдХреНрд╖ рджрд┐рд╕рддреЛ: browser рдордзрд▓реЗ page. Google рдЪреЗ Core Web Vitals тАФ LCP (loading), INP (responsiveness) рдЖрдгрд┐ CLS (visual stability) тАФ рдЖрдгрд┐ рд░реЛрдЬрдЪреЗ рдЙрдкрд╛рдп: рдХрдореА bytes, compression, scripts defer рдХрд░рдгреЗ, рд▓рд╣рд╛рди images, caching headers, рдЖрдгрд┐ images рд╕рд╛рдареА рдЬрд╛рдЧрд╛ рд░рд╛рдЦреВрди рдареЗрд╡рдгреЗ. perf/demo.py рдордзреАрд▓ web() рдЖрдгрд┐ perf/sim.py рдордзреАрд▓ page_load, cls_score, inp_ms рдЖрдгрд┐ rate тАФ simulated page load, рдПрдХ рд╢рд┐рдХрд╡рдгреНрдпрд╛рд╕рд╛рдареАрдЪреЗ model.

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

рдХрддрд░рд┐рдирд╛рдЪреА рдЖрдИ car park рдордзреНрдпреЗ, рдХрдордХреБрд╡рдд signal рдЕрд╕рддрд╛рдирд╛, рддрд┐рдЪреНрдпрд╛ phone рд╡рд░ рдХреНрд░реАрдбрд╛ рджрд┐рдирд╛рдЪреЗ рдирд┐рдХрд╛рд▓рд╛рдЪреЗ page рдЙрдШрдбрддреЗ. ЁЯУ▒

рдРрд╢реНрд╡рд░реНрдпрд╛ page рджреБрд░реБрд╕реНрдд рдХрд░рддреЗ: рддреА text files рджрд╛рдмреВрди рд▓рд╣рд╛рди рдХрд░рддреЗ (compression), рдореЛрдареНрдпрд╛ script рд▓рд╛ рдЖрдкрд▓реА рдкрд╛рд│реА рдпреЗрдИрдкрд░реНрдпрдВрдд рдерд╛рдВрдмрд╛рдпрд▓рд╛ рд╕рд╛рдВрдЧрддреЗ, рд▓рд╣рд╛рди рдлреЛрдЯреЛ рд╡рд╛рдкрд░рддреЗ, рдЖрдгрд┐ рдлреЛрдЯреЛрдЪреА рдЬрд╛рдЧрд╛ рд░рд╛рдЦреВрди рдареЗрд╡рддреЗ рдореНрд╣рдгрдЬреЗ рдХрд╛рд╣реАрдЪ рдЙрдбреА рдорд╛рд░рдд рдирд╛рд╣реА. рдЖрддрд╛ page рд╕реБрдорд╛рд░реЗ 1 рд╕реЗрдХрдВрджрд╛рдд рджрд┐рд╕рддреЗ. рдЖрдгрд┐ рджреБрд╕рд░реА рднреЗрдЯ рдЖрдгрдЦреА рд╡реЗрдЧрд╡рд╛рди рдЕрд╕рддреЗ, рдХрд╛рд░рдг phone рдиреЗ рдорд╛рдЧрдЪреНрдпрд╛ рд╡реЗрд│рдЪреНрдпрд╛ files рдЬрдкреВрди рдареЗрд╡рд▓реНрдпрд╛ рд╣реЛрддреНрдпрд╛.

ЁЯЧ║я╕П рдЖрдХреГрддреА

flowchart LR
    a["as built ┬╖ 2210 KB<br/>LCP 5020 ms (poor)"] --> b["+ compress text<br/>LCP 3505 ms"]
    b --> c["+ defer the script<br/>LCP 3055 ms"]
    c --> d["+ smaller AVIF hero ┬╖ 452.5 KB<br/>LCP 1055 ms (good)"]
    d --> e["repeat visit, cached<br/>LCP 450 ms"]
    cls["CLS 0.1875 тЖТ 0.0<br/>set width/height"]
    inp["INP 316 тЖТ 66 ms<br/>break up the long task"]

ЁЯЧ║я╕П рд░реЗрдЦрд╛рдЯрд▓реЗрд▓реА рдЖрд╡реГрддреНрддреА + рдПрдХ lab: https://school-edh.pages.dev/performance/lesson-diagrams.html#l11

тЭУ рдХрд╛рдп

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг user рдЪрд╛ рд╡реЗрд│ page рд╡рд░рдЪ рдЬрд╛рддреЛ. 2 MB рдЪреНрдпрд╛ page рдорд╛рдЧрдЪреЗ рд╡реЗрдЧрд╡рд╛рди API рдореНрд╣рдгрдЬреЗ рд╣рд│реВ product. рдХрдордХреБрд╡рдд signal рдЕрд╕рд▓реЗрд▓реНрдпрд╛ phone рд╡рд░ рдкреНрд░рддреНрдпреЗрдХ round trip рдЖрдгрд┐ рдкреНрд░рддреНрдпреЗрдХ kilobyte рдорд╣рд╛рдЧ рдЕрд╕рддреЛ (рдкреБрдиреНрд╣рд╛ рдзрдбрд╛ 07 рдЪрд╛ toll), рдЖрдгрд┐ рд▓рд╛рдВрдм script рдореБрд│реЗ рдкреНрд░рддреНрдпреЗрдХ tap рдмрд┐рдШрдбрд▓реНрдпрд╛рд╕рд╛рд░рдЦрд╛ рд╡рд╛рдЯрддреЛ. Search engines рд╕реБрджреНрдзрд╛ ranking рдЪреНрдпрд╛ рдПрдХрд╛ signal рдореНрд╣рдгреВрди рд╣реЗ metrics рд╡рд╛рдкрд░рддрд╛рдд.

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

perf/sim.py рдордзреАрд▓ page_load(...) рд╣реЗ рд╣рд│реВ phone network рд╡рд░рдЪреНрдпрд╛ рдПрдХрд╛ load рдЪреЗ model рдЖрд╣реЗ: connect рдХрд░рд╛рдпрд▓рд╛ 2 round trips (TCP + TLS 1.3) рд▓рд╛рдЧрддрд╛рдд, HTML рд▓рд╛ 1 round trip рдЕрдзрд┐рдХ рддреНрдпрд╛рдЪреЗ bytes, рдордЧ рдкрд╣рд┐рд▓реНрдпрд╛ рдореЛрдареНрдпрд╛ paint рд╕рд╛рдареА рд▓рд╛рдЧрдгрд╛рд░реЗ рд╕рдЧрд│реЗ (CSS, hero image, рдЖрдгрд┐ defer рдХреЗрд▓реЗрд▓рд╛ рдирд╕реЗрд▓ рддрд░ script) bandwidth рд╡рд╛рдЯреВрди parallel download рд╣реЛрддреЗ. рддреЗ рд╕рдВрдкрддреЗ рддреЗрд╡реНрд╣рд╛ LCP. compress=True text 25% рдкрд░реНрдпрдВрдд рд▓рд╣рд╛рди рдХрд░рддреЗ (рдПрдХ рдиреЗрд╣рдореАрдЪреЗ рдкреНрд░рдорд╛рдг тАФ рдПрдХ рдЧреГрд╣реАрддрдХ). рдЦрд▒реНрдпрд╛ browsers рдордзреНрдпреЗ priorities, HTTP/2 рдЖрдгрд┐ HTTP/3 multiplexing рдЖрдгрд┐ TCP slow start рдЕрд╕рддрд╛рдд, рдЬреЗ model рд╕реЛрдбреВрди рджреЗрддреЗ. cls_score, inp_ms рдЖрдгрд┐ rate рд╡рд░реАрд▓ рд╡реНрдпрд╛рдЦреНрдпрд╛ рдЖрдгрд┐ рдорд░реНрдпрд╛рджрд╛ рд▓рд╛рдЧреВ рдХрд░рддрд╛рдд.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

python3 perf/demo.py web
python3 - <<'EOF'
import sys; sys.path.insert(0, "perf"); from sim import page_load, rate
for rtt, bw in ((50, 2000), (150, 500), (300, 150)):
    for label, kw in (("as built", {}), ("all fixes", dict(compress=True, defer_js=True, hero_kb=200))):
        lcp, kb = page_load(rtt_ms=rtt, kb_per_s=bw, **kw)
        print(f"RTT {rtt:>3} ms, {bw:>4} KB/s, {label:<9} тЖТ LCP {lcp:>5} ms ({rate('LCP', lcp)})")
EOF

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

web рд╣реЗ рдЫрд╛рдкрддреЗ:

тФАтФА the sports-day results page on a phone: 150 ms round trip, 500 KB/s (a teaching model of one page load)
   as built: 30 KB HTML, 80 KB CSS, 900 KB JS, 1200 KB hero image   LCP  5020 ms (poor) ┬╖   2210 KB
   + compress text (gzip/Brotli, text to about 25%)                 LCP  3505 ms (needs improvement) ┬╖ 1452.5 KB
   + defer the script (no longer blocks the first paint)            LCP  3055 ms (needs improvement) ┬╖ 1452.5 KB
   + resize the hero and serve AVIF (1200 тЖТ 200 KB)                 LCP  1055 ms (good) ┬╖  452.5 KB
   repeat visit: files cached, the HTML revalidated (304)           LCP   450 ms (good) ┬╖      0 KB
   (hashed files: Cache-Control: max-age=31536000, immutable ┬╖ the HTML: Cache-Control: no-cache)
тФАтФА CLS: an image with no width/height pushes the text down a quarter of the screen тЖТ 0.1875 (needs improvement) ┬╖ with width/height set тЖТ 0.0 (good)
тФАтФА INP: a tap runs 300 ms of script in one piece тЖТ 316 ms (needs improvement) ┬╖ in 50 ms chunks that yield тЖТ 66 ms (good)

рддреБрдордЪрд╛ snippet рд╣реЗ рдЫрд╛рдкрддреЛ:

RTT  50 ms, 2000 KB/s, as built  тЖТ LCP  1305 ms (good)
RTT  50 ms, 2000 KB/s, all fixes тЖТ LCP   314 ms (good)
RTT 150 ms,  500 KB/s, as built  тЖТ LCP  5020 ms (poor)
RTT 150 ms,  500 KB/s, all fixes тЖТ LCP  1055 ms (good)
RTT 300 ms,  150 KB/s, as built  тЖТ LCP 15933 ms (poor)
RTT 300 ms,  150 KB/s, all fixes тЖТ LCP  2717 ms (needs improvement)

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

Developer рдЪреНрдпрд╛ рд╡реЗрдЧрд╡рд╛рди connection рд╡рд░ good (1305 ms) рджрд┐рд╕рдгрд╛рд░реЗ page рдкрд╛рд▓рдХрд╛рдВрдЪреНрдпрд╛ phone рд╡рд░ poor (5020 ms) рдЖрд╣реЗ, рдЖрдгрд┐ рдЦреВрдк рд╣рд│реВ connection рд╡рд░ рдЬрд╡рд│рдЬрд╡рд│ 16 рд╕реЗрдХрдВрдж тАФ рддреБрдордЪреНрдпрд╛ users рдХрдбреЗ рдЕрд╕рд▓реЗрд▓реНрдпрд╛ network рд╡рд░ test рдХрд░рд╛. рд╕рдорд╕реНрдпреЗрдЪрд╛ рдмрд╣реБрддреЗрдХ рднрд╛рдЧ bytes рд╣реЛрддреЗ: compression, defer рдХрд░рдгреЗ рдЖрдгрд┐ рдпреЛрдЧреНрдп рдЖрдХрд╛рд░рд╛рдЪреА image рдпрд╛рдВрдиреА LCP 5020 рд╡рд░реВрди 1055 ms рд╡рд░ рдЖрдгрд▓рд╛, рдЖрдгрд┐ caching рдиреЗ рдкреБрдиреНрд╣рд╛ рднреЗрдЯ 450 ms рд╡рд░ рдЖрдгрд▓реА тАФ рдлрдХреНрдд рддреАрди round trips, bytes рд╢реВрдиреНрдп. CLS shift 0.75 ├Ч 0.25 = 0.1875 рд╣реЛрддрд╛; image рдЪреА рдЬрд╛рдЧрд╛ рд░рд╛рдЦреВрди рдареЗрд╡рд▓реНрдпрд╛рдиреЗ рддреЛ 0 рдЭрд╛рд▓рд╛. 300 ms рдЪреНрдпрд╛ task рдЪреЗ 50 ms рдЪреЗ рддреБрдХрдбреЗ рдХреЗрд▓реНрдпрд╛рдиреЗ page рдиреЗ tap рд▓рд╛ 66 ms рдордзреНрдпреЗ рдЙрддреНрддрд░ рджрд┐рд▓реЗ тАФ рдПрдХреВрдг рдХрд╛рдо рддреЗрд╡рдвреЗрдЪ, рдкрдг page рдордзреЗрдордзреЗ paint рдХрд░реВ рд╢рдХрддреЗ.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

рдЦрд▒реНрдпрд╛ account рд╡рд░ тАФ compression рдЖрдгрд┐ caching headers рддрдкрд╛рд╕рд╛:

curl -sI -H 'Accept-Encoding: br, gzip' https://results.school.example/app.3f9a1c.js | grep -iE 'content-encoding|cache-control|etag'
npx lighthouse https://results.school.example/ --only-categories=performance --form-factor=mobile

рдЙрдкрд╛рдп рд▓рд╛рдЧреВ рдХреЗрд▓реЗрд▓реЗ HTML:

<link rel="stylesheet" href="/style.8c1d2e.css">
<script src="/app.3f9a1c.js" defer></script>
<img src="/hero-800.avif" width="800" height="450" fetchpriority="high" alt="Runners crossing the finish line">

NGINX: text рд╕рд╛рдареА gzip, hashed assets рд╕рд╛рдареА рд▓рд╛рдВрдм caching (Brotli рд▓рд╛ ngx_brotli module рд▓рд╛рдЧрддреЛ):

gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location ~* \.[0-9a-f]{6,}\.(js|css|avif|woff2)$ { add_header Cache-Control "public, max-age=31536000, immutable"; }
location = /index.html { add_header Cache-Control "no-cache"; }

web-vitals library рдиреЗ рдЦрд░реЗ users рдореЛрдЬрд╛, рдЖрдгрд┐ рд▓рд╛рдВрдм tasks рдЪреЗ рддреБрдХрдбреЗ рдХрд░рд╛:

import { onLCP, onINP, onCLS } from 'web-vitals';
[onLCP, onINP, onCLS].forEach(on => on(m => navigator.sendBeacon('/vitals', JSON.stringify(m))));

async function showTimes(rows) {
  for (const chunk of chunks(rows, 200)) {
    render(chunk);
    await (globalThis.scheduler?.yield?.() ?? new Promise(r => setTimeout(r, 0)));   // let the browser paint
  }
}

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: phones рд╕рд╛рдареА p75 рд╡рд░рдЪрд╛ field data рдкрд╛рд╣рдд рд░рд╛рд╣рд╛, рдкреНрд░рддреНрдпреЗрдХ page рд▓рд╛ byte budget рджреНрдпрд╛, рдЖрдгрд┐ рдкреНрд░рддреНрдпреЗрдХ рдирд╡реНрдпрд╛ third-party script рд▓рд╛ рдХрд╛рд░рдг рд▓рд╛рдЧрдгрд╛рд░рд╛ performance рдмрджрд▓ рд╕рдордЬрд╛.

тПня╕П рдкреБрдвреЗ

рдкреНрд░рддреНрдпреЗрдХ рдзрдбреНрдпрд╛рдиреЗ рдХрд╛рд╣реАрддрд░реА рд╡реЗрдЧрд╡рд╛рди рдХреЗрд▓реЗ. рд╢реЗрд╡рдЯрдЪрд╛ рдзрдбрд╛ рддреЗ рд╡реЗрдЧрд╡рд╛рди рд░рд╛рд╣реАрд▓ рдпрд╛рдЪреА рдЦрд╛рддреНрд░реА рдХрд░рддреЛ: load tests, CI рдордзрд▓реЗ performance budgets, рдЖрдгрд┐ рд╕рдВрдкреВрд░реНрдг рдЪрд┐рддреНрд░.

git checkout lesson-12-load-testing-budgets

ЁЯУ▒ Lesson 11 тАФ Web performance: the results page on a parent's phone

ЁЯУН You are here: Lesson 11 of 12 ┬╖ Previous: lesson-10-database-queries ┬╖ Next: lesson-12-load-testing-budgets


ЁЯУж What's in this branch

Lessons 01тАУ10, plus the part of performance the user actually sees: the page in the browser. Google's Core Web Vitals тАФ LCP (loading), INP (responsiveness) and CLS (visual stability) тАФ and the everyday fixes: fewer bytes, compression, deferring scripts, smaller images, caching headers, and reserving space for images. web() in perf/demo.py and page_load, cls_score, inp_ms and rate in perf/sim.py тАФ a simulated page load, a teaching model.

ЁЯзТ Explain like I'm 5

Katrina's mum opens the sports-day results page on her phone, in the car park, with a weak signal. ЁЯУ▒

Aishwarya fixes the page: she squeezes the text files (compression), tells the big script to wait its turn, uses a smaller photo, and reserves the photo's space so nothing jumps. Now the page appears in about 1 second. And the second visit is even faster, because the phone kept the files from last time.

ЁЯЧ║я╕П Diagram

flowchart LR
    a["as built ┬╖ 2210 KB<br/>LCP 5020 ms (poor)"] --> b["+ compress text<br/>LCP 3505 ms"]
    b --> c["+ defer the script<br/>LCP 3055 ms"]
    c --> d["+ smaller AVIF hero ┬╖ 452.5 KB<br/>LCP 1055 ms (good)"]
    d --> e["repeat visit, cached<br/>LCP 450 ms"]
    cls["CLS 0.1875 тЖТ 0.0<br/>set width/height"]
    inp["INP 316 тЖТ 66 ms<br/>break up the long task"]

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/performance/lesson-diagrams.html#l11

тЭУ What

ЁЯдФ Why

Because the page is where the user's time goes. A fast API behind a 2 MB page is a slow product. On a phone with a weak signal, every round trip and every kilobyte is expensive (lesson 07's toll, again), and a long script makes every tap feel broken. Search engines also use these metrics as one ranking signal.

ЁЯФз How (in this repo)

page_load(...) in perf/sim.py is a model of one load on a slow phone network: connecting costs 2 round trips (TCP + TLS 1.3), the HTML costs 1 round trip plus its bytes, then everything needed for the first big paint (CSS, the hero image, and the script unless it is deferred) downloads in parallel, sharing the bandwidth. LCP is when that finishes. compress=True shrinks text to 25% (a typical ratio тАФ an assumption). Real browsers have priorities, HTTP/2 and HTTP/3 multiplexing and TCP slow start, which the model leaves out. cls_score, inp_ms and rate apply the definitions and thresholds above.

ЁЯзк Try it

python3 perf/demo.py web
python3 - <<'EOF'
import sys; sys.path.insert(0, "perf"); from sim import page_load, rate
for rtt, bw in ((50, 2000), (150, 500), (300, 150)):
    for label, kw in (("as built", {}), ("all fixes", dict(compress=True, defer_js=True, hero_kb=200))):
        lcp, kb = page_load(rtt_ms=rtt, kb_per_s=bw, **kw)
        print(f"RTT {rtt:>3} ms, {bw:>4} KB/s, {label:<9} тЖТ LCP {lcp:>5} ms ({rate('LCP', lcp)})")
EOF

тЬЕ Verify тАФ what you should see

web prints:

тФАтФА the sports-day results page on a phone: 150 ms round trip, 500 KB/s (a teaching model of one page load)
   as built: 30 KB HTML, 80 KB CSS, 900 KB JS, 1200 KB hero image   LCP  5020 ms (poor) ┬╖   2210 KB
   + compress text (gzip/Brotli, text to about 25%)                 LCP  3505 ms (needs improvement) ┬╖ 1452.5 KB
   + defer the script (no longer blocks the first paint)            LCP  3055 ms (needs improvement) ┬╖ 1452.5 KB
   + resize the hero and serve AVIF (1200 тЖТ 200 KB)                 LCP  1055 ms (good) ┬╖  452.5 KB
   repeat visit: files cached, the HTML revalidated (304)           LCP   450 ms (good) ┬╖      0 KB
   (hashed files: Cache-Control: max-age=31536000, immutable ┬╖ the HTML: Cache-Control: no-cache)
тФАтФА CLS: an image with no width/height pushes the text down a quarter of the screen тЖТ 0.1875 (needs improvement) ┬╖ with width/height set тЖТ 0.0 (good)
тФАтФА INP: a tap runs 300 ms of script in one piece тЖТ 316 ms (needs improvement) ┬╖ in 50 ms chunks that yield тЖТ 66 ms (good)

Your snippet prints:

RTT  50 ms, 2000 KB/s, as built  тЖТ LCP  1305 ms (good)
RTT  50 ms, 2000 KB/s, all fixes тЖТ LCP   314 ms (good)
RTT 150 ms,  500 KB/s, as built  тЖТ LCP  5020 ms (poor)
RTT 150 ms,  500 KB/s, all fixes тЖТ LCP  1055 ms (good)
RTT 300 ms,  150 KB/s, as built  тЖТ LCP 15933 ms (poor)
RTT 300 ms,  150 KB/s, all fixes тЖТ LCP  2717 ms (needs improvement)

ЁЯПБ What you just proved

The page that looks good (1305 ms) on the developer's fast connection is poor (5020 ms) on a parent's phone, and nearly 16 seconds on a very slow one тАФ test on the network your users have. Bytes were most of the problem: compression, deferring and a right-sized image took LCP from 5020 to 1055 ms, and caching took the repeat visit to 450 ms тАФ all three round trips, no bytes. The CLS shift was 0.75 ├Ч 0.25 = 0.1875; reserving the image's space made it 0. Breaking a 300 ms task into 50 ms chunks let the page answer the tap in 66 ms тАФ the same total work, but the page can paint in between.

тЪая╕П Common mistakes

ЁЯПн In production

On a real account тАФ check compression and caching headers:

curl -sI -H 'Accept-Encoding: br, gzip' https://results.school.example/app.3f9a1c.js | grep -iE 'content-encoding|cache-control|etag'
npx lighthouse https://results.school.example/ --only-categories=performance --form-factor=mobile

The HTML that gets the fixes:

<link rel="stylesheet" href="/style.8c1d2e.css">
<script src="/app.3f9a1c.js" defer></script>
<img src="/hero-800.avif" width="800" height="450" fetchpriority="high" alt="Runners crossing the finish line">

NGINX: gzip for text, long caching for hashed assets (Brotli needs the ngx_brotli module):

gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location ~* \.[0-9a-f]{6,}\.(js|css|avif|woff2)$ { add_header Cache-Control "public, max-age=31536000, immutable"; }
location = /index.html { add_header Cache-Control "no-cache"; }

Measure real users with the web-vitals library, and break up long tasks:

import { onLCP, onINP, onCLS } from 'web-vitals';
[onLCP, onINP, onCLS].forEach(on => on(m => navigator.sendBeacon('/vitals', JSON.stringify(m))));

async function showTimes(rows) {
  for (const chunk of chunks(rows, 200)) {
    render(chunk);
    await (globalThis.scheduler?.yield?.() ?? new Promise(r => setTimeout(r, 0)));   // let the browser paint
  }
}

ЁЯПн Why this matters in production: watch the field data at p75 for phones, give each page a byte budget, and treat every new third-party script as a performance change that needs a reason.

тПня╕П Next

Every lesson made something faster. The last one makes sure it stays fast: load tests, performance budgets in CI, and the whole picture.

git checkout lesson-12-load-testing-budgets
тЖР Previousdatabase queriesNext тЖТload testing budgets

This page is the lesson's README from the lesson-11-web-performance branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.