Performance work has two halves: measuring the right thing (Core Web Vitals from real users, backed by lab tests to find causes) and controlling what the browser and caches do (HTTP caching headers, resource hints, image and font markup). This sheet covers both. Official thresholds come from Google’s web.dev documentation; anything that is guidance rather than an official threshold is labeled.
Placeholders: EXAMPLE.COM is your domain, /PATH/hero.webp and similar paths are your own files, and CDN.EXAMPLE.COM is a third-party or CDN origin. Check headers on any URL with curl -sI https://EXAMPLE.COM/ (see CS-08).
Core Web Vitals
| Metric |
Measures |
Good |
Needs improvement |
Poor |
| LCP (Largest Contentful Paint) |
Loading: when the largest image or text block in the viewport is rendered |
2.5 s or less |
Over 2.5 s up to 4.0 s |
Over 4.0 s |
| INP (Interaction to Next Paint) |
Responsiveness: delay from a click, tap or key press to the next frame, across the visit (close to the worst interaction) |
200 ms or less |
Over 200 ms up to 500 ms |
Over 500 ms |
| CLS (Cumulative Layout Shift) |
Visual stability: unexpected movement of content, scored by size and distance of shifts |
0.1 or less |
Over 0.1 up to 0.25 |
Over 0.25 |
| Rule |
Detail |
| 75th percentile |
A page passes a metric when at least 75% of page views meet the “good” threshold, assessed separately for mobile and desktop. |
| All three must pass |
The Core Web Vitals assessment in PageSpeed Insights and Search Console needs all three metrics to be good. |
| INP replaced FID |
INP became a Core Web Vital in March 2024, replacing First Input Delay. FID only measured the delay before the first interaction was handled. |
| CLS windows |
Shifts are grouped in session windows (up to 5 s, with gaps under 1 s). CLS is the worst window, not the total of the whole visit. Shifts within 500 ms of a user input do not count. |
What affects each metric
| Metric |
Common causes |
Typical fixes |
| LCP |
Slow server response (TTFB), LCP image discovered late (CSS background, lazy-loaded, injected by JavaScript), large unoptimized image, render-blocking CSS and JS, web fonts hiding text, client-side rendering |
Page caching and a CDN, fetchpriority="high" on the LCP image, never lazy-load it, preload if it is a CSS background, serve AVIF or WebP at the right size, inline critical CSS, defer non-critical JS |
| LCP breakdown |
web.dev splits LCP into four parts: TTFB, resource load delay, resource load duration and element render delay |
Find the biggest part in Chrome DevTools (Performance panel) or PageSpeed Insights and fix that one first |
| INP |
Long JavaScript tasks on the main thread (over 50 ms), heavy third-party scripts (chat, tag managers, ads), large DOM size, expensive event handlers, layout thrashing |
Remove or delay third-party scripts, break up long tasks (yield to the main thread), reduce DOM size, avoid running work on every scroll or input event |
| INP breakdown |
Input delay, processing duration and presentation delay |
Input delay means the main thread was busy; processing means slow handlers; presentation means heavy rendering afterward |
| CLS |
Images, videos and iframes without dimensions, ads and embeds without reserved space, banners and cookie notices injected above content, web fonts swapping with different metrics, animations of layout properties |
width and height attributes or CSS aspect-ratio, reserve space with min-height, overlay banners instead of pushing content, font fallback metric overrides, animate transform and opacity only |
Field data vs lab data
| Compare |
Field (real users, RUM) |
Lab (synthetic) |
| Source |
Chrome UX Report (CrUX) or your own real-user monitoring |
Lighthouse, PageSpeed Insights lab section, WebPageTest, DevTools |
| Devices and networks |
Your actual visitors |
One simulated device and connection |
| Measures INP? |
Yes |
No: there is no real user interacting. Use TBT as a proxy. |
| Used for Google’s page experience assessment |
Yes (CrUX) |
No |
| Speed of feedback |
Slow: CrUX is a rolling 28-day window, so a fix takes up to four weeks to show fully |
Immediate |
| Best for |
Knowing whether you have a problem |
Finding the cause and testing a fix |
| Tool |
Shows |
| PageSpeed Insights |
CrUX field data for the URL and the origin (if there is enough traffic), plus a Lighthouse lab run |
| Search Console, Core Web Vitals report |
CrUX data grouped by similar URLs, for the whole property |
| CrUX API, CrUX BigQuery dataset |
Raw field data for scripts and history |
| Chrome DevTools, Performance panel |
Live LCP, INP and CLS as you use the page, plus traces to find causes |
web-vitals JavaScript library |
Collect the same metrics from your own visitors and send them to your analytics |
CrUX only includes Chrome users who have opted in to sharing usage statistics, and only URLs and origins with enough traffic. A low-traffic site may have no field data at all; PageSpeed Insights then shows only the lab run.
Supporting metrics
| Metric |
Measures |
Target |
Status of target |
| TTFB (Time to First Byte) |
From navigation start to the first byte of the HTML response, including redirects, DNS, connection and TLS |
Good 0.8 s or less, poor over 1.8 s |
Guidance from web.dev (“a rough guide”), not a Core Web Vital |
| FCP (First Contentful Paint) |
When the first text or image is painted |
Good 1.8 s or less, poor over 3.0 s |
web.dev threshold at the 75th percentile, not a Core Web Vital |
| TBT (Total Blocking Time) |
Lab only: total time between FCP and Time to Interactive where long tasks blocked the main thread (the part of each task over 50 ms) |
Lighthouse shows 0 to 200 ms as green on mobile (150 ms on desktop) |
Lighthouse scoring band, lab proxy for INP |
| Speed Index |
Lab only: how quickly the visible area fills in, from a video of the load |
Lighthouse shows 0 to 3.4 s as green on mobile (1.3 s on desktop) |
Lighthouse scoring band |
The Lighthouse score (0 to 100) is a weighted blend of lab metrics and changes between Lighthouse versions. It is a diagnostic aid, not the Core Web Vitals assessment, and a page can score well in the lab and still fail in the field (or the reverse).
HTTP caching: Cache-Control
Cache-Control (defined in RFC 9111) tells browsers and shared caches (CDNs, proxies) whether and for how long to keep a response. Directives are comma-separated: Cache-Control: public, max-age=31536000, immutable.
| Directive |
Meaning |
Typical use |
public |
Any cache may store it, even if the request had an Authorization header |
Static assets |
private |
Only the user’s browser may store it, not shared caches |
Pages personalized for a logged-in user |
no-cache |
Caches may store it but must revalidate with the server before every use. It does not mean “do not cache”. |
HTML that must always be current but can use 304 responses |
no-store |
Do not store at all |
Account pages, checkout, sensitive data |
max-age=N |
Fresh for N seconds from the time of the response |
31536000 (one year) for versioned assets |
s-maxage=N |
Like max-age but only for shared caches, overriding max-age there |
Let the CDN keep HTML for minutes while browsers revalidate |
stale-while-revalidate=N |
After expiry, a cache may serve the stale copy for up to N more seconds while it fetches a fresh one in the background (RFC 5861) |
Fast responses for content that can be slightly out of date |
stale-if-error=N |
May serve a stale copy for N seconds if the origin returns an error or cannot be reached (RFC 5861) |
Resilience during origin outages, where the cache supports it |
immutable |
The response will never change while fresh, so the browser should not revalidate it even on reload (RFC 8246) |
Files with a version or hash in the URL |
must-revalidate |
Once stale, it must not be used without successful revalidation (no serving stale on error) |
Content where stale data would be wrong |
proxy-revalidate |
Same as must-revalidate, for shared caches only |
Rare |
no-transform |
Intermediaries must not modify the body (for example recompress images) |
When a proxy’s optimization breaks files |
Recommended patterns
| Resource |
Header |
Versioned CSS, JS, fonts, images (style.css?ver=6.8, app.3f9a1c.js) |
Cache-Control: public, max-age=31536000, immutable |
| Unversioned images and media |
Cache-Control: public, max-age=86400 (pick a lifetime you can live with: a changed file keeps its old copy in browsers until it expires) |
| Public HTML pages behind a CDN |
Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=60, or rely on the CDN’s own HTML cache rules |
| Logged-in or personalized HTML |
Cache-Control: private, no-cache |
| Checkout, account, admin |
Cache-Control: no-store |
Validators and Vary
| Header |
Role |
ETag: "abc123" |
An identifier for this version of the resource. The browser sends it back as If-None-Match; if unchanged, the server answers 304 Not Modified with no body. |
ETag: W/"abc123" |
Weak validator: the content is equivalent but not necessarily byte-identical. |
Last-Modified |
Date the resource last changed. The browser sends If-Modified-Since; the server can answer 304. ETag takes precedence when both are present. |
Age: 120 |
Seconds the response has been in a shared cache. A rising Age on repeat requests proves the CDN is caching. |
Expires |
Older absolute expiry date. Ignored when Cache-Control: max-age is present. |
Vary: Accept-Encoding |
Keep separate cached copies per compression (gzip, br, none). Needed whenever compression is on. |
Vary: Cookie or Vary: User-Agent |
One copy per distinct value, which can make a cache almost useless. Avoid on public pages. |
Vary: Origin |
Needed when CORS responses differ by requesting origin. |
Multiple servers behind a load balancer can produce different ETags for the same file (for example when the ETag includes an inode number), which breaks revalidation. Use content-based ETags or Last-Modified in that case.
Cloudflare cache status (cf-cache-status)
| Value |
Meaning |
What to do |
HIT |
Served from Cloudflare’s cache without contacting the origin |
Working as intended |
MISS |
Not in this data center’s cache, fetched from the origin (and stored if cacheable) |
Request again: it should become HIT. Each data center has its own cache. |
EXPIRED |
Was cached, but had expired, so it was fetched again from the origin |
Consider a longer edge TTL |
STALE |
An expired copy was served because the origin could not be reached or returned an error |
Investigate the origin |
BYPASS |
Eligible for caching, but caching was skipped: the origin sent no-cache, private or max-age=0, a rule said bypass, or the origin set a cookie |
Check origin headers and Set-Cookie on that URL |
DYNAMIC |
Not eligible for caching under the current settings. By default Cloudflare does not cache HTML, so normal pages show DYNAMIC. |
Add a Cache Rule if you want HTML cached (and exclude logged-in users, carts and admin) |
REVALIDATED |
The stale copy was revalidated with the origin (for example with a 304) and served |
Normal |
UPDATING |
A stale copy was served while the cache fetched a fresh one in the background |
Normal, especially with stale-while-revalidate behavior |
Other CDNs and caches use their own headers, often X-Cache: HIT or MISS, plus Age. Server page caches add headers such as X-LiteSpeed-Cache: hit. Check the provider’s documentation for exact values.
Resource hints and priority
| Hint |
Example |
Use for |
Caution |
preload |
<link rel="preload" href="/PATH/hero.webp" as="image" fetchpriority="high"> |
Critical resources the browser finds late: a CSS background LCP image, a font referenced only in CSS |
Needs a correct as value or the file may download twice. Every preload competes with other downloads, so keep the list short. An unused preload triggers a console warning. |
| Preload a font |
<link rel="preload" href="/PATH/font.woff2" as="font" type="font/woff2" crossorigin> |
The main text font |
crossorigin is required even for same-origin fonts, because fonts are fetched in CORS mode. |
| Preload a responsive image |
<link rel="preload" as="image" imagesrcset="hero-800.webp 800w, hero-1600.webp 1600w" imagesizes="100vw"> |
Responsive LCP images |
Must match the srcset and sizes on the image itself. |
preconnect |
<link rel="preconnect" href="https://CDN.EXAMPLE.COM"> |
Opens DNS, TCP and TLS early for a third-party origin you will use soon |
Limit to a few critical origins. Add crossorigin for origins fetched in CORS mode (fonts). |
dns-prefetch |
<link rel="dns-prefetch" href="https://CDN.EXAMPLE.COM"> |
Cheaper than preconnect: DNS lookup only |
Useful for less critical origins. |
modulepreload |
<link rel="modulepreload" href="/PATH/app.js"> |
ES modules: fetches, parses and compiles the module ahead of use |
For type="module" scripts only. |
prefetch |
<link rel="prefetch" href="/next-page/"> |
Low-priority fetch of something likely needed for the next navigation |
Wastes data if the guess is wrong. |
fetchpriority |
<img src="/PATH/hero.webp" fetchpriority="high" alt=""> |
Raise the LCP image; lower off-screen carousel images with low. Values: high, low, auto. |
A hint, not a command. Works on img, link and script elements. |
| HTTP Link header |
Link: </PATH/style.css>; rel=preload; as=style |
Send hints in the response headers, or in a 103 Early Hints response where the server and CDN support it |
Same rules as the HTML version. |
Images
| Format |
Best for |
Notes |
| AVIF |
Photos and complex images at the smallest size |
Supports transparency and HDR. Slower to encode. Supported in all current major browsers. |
| WebP |
Photos and graphics, lossy or lossless |
Transparency and animation. Very broad support. WordPress accepts WebP uploads and, since 6.5, AVIF if the server’s image library supports it. |
| JPEG |
Photos, maximum compatibility |
No transparency. Progressive JPEG renders in passes. |
| PNG |
Screenshots, graphics needing lossless quality |
Large for photos. |
| SVG |
Logos, icons, illustrations |
Scales to any size. Text-based, so compress it. Uploading SVGs needs sanitizing because they can contain scripts. |
| GIF |
Legacy animation |
Replace with a muted, looping <video> or animated WebP for a fraction of the size. |
srcset and sizes
<img src="/PATH/photo-800.jpg"
srcset="/PATH/photo-400.jpg 400w,
/PATH/photo-800.jpg 800w,
/PATH/photo-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 600px"
width="1600" height="1067"
alt="DESCRIPTION"
loading="lazy" decoding="async">
| Part |
Meaning |
400w |
Width descriptor: the file’s real pixel width. The browser picks a file using the slot width and the screen’s pixel density. |
sizes |
How wide the image will display, as media conditions checked in order; the last value is the default. Without it, the browser assumes 100vw and often downloads too large a file. |
1x, 2x |
Density descriptors, for fixed-size images: srcset="logo.png 1x, logo@2x.png 2x". Do not mix with w in one srcset. |
width and height |
Intrinsic size, so the browser reserves the right aspect ratio before the file loads (prevents CLS). Pair with CSS height: auto. |
loading="lazy" |
Defers off-screen images. Never on the LCP image or anything above the fold. WordPress skips lazy loading on the first content images it detects. |
sizes="auto" |
For lazy-loaded images, lets the browser use the laid-out width. Supported in Chromium-based browsers at the time of writing; other browsers fall back to the rest of the sizes list. WordPress 6.7 and later prepends auto to sizes on lazy-loaded images. |
decoding="async" |
Lets the browser decode the image off the critical path. |
Serving modern formats with fallback:
<picture>
<source type="image/avif" srcset="/PATH/hero.avif">
<source type="image/webp" srcset="/PATH/hero.webp">
<img src="/PATH/hero.jpg" width="1600" height="900" alt="DESCRIPTION" fetchpriority="high">
</picture>
Web fonts: font-display
Set in the @font-face rule. The block period hides text (invisible fallback); the swap period shows fallback text and swaps in the web font when it arrives.
| Value |
Block period |
Swap period |
Result |
auto |
Browser decides |
Browser decides |
Usually behaves like block |
block |
Short (typically 3 s) |
Infinite |
Invisible text first, which hurts FCP and LCP. Suits icon fonts. |
swap |
Extremely small |
Infinite |
Text shows at once in the fallback, then swaps. Good for LCP, can cause layout shift. |
fallback |
Extremely small (around 100 ms) |
Short (around 3 s) |
If the font is late, the fallback stays for that page view. |
optional |
Extremely small |
None |
Uses the web font only if it is already available (or arrives almost instantly). Best for CLS; the font may not appear on the first visit. |
Reduce font-swap layout shift with fallback metric overrides (size-adjust, ascent-override, descent-override, line-gap-override) on a local fallback @font-face. Self-host WOFF2 files and subset them to the characters you use to cut their size.
Common recipes
| Goal |
Steps |
| Find the LCP element |
PageSpeed Insights diagnostics (“Largest Contentful Paint element”), or the DevTools Performance panel live metrics |
| Speed up a hero image LCP |
Use an img (not a CSS background), add fetchpriority="high", remove loading="lazy", serve AVIF or WebP with srcset, make sure the page is cached so TTFB is low |
| Prove caching works |
curl -sI URL | grep -iE 'cache-control|age|cf-cache-status|x-cache|etag' twice; expect a HIT and a rising Age on the second |
| Find what causes CLS |
DevTools Performance panel layout shift markers, or the PageSpeed Insights “Avoid large layout shifts” diagnostic; then add dimensions or reserved space |
| Improve INP on a WordPress site |
Audit third-party and plugin scripts in the DevTools Performance panel, delay non-essential scripts until interaction, remove unused plugins that add front-end JS |
| Check a fix is working in the field |
Watch the PageSpeed Insights URL data or the Search Console report over the following 28 days; use the web-vitals library for faster feedback |
Gotchas
| Problem |
Explanation |
| Lighthouse score is good but Core Web Vitals fail |
The lab test uses one device and network; real visitors may be on slower phones. Field data decides the assessment. |
| A fix does not show in PageSpeed Insights field data |
CrUX is a 28-day rolling window. The lab section updates immediately; the field section changes gradually. |
no-cache pages still cached |
Correct behavior: no-cache means revalidate, not “do not store”. Use no-store to forbid storage. |
| CDN always shows BYPASS on pages |
WordPress or a plugin sets a cookie (sessions, carts, consent) or sends no-cache headers on every page. |
| Long max-age on unversioned files |
Visitors keep the old file until it expires. Version file names or query strings before using a one-year lifetime. |
| Preloading everything |
Preloads compete with the LCP image and CSS. Preload only what the browser would otherwise discover late. |
| Lazy-loading the hero image |
Delays LCP because the browser waits for layout before fetching it. Remove loading="lazy" above the fold. |
Font preload without crossorigin |
The preloaded copy is not reused and the font downloads twice. |
Missing Vary: Accept-Encoding |
A cache can serve a compressed response to a client that cannot decode it. |