You shipped the redesign. The product team loves it. Then the dashboards light up: Core Web Vitals “Needs improvement,” conversion dips, and customer support reports “it feels slow” like it’s a single bug you can grep.
This is the part where teams waste weeks polishing the wrong pebble. Core Web Vitals are measurable, but they’re not magical. Treat them like any production SLO: understand what’s actually failing, isolate the bottleneck, and ship the smallest fix that changes the curve.
Core Web Vitals in plain terms (no incense)
Core Web Vitals (CWV) are a small set of user-centric performance metrics. They’re not “Google’s secret sauce.” They’re a standard-ish way to describe: (1) how fast meaningful content appears, (2) how stable the page is while it loads, and (3) how responsive it feels when users try to do something.
LCP: Largest Contentful Paint (loading)
LCP answers: “When did the user see the main thing?” Usually a hero image, a big heading, or the top content block. It’s not “when the spinner starts.” It’s not “DOMContentLoaded.” It’s the largest visible content element in the viewport.
Common LCP killers:
- High TTFB (slow origin, cache misses, expensive SSR, bad CDN config)
- Render-blocking CSS
- Hero images not prioritized (lazy-loaded above the fold, no preload, huge bytes)
- Client-side rendering that delays content until JS executes
- Third-party scripts that steal the main thread early
CLS: Cumulative Layout Shift (visual stability)
CLS answers: “Did the page move under the user’s cursor?” Layout shifts are those annoying jumps when images, ads, or late-loading fonts push content around. CLS is not about animation; it’s about unexpected movement.
Common CLS killers:
- Images/iframes without width/height (or no aspect-ratio)
- Injected banners (cookie consent, promo ribbons) that push content
- Late font swaps that reflow text
- Ads/widgets that resize after load
INP: Interaction to Next Paint (responsiveness)
INP answers: “When the user interacts, how long until the UI updates?” It replaced FID because users don’t care about the first interaction only; they care about the site being responsive for the whole session. INP is sensitive to long tasks, main-thread contention, and expensive event handlers.
Here’s the operational translation: LCP is mostly a delivery pipeline problem (network + critical resources). CLS is mostly a layout discipline problem. INP is mostly a CPU scheduling and code hygiene problem.
One quote, because it holds up in web perf too: Paraphrased idea from John Ousterhout: complexity is the root cause of many software problems; reduce it to improve reliability and performance.
Joke #1: Performance work is like dieting: people swear by weird tricks, but “eat fewer calories” still wins.
What actually moves the needle: a priority stack
Most teams lose time because they treat CWV like a checklist of micro-optimizations. Don’t. Treat it like incident response: identify the dominant constraint, fix it, re-measure, repeat.
1) Fix the critical path before you touch micro-optimizations
If your HTML takes 800ms to arrive, shaving 20ms from a JS bundle is theater. Your critical path is:
- DNS/TCP/TLS (sometimes hidden by CDNs, sometimes not)
- HTML TTFB + HTML size
- Critical CSS and render-blocking resources
- Hero image (priority + bytes)
- Hydration / JS execution (for SPA/SSR hybrids)
Move LCP by fixing the earliest constraint that’s slow.
2) Cache like you mean it (and verify it)
“We use a CDN” is not the same as “our HTML and hero assets are served from cache for real users.” CWV is user-centric; if 40% of users miss cache due to cookies, Vary headers, or geo behavior, your averages will be ugly.
3) Stop lazy-loading above-the-fold images
Lazy-loading is great for below-the-fold. Above-the-fold, it’s frequently self-sabotage: you’re telling the browser “this isn’t important,” and then you wonder why LCP suffers.
4) Make layout stable by design
CLS fixes are often boring: declare dimensions, reserve space, don’t inject layout-affecting UI late. The win is durable and doesn’t depend on user device, network, or luck.
5) Buy down main-thread debt
INP is your “JavaScript is a tax” bill. You pay it in long tasks, heavy frameworks, and third-party scripts. The fix isn’t one trick; it’s ongoing discipline: smaller bundles, fewer observers, less re-render churn, smarter scheduling.
6) Third-party scripts: treat them like production dependencies
Marketing tags, A/B testing, chat widgets, fraud detection: they run on your users’ CPUs, not yours. They can dominate INP and even harm LCP if they run early. Load them later, sandbox them, or delete them. Yes, delete.
Joke #2: The fastest third-party script is the one your legal team already approved to remove.
Facts and history that explain today’s mess
- Fact 1: “DOMContentLoaded” and “onload” became popular because they were easy to measure, not because they matched user perception.
- Fact 2: HTTP/2 changed the “one big bundle vs many small files” trade-off by multiplexing requests, but head-of-line blocking didn’t disappear everywhere; the transport still matters.
- Fact 3: The widespread use of SPAs shifted performance failures from network-bound to CPU-bound: users wait on JS parsing/execution, not just bytes.
- Fact 4: Web fonts used to be a straightforward visual enhancement; now they’re a performance risk because they affect rendering and can trigger layout shifts when swapped.
- Fact 5: The “lazy-load everything” era was a reaction to heavy pages, but it created a new class of bugs: deferring the exact content users came to see.
- Fact 6: Google introduced Web Vitals as a push toward standardized, user-centric metrics; the industry had too many incompatible definitions of “fast.”
- Fact 7: INP replaced FID because optimizing only the first interaction let pages “pass” while still stuttering during real use.
- Fact 8: Layout instability became worse when the ad-tech ecosystem normalized dynamic content insertion; CLS is basically the metricification of user rage.
- Fact 9: RUM (real user monitoring) became critical because lab tests can’t model every device, network, CPU throttle, or extension ecosystem.
Fast diagnosis playbook (first/second/third)
This is the fastest route to the bottleneck when a page fails CWV. The goal: don’t boil the ocean. Find the dominant limiter.
First: decide whether it’s LCP, CLS, or INP (and for which pages)
- Use RUM to identify top failing URLs/templates (not just averages).
- Segment by device class (mobile usually tells the truth first).
- Look at p75, not mean. CWV is scored by percentiles.
Second: for LCP, split into server vs client vs bytes
- TTFB high? Fix caching, origin latency, SSR cost, edge config.
- TTFB fine but LCP high? Look at render-blocking CSS, hero image priority/size, and preloads.
- LCP element is text? Look at font loading behavior and CSS blocking.
- LCP element is an image? Fix format/size, priority hints, caching, and avoid late decode.
Third: for INP, find long tasks and the guilty handlers
- Use traces to identify long tasks > 50ms; look for repeated ones.
- Identify heavy event handlers (click/input/keydown) and rerender storms.
- Audit third-party scripts and timers; measure their main-thread time.
CLS triage: watch for late injections and missing dimensions
- Find the top shift sources; they’re usually a small set of elements.
- Fix by reserving space and avoiding layout-affecting inserts above the fold.
If you can’t answer “what is the LCP element for this template?” within 10 minutes, you’re not doing performance engineering. You’re doing vibes.
Practical tasks: commands, outputs, decisions
These are boring, runnable tasks you can do today. Each one has: a command, sample output, what it means, and the decision to make. You don’t need all of them every day; you need the right ones when you’re stuck.
Task 1: Measure TTFB and caching at the edge with curl
cr0x@server:~$ curl -s -o /dev/null -D - https://www.example.com/ | egrep -i 'HTTP/|cache-control|age|x-cache|server-timing|vary'
HTTP/2 200
cache-control: public, max-age=0, s-maxage=600
age: 512
x-cache: HIT
server-timing: cdn-cache;desc=HIT, edge;dur=12, origin;dur=0
vary: Accept-Encoding
What it means: age: 512 and x-cache: HIT suggest the response is served from cache. server-timing indicates no origin time.
Decision: If you’re seeing MISS for real traffic, fix cache keys (cookies, Vary headers, query params) and edge TTLs before touching JS.
Task 2: Check time to first byte precisely with curl timings
cr0x@server:~$ curl -s -o /dev/null -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://www.example.com/
dns=0.012 connect=0.045 tls=0.089 ttfb=0.312 total=0.428
What it means: TTFB is 312ms. Total is 428ms. Network is not the main villain here.
Decision: If TTFB > ~800ms on cache hits, you likely have edge/origin latency or HTML generation overhead. Fix that first.
Task 3: Identify render-blocking resources with Lighthouse CI (headless)
cr0x@server:~$ npx lighthouse https://www.example.com/ --quiet --chrome-flags="--headless" --only-categories=performance --output=json --output-path=./lh.json
...Saved JSON report to ./lh.json...
cr0x@server:~$ jq '.audits["render-blocking-resources"].details.items[] | {url, totalBytes, wastedMs}' lh.json | head
{
"url": "https://www.example.com/assets/app.css",
"totalBytes": 184322,
"wastedMs": 410
}
{
"url": "https://www.example.com/assets/vendor.js",
"totalBytes": 912443,
"wastedMs": 280
}
What it means: CSS is big and blocking; vendor JS is also blocking (likely via sync scripts or preload misuse).
Decision: Inline critical CSS, split non-critical CSS, and ensure JS is deferred/async appropriately. Don’t “minify harder” and call it done.
Task 4: Confirm the LCP element and its request chain (trace via Chrome DevTools Protocol)
cr0x@server:~$ npx chrome-har-capturer --url https://www.example.com/ --output ./page.har
Saved HAR to ./page.har
cr0x@server:~$ jq '.log.entries[] | select(.response.content.mimeType|test("image|text/html|text/css")) | {url: .request.url, status: .response.status, size: .response.content.size, wait: .timings.wait}' page.har | head
{
"url": "https://www.example.com/",
"status": 200,
"size": 62310,
"wait": 180
}
{
"url": "https://www.example.com/assets/app.css",
"status": 200,
"size": 184322,
"wait": 92
}
{
"url": "https://www.example.com/images/hero.jpg",
"status": 200,
"size": 1452200,
"wait": 210
}
What it means: The hero image is 1.45MB and waits 210ms before first byte. That’s a classic LCP anchor.
Decision: Convert hero to AVIF/WebP, resize, serve responsive variants, and ensure it’s requested early (no lazy-load, consider preload).
Task 5: Inspect cacheability and compression of the hero image
cr0x@server:~$ curl -s -I https://www.example.com/images/hero.jpg | egrep -i 'content-type|content-length|cache-control|etag|accept-ranges|content-encoding'
content-type: image/jpeg
content-length: 1452200
cache-control: public, max-age=3600
etag: "a9d1-5f2c9d3f"
accept-ranges: bytes
What it means: It’s JPEG, big, and cached for an hour. Caching is okay; encoding is not.
Decision: Ship modern formats and smaller dimensions. Cache won’t save first-time visitors.
Task 6: Verify HTML is not accidentally uncacheable due to cookies/Vary
cr0x@server:~$ curl -s -I https://www.example.com/ | egrep -i 'set-cookie|vary|cache-control'
cache-control: private, no-store
set-cookie: session=...; Path=/; Secure; HttpOnly
vary: Cookie
What it means: You’ve told every cache on earth to step aside. This is a TTFB tax on every user.
Decision: Separate personalized content from cacheable shell. Avoid Vary: Cookie on HTML unless you’re ready to pay for it.
Task 7: Find long tasks in a local trace captured with Chromium
cr0x@server:~$ chromium --headless --disable-gpu --trace-startup --trace-startup-file=./trace.json https://www.example.com/
[0204/090312.112233:INFO:headless_shell.cc(661)] Written trace file to ./trace.json
cr0x@server:~$ jq '[.. | objects | select(has("dur") and has("name")) | select(.dur > 50000) | {name, dur, cat}] | sort_by(.dur) | reverse | .[0:5]' trace.json
[
{
"name": "EvaluateScript",
"dur": 182334,
"cat": "devtools.timeline"
},
{
"name": "FunctionCall",
"dur": 93422,
"cat": "devtools.timeline"
}
]
What it means: You have >50ms long tasks, especially script evaluation. This is INP territory.
Decision: Reduce JS shipped/executed early. Split bundles, remove dead code, and delay non-critical third-party scripts.
Task 8: Quantify JS/CSS bytes by route using build artifacts
cr0x@server:~$ ls -lh dist/assets | egrep '\.js$|\.css$' | head
-rw-r--r-- 1 cr0x cr0x 912K Feb 4 09:01 vendor-9a12c.js
-rw-r--r-- 1 cr0x cr0x 286K Feb 4 09:01 app-1b22f.js
-rw-r--r-- 1 cr0x cr0x 181K Feb 4 09:01 app-4aa2.css
What it means: Vendor chunk is huge. That often correlates with parse/compile time on mid-tier phones.
Decision: Audit dependencies. If you’re shipping three date libraries and two state managers, pick one and delete the rest.
Task 9: Detect unused CSS in a page (quick-and-dirty with coverage in Puppeteer)
cr0x@server:~$ node -e '
const puppeteer=require("puppeteer");
(async()=>{
const b=await puppeteer.launch({headless:"new"});
const p=await b.newPage();
await p.coverage.startCSSCoverage();
await p.goto("https://www.example.com/",{waitUntil:"networkidle2"});
const cov=await p.coverage.stopCSSCoverage();
let used=0,total=0;
for (const c of cov){ total+=c.text.length; used+=c.ranges.reduce((s,r)=>s+(r.end-r.start),0); }
console.log(`css_used=${(used/1024).toFixed(1)}KB css_total=${(total/1024).toFixed(1)}KB used_pct=${(used/total*100).toFixed(1)}%`);
await b.close();
})();'
css_used=28.4KB css_total=412.7KB used_pct=6.9%
What it means: You’re shipping a winter coat to the beach. 93% of CSS is unused for this route.
Decision: Route-split CSS, purge unused styles, and avoid global frameworks loaded everywhere “just in case.”
Task 10: Verify font loading behavior and whether it risks CLS
cr0x@server:~$ curl -s -I https://www.example.com/assets/fonts/brand.woff2 | egrep -i 'content-type|cache-control|timing-allow-origin'
content-type: font/woff2
cache-control: public, max-age=31536000, immutable
timing-allow-origin: *
What it means: Font is cache-friendly and exposes timing data. Good hygiene.
Decision: If CLS is still high, check for missing font-display and fallback metric mismatch; consider a system font stack for critical UI text.
Task 11: Find third-party script bloat in requests
cr0x@server:~$ jq -r '.log.entries[].request.url' page.har | egrep -i 'goog|doubleclick|segment|mixpanel|hotjar|optimizely|datadog|newrelic' | sort | uniq | head
https://cdn.segment.com/analytics.js/v1/...
https://www.googletagmanager.com/gtm.js?id=GTM-...
What it means: You have third-party dependencies that can execute early and often.
Decision: Move them behind consent and/or after LCP. If the business insists, at least make them non-blocking and delay initialization.
Task 12: Check server-side compression and HTML size
cr0x@server:~$ curl -s -H 'Accept-Encoding: gzip, br' -I https://www.example.com/ | egrep -i 'content-encoding|content-type|content-length'
content-type: text/html; charset=utf-8
content-encoding: br
content-length: 24132
What it means: Brotli is on and HTML is ~24KB compressed. That’s fine.
Decision: If you don’t see compression, enable it at the edge/origin; if HTML is huge, stop inlining JSON state dumps into the page.
Task 13: Validate that your CDN is serving correct image variants by Accept header
cr0x@server:~$ curl -s -I -H 'Accept: image/avif,image/webp,image/*,*/*;q=0.8' https://www.example.com/images/hero | egrep -i 'content-type|vary|cache-control'
content-type: image/avif
vary: Accept
cache-control: public, max-age=31536000, immutable
What it means: You serve AVIF when the client supports it, and you vary correctly on Accept.
Decision: If you always serve JPEG/PNG, you’re paying a bandwidth tax that directly hits LCP on mobile.
Task 14: Check for accidental no-cache on static assets
cr0x@server:~$ curl -s -I https://www.example.com/assets/app-1b22f.js | egrep -i 'cache-control|etag'
cache-control: public, max-age=31536000, immutable
etag: "a1b2c3"
What it means: Good: fingerprinted assets cached long-term.
Decision: If you see no-cache on fingerprinted assets, fix it immediately; you’re forcing repeat downloads and burning INP via extra parse work.
Three corporate mini-stories from the performance trenches
Mini-story 1: The incident caused by a wrong assumption (“CDN means fast”)
A consumer app team rolled out a personalization feature on their landing page. It was tasteful: “Welcome back,” plus a few recommended items. They already had a CDN, so they assumed the impact would be negligible. The change passed unit tests, end-to-end tests, and the synthetic performance check on a fast office connection.
Two days later, the CWV report for mobile tanked. Support tickets described “white page” and “tap doesn’t respond.” Product assumed it was a JavaScript regression. The frontend team started shaving bundle size. The backend team started profiling APIs. Everyone was busy; nobody was effective.
The SRE on call did the boring thing: curl -I on the homepage and looked at headers. The HTML was now Cache-Control: private, no-store, plus Vary: Cookie. The personalization code touched session state early, which caused the framework to mark the entire response uncacheable. The CDN wasn’t “slow”; it was bypassed. Every request hit origin, did SSR work, and the long tail of mobile users paid the full price.
The fix wasn’t heroic. They separated the page into a cacheable shell and a small client-fetched personalized block that loaded after first paint. They also trimmed cookies, because the request headers were getting large enough to matter on constrained networks.
The lesson is sharp: assumptions about caching are not architecture. Headers are architecture. Verify cache behavior with real requests, not vibes.
Mini-story 2: The optimization that backfired (lazy-loading the hero)
A B2B dashboard had strong desktop CWV but poor mobile LCP on the marketing pages. The team decided to “lazy-load more images” because it was an easy win and the web is full of advice that sounds like it was written by someone who never shipped a site with a hero banner.
They set loading="lazy" on all images globally through a component wrapper. It looked fine in local dev. In staging, synthetic tests showed lower total bytes early. The change shipped.
Within a week, LCP got worse. The LCP element was the hero image, and the browser now deprioritized it. The image request started later, decoded later, and the main content arrived later in user-perceived terms. Conversion dropped slightly, not enough to trigger alarms, but enough that marketing started running “site feels slow” meetings.
When they finally traced it, it was obvious: the hero image was delayed by design. They reverted lazy-loading for above-the-fold images, added responsive sources, and used preload hints selectively. The net effect was fewer bytes and earlier paint—because they prioritized the right bytes.
The lesson: the browser is good at prioritization when you don’t lie to it. Lazy-load is not a virtue; it’s a tool. Use it where it belongs.
Mini-story 3: The boring but correct practice that saved the day (budgets and canaries)
A fintech company had a performance culture that wasn’t glamorous. They had budgets per route: maximum JS bytes, maximum CSS bytes, and a “no new third-party scripts without review” rule. Engineers complained occasionally. Then they forgot about it, which is the highest compliment for a control mechanism.
One quarter, a new analytics vendor was introduced. The vendor’s snippet was small, but it pulled in a larger library and started doing heavy work on user interactions. On staging, nobody noticed; staging traffic and devices weren’t representative, and the app was already “fast enough” in lab tests.
The deployment pipeline ran a canary in production with RUM gating. The canary showed INP regression concentrated on mid-tier Android devices and on the checkout route. The rollout paused automatically. No drama, no blame. The team had a crisp diff: “INP up; new third-party script loaded before interaction.”
They worked with the vendor integration: delayed initialization until after the page became idle, and limited tracking to routes where it actually mattered. They also sandboxed it behind consent. The canary passed; the rollout resumed.
The lesson: boring guardrails beat heroic postmortems. Budgets and canaries are not “process.” They’re a system that prevents you from shipping latency to customers.
Common mistakes: symptom → root cause → fix
1) Symptom: LCP is bad only on first visit
Root cause: Assets cache well, but the critical hero image is too large or not served in modern formats; first-time visitors pay full download/decode cost.
Fix: Use responsive images and AVIF/WebP; ensure the hero is requested early; verify cache headers and CDN compression where applicable.
2) Symptom: LCP is bad and TTFB is also high
Root cause: Origin latency, SSR slowness, cache bypass via cookies or personalization, or a misconfigured CDN.
Fix: Make HTML cacheable; move personalization to edge-includes or client fetch after paint; profile SSR; fix cache keys; reduce Vary and cookie bloat.
3) Symptom: CLS spikes on pages with ads or consent banners
Root cause: Late DOM insertion above the fold; ad slots resize after load; banner pushes content down.
Fix: Reserve space (fixed containers or min-height), render placeholders, avoid inserting above-the-fold layout changes after first paint.
4) Symptom: CLS is small in lab but high in RUM
Root cause: Real-user variability: different viewport sizes trigger different wrapping; fonts swap differently; third-party widgets behave inconsistently.
Fix: Use RUM to identify shift sources; test with varied viewports; apply explicit dimensions and stable font fallbacks; constrain third-party containers.
5) Symptom: INP is bad on “simple” pages
Root cause: Third-party scripts or global listeners doing work on every interaction; heavy hydration; long tasks from framework runtime.
Fix: Delay third-party initialization; remove unused listeners; split hydration; move non-critical work to idle callbacks; reduce JS shipped.
6) Symptom: Improving bundle size didn’t improve INP
Root cause: The problem is not download size; it’s execution patterns (re-renders, layout thrash, heavy handlers) or a single expensive interaction path.
Fix: Trace interactions; find long tasks; fix rerender storms; reduce synchronous work in handlers; use batching and memoization carefully.
7) Symptom: LCP regresses after adding “preload everything”
Root cause: Priority inversion: preloading too many resources competes with the actual LCP resource.
Fix: Preload only the true critical resources (usually one hero, one font if needed, critical CSS). Validate with network priority in traces.
8) Symptom: Mobile is much worse than desktop across all metrics
Root cause: CPU-bound JS and heavy layout work; desktop hides it with brute force.
Fix: Test with CPU throttling and mid-tier device profiles; reduce JS execution; break long tasks; simplify UI and DOM.
Checklists / step-by-step plan
Step-by-step plan for an LCP rescue (one template at a time)
- Identify LCP element from RUM (or trace) for that template. If you can’t name it, stop and find it.
- Check TTFB on cache hit and miss. If cache hit is slow, fix edge/origin before frontend work.
- Audit hero bytes: format, dimensions, compression, and cache headers.
- Ensure priority: avoid lazy-load above the fold; preload hero when appropriate; don’t drown it with other preloads.
- Reduce render blocking: critical CSS inline/split; defer non-critical JS.
- Re-measure with RUM at p75 on mobile. Ship, then verify. Don’t stop at “lab looks better.”
Step-by-step plan for CLS stabilization
- List top shifting elements using RUM or DevTools “Layout Shift Regions.”
- Reserve space for images/iframes/ads with width/height or
aspect-ratio. - Stop late inserts above the fold or render them in a reserved slot from the start.
- Fix fonts: ensure sensible fallback metrics; use
font-displaystrategy that doesn’t cause reflow surprises. - Re-test across viewports because wrapping differences can create “CLS only on certain screens.”
Step-by-step plan for INP improvements that stick
- Trace a bad interaction (click/input) on a mid-tier profile and find the longest tasks.
- Identify the owner: your code vs third-party vs framework runtime.
- Break long tasks and move work off the critical interaction path.
- Reduce rerenders: avoid state updates that trigger large component trees; virtualize big lists.
- Defer non-critical scripts and stop doing analytics work synchronously on input events.
- Put a budget on long tasks and on route bundle size, then enforce it in CI/canary.
Operational checklist: keep gains from regressing
- RUM dashboards by template and device class, alerting on p75 regressions.
- Performance budgets for JS/CSS bytes and third-party additions.
- Canary rollouts with automatic rollback/pause on CWV regression.
- Regular dependency pruning (quarterly is fine; weekly is fantasy).
- Cache header tests in CI for critical routes and assets.
FAQ
1) Should I optimize lab scores or RUM?
RUM for truth, lab for debugging. Lab tests are controlled; they’re great for catching obvious regressions and for isolating causes. RUM tells you what customers actually experience, including slow devices and weird networks.
2) Why does my Lighthouse score change every run?
Because performance is a distributed system: network jitter, CPU scheduling, cache state, and third-party behavior vary. Use multiple runs, look at distributions, and focus on repeatable bottlenecks like huge hero images, blocking CSS, and long tasks.
3) Is SPA inherently worse for CWV?
Not inherently, but it’s easier to mess up. SPAs often delay meaningful content until JS runs, which hurts LCP and can hurt INP. SSR/streaming and selective hydration can close the gap, but only if you keep the critical path lean.
4) Do I need to inline all CSS?
No. Inline critical CSS for above-the-fold, split the rest, and avoid shipping the entire design system on every route. Inlining everything can inflate HTML and delay first byte on slow connections.
5) Are preloads always good?
No. Preload the one or two resources that truly define LCP (often a hero image and maybe a font). Over-preloading competes for bandwidth and can delay the actual critical resource.
6) How do fonts affect CWV?
Fonts can delay text rendering (hurting perceived load) and can cause layout shifts when swapped (hurting CLS). Cache fonts aggressively, limit variants, and ensure fallback metrics don’t cause big reflows.
7) What’s the fastest “big win” for CLS?
Give everything dimensions. Images, iframes, ad slots. Reserve space for banners. The fastest CLS fixes look like housekeeping because they are.
8) What’s the fastest “big win” for INP?
Remove or delay third-party scripts that run on interaction and break up long tasks. Also: stop doing heavy work synchronously in input handlers. If you must do analytics, buffer it.
9) Why does mobile look disproportionately bad?
Because mobile CPUs and radios are slower, and memory pressure changes everything. Desktop hides a lot of sins. If you only test on a dev laptop, you’re benchmarking the wrong machine.
10) If my backend is fast, can I ignore TTFB?
No. TTFB includes network, TLS, CDN behavior, edge routing, and cache misses. Backend latency might be fine while users still wait because you accidentally made HTML uncacheable or routed traffic poorly.
Next steps you can ship this week
Don’t start with “optimize everything.” Start with one failing template and get a measurable win at p75 mobile.
- Pick the worst high-traffic template from RUM (not the homepage by tradition).
- Run the fast diagnosis playbook and identify whether TTFB, render-blocking, hero bytes, or long tasks dominate.
- Ship one LCP fix: make HTML cacheable, prioritize the hero, or reduce hero bytes. Verify via headers and RUM.
- Ship one CLS fix: reserve space for the top shift source. Verify CLS drops in RUM.
- Ship one INP fix: remove/delay one third-party script or break one long task path. Verify interaction latency improves.
- Add guardrails: budgets, canary gating, and a weekly “what did we add?” dependency review.
The honest secret: the best CWV work looks like reliability work. Measure, isolate, fix, verify, and prevent regressions. If you’re doing clever tricks without a before/after curve, you’re not tuning performance—you’re collecting charms.