dotvitals

Website Speed Test

See what real Chrome visitors experience, and which bytes on one page load are worth removing.

Try

How to fix these

One section per finding above. There is nothing to copy here — these are changes in your own configuration, so each one names where the change is made, what it can break, and how to check it worked.

Speed up the largest thing on the screen

Largest Contentful Paint is the moment the main thing on the page — usually the hero image or the headline — finishes appearing. Google's threshold for a good experience is 2.5 seconds at the 75th percentile, meaning three visits in four; past 4 seconds the visit is counted as poor. This is the measurement people mean when they say a site is slow, and it is where visitors leave before anything useful is on screen.

Who makes this change: You — this is a change on your own site. Everything that makes LCP slow is under your control: your markup, your images, your server and your CDN. The one part you do not control is how fast the number moves once fixed, which is Chrome's collection window.

Page HTML — the page markup and its images

Where: The template that renders the hero area, and the image files it references.

  1. Find out which element it is. We do not report it — our lab run measures render-blocking resources, image weight and server response time, not which element painted last. Chrome DevTools does, under Performance beside the LCP marker, and so does PageSpeed Insights for the same URL.
  2. If it is an image, never lazy-load it. `loading="lazy"` on the LCP image is the single most common cause of a poor LCP, because the browser deliberately delays a resource you need immediately.
  3. Give it `fetchpriority="high"` and preload it: `<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">` in the head, so it is discovered with the document rather than after the CSS has been parsed.
  4. Serve it at the size it is displayed and in AVIF or WebP. A 2 MB photograph scaled down by the browser costs the visitor the full 2 MB.
  5. Always set `width` and `height` on it. They cost nothing, and without them the same image also causes layout shift — one omission failing two separate checks.
  6. Take render-blocking CSS and synchronous scripts off the critical path. They delay the paint itself, not only the download, so an already-downloaded image still waits behind them.

If it goes wrong: Each of these is a per-element attribute change. Removing the attribute restores the previous behaviour exactly; the only one with a visible side effect is preloading, which wastes bandwidth if you preload something the page does not use — the browser warns about that in the console.

Cloudflare — served through a CDN

Where: Caching → Configuration and Speed → Optimization in the Cloudflare dashboard for this zone.

  1. Confirm the image is actually being cached at the edge rather than fetched from your origin on every visit: look at the `cf-cache-status` response header, where `HIT` is what you want and `DYNAMIC` means it is not being cached at all.
  2. Where the HTML itself is dynamic, the image can still be cached aggressively — it is a different URL with a different policy, and giving static assets a long lifetime is the change that most often moves this number.
  3. Be careful with automatic optimisation features. They can help, and they change the bytes your visitors receive, so enable one at a time and measure rather than turning on everything at once.
  4. Check where your origin is relative to your visitors. A CDN hit is fast from anywhere; a miss still has to cross the distance to your origin, and that shows up in the quarter of visits this metric is about.

If it goes wrong: Every setting here is a toggle that reverts in the dashboard, but a cached response does not: purge the cache for the affected URLs after reverting, or visitors keep receiving the version produced under the old setting until it expires.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell how this page is built or served

Where: The template that renders the top of the page, and whatever serves its images — this check does not detect your stack.

  1. Start by splitting the time. If the server's response is already a large share of the total, no front-end change can win that back; fix the server first and see the separate server-response section of this report.
  2. Then identify the LCP element in Chrome DevTools under Performance, or in PageSpeed Insights for the same URL. Everything after this step depends on knowing which element it is.
  3. If it is an image: stop lazy-loading it, set an explicit width and height, serve it at display size in a modern format, and preload it.
  4. If it is text: the font is usually the cause. Preload the font file and use `font-display: swap` so text paints in a fallback face instead of waiting.
  5. Remove render-blocking resources from the critical path in either case — they delay the paint regardless of what is being painted.

If it goes wrong: These are individually revertible markup changes; none of them alters what the page contains, only when the browser is told about it.

Check it worked:

  • Open Chrome DevTools → Performance, reload the page, and read the LCP marker: it should now be under 2.5 seconds on a throttled profile
  • curl -sSI https://<host>/<the LCP image> | grep -iE 'content-type|content-length' — confirm a modern format and a sensible size
  • Run PageSpeed Insights against the same URL for a second lab opinion on Google's own hardware

Test again re-runs this check immediately, but the number it reports comes from the Chrome UX Report's trailing 28-day window of real visits — so it will not move until the fix has been in front of your visitors for most of a month. Nothing is wrong if it is unchanged tomorrow. Use the lab section of this report, which re-measures on the spot, to confirm the change actually landed.

The 2.5 s and 4 s boundaries are Google's published thresholds, not ours, and the 75th percentile is Google's choice of statistic. A site with a fast median can still fail here — that is deliberate, because the slowest quarter of visits are real people.

web.dev — Largest Contentful Paint (LCP) · web.dev — Defining the Core Web Vitals metrics thresholds · web.dev — Optimize resource loading and the critical rendering path

Make the page respond when people tap it

Interaction to Next Paint measures the whole time from a tap, click or keypress to the next frame the browser paints. Google's good threshold is 200 ms and poor begins above 500 ms. This is what makes a site feel broken rather than merely slow: the visitor presses a button, nothing visible happens, and they press it again. INP replaced First Input Delay as a Core Web Vital in March 2024 and is a much harder test, because it measures every interaction in the visit rather than only the first.

Who makes this change: You — this is a change on your own site. Your own JavaScript and the third-party scripts you chose to include. A slow interaction caused by an analytics or chat widget is still yours in the sense that only you can remove it.

Page HTML — your own JavaScript

Where: The event handlers on the slow interaction, and whatever runs on the main thread around them.

  1. Record the interaction. In Chrome DevTools, open Performance, start recording, perform the slow action, and look for main-thread tasks over 50 ms — the tap is queued behind those.
  2. Paint the response first and compute afterwards. Update the visible state in the handler, yield to the browser, then do the expensive work: `await scheduler.yield()` in Chromium, or `await new Promise(r => setTimeout(r, 0))` where that is unavailable.
  3. Break long work into pieces with a yield between them, so the browser gets a chance to paint between chunks rather than after all of it.
  4. Move work that does not need the main thread off it — a Web Worker for parsing or computation keeps the interface answering while it runs.
  5. Check what runs on page load as well: a long task still executing when the visitor taps is what creates input delay, and it is often initialisation rather than the handler itself.

If it goes wrong: Yielding changes the order work happens in, which can expose a latent assumption that two steps run in the same frame. Keep the changes small and test the interaction's result, not only its speed; reverting is a matter of removing the yield.

WordPress — plugins and third-party scripts

Where: The active plugin list in wp-admin, and the tag manager or consent tool loading scripts on this page.

  1. List what is actually loading: in DevTools, Network → JS, sorted by size. Analytics, tag managers, chat widgets, consent banners and A/B testing tools are disproportionately represented in long-task traces.
  2. Deactivate one plugin at a time on a staging copy and re-measure the interaction. Guessing which plugin is responsible is slower than measuring it, and the answer is frequently not the obvious one.
  3. Where a script must stay, load it after the critical path rather than in the head, and check whether the vendor offers an asynchronous or deferred snippet — most do.
  4. Beware page builders that attach handlers to every element. The fix there is usually a lighter template for the page, not a setting.

If it goes wrong: Reactivate the plugin. Deactivating a plugin can remove functionality or shortcodes the page depends on, so do this on a staging copy first and check the page still renders what it should before repeating it in production.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell what this page runs

Where: Whatever JavaScript handles the slow interaction — this check does not detect your stack.

  1. Find the slow interaction first. INP is the worst interaction in a visit, not an average, so there is usually one specific control responsible.
  2. Record it in DevTools Performance and look at the three parts: input delay (the main thread was busy when the user acted), processing (your handler ran long), and presentation (rendering the result was expensive). The fix differs for each.
  3. Cut the amount of JavaScript the page parses and executes at all. The separate JavaScript section of this report measures how much is shipped and how much of it goes unused.
  4. Remove or defer third-party scripts before optimising your own — they are frequently the larger share and the easier removal.

If it goes wrong: Deferring a script changes when it runs, which can break code that assumed it had already run. Test the page's behaviour, not only its timing, and restore the original loading order if something depends on it.

Check it worked:

  • In Chrome DevTools → Performance, record the slowest interaction and confirm no main-thread task exceeds 50 ms
  • In DevTools, the Performance panel's interaction track reports the INP of the recorded interaction directly

Test again re-runs this check immediately, but the number it reports comes from the Chrome UX Report's trailing 28-day window of real visits — so it will not move until the fix has been in front of your visitors for most of a month. Nothing is wrong if it is unchanged tomorrow. Use the lab section of this report, which re-measures on the spot, to confirm the change actually landed.

web.dev — Interaction to Next Paint (INP) · web.dev — Defining the Core Web Vitals metrics thresholds · web.dev — Optimize long tasks

Stop the page moving while it loads

Cumulative Layout Shift scores how much content moves after it has already appeared. Google's good threshold is 0.1 and poor begins above 0.25. It is what makes a visitor click the wrong thing: they reach for a link, an image or an advert loads above it, and the link moves out from under their finger. The score is the largest burst of shifting during the visit, not a lifetime total, so one bad moment is enough.

Who makes this change: You — this is a change on your own site. Your markup, your stylesheets and your advertising code. An advert that expands the page is the ad network's content but your layout's problem, and reserving space for it is a change only you can make.

Page HTML — markup and stylesheets

Where: The templates that emit images, embeds and advert containers, and the stylesheet that sizes them.

  1. Give every `<img>`, `<video>` and `<iframe>` explicit `width` and `height` attributes, or a CSS `aspect-ratio`. The browser reserves the correct box before the file arrives, which is the whole fix for most sites.
  2. Those attributes still work with responsive CSS: with `img { max-width: 100%; height: auto; }` the browser uses them only to compute the aspect ratio, which is exactly what reserves the space.
  3. Reserve the exact dimensions of every advert slot in the layout. An empty container of the right height causes no shift; an advert that expands the page causes a large one.
  4. Never insert content above existing content after load. Cookie banners and notification bars belong in an overlay, or their height must be reserved from the first paint.
  5. Animate with `transform` and `opacity` rather than with `top`, `height` or `margin`, which trigger layout and are counted as shifts.
  6. Load fonts with `font-display: optional`, or `swap` plus a fallback matched with `size-adjust`, so text does not re-flow when the web font arrives.

If it goes wrong: Reserving space changes the layout on purpose: a wrong aspect ratio leaves a visible gap or crops the image's box. Check the page at phone and desktop widths after the change; reverting is removing the attributes you added.

WordPress — themes, page builders and ad plugins

Where: The theme's image templates, and the settings of whichever plugin injects adverts or embeds.

  1. Check whether the theme strips image dimensions. WordPress emits `width` and `height` by default; a theme or an optimisation plugin that removes them is a common cause of this finding.
  2. In the ad plugin, set a fixed container size for each slot rather than letting the advert size the container.
  3. Disable 'lazy load everything' settings that apply to above-the-fold images: a lazily loaded image that arrives late shifts the page as well as delaying the paint.
  4. Re-check after clearing the cache, since a cached copy of the page keeps the old markup.

If it goes wrong: All of these are settings that revert in the same panel. Changing a slot's reserved size is visible immediately, so check the page after each change rather than at the end.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell how this page is built

Where: Whatever emits the elements that arrive late — images, embeds, adverts, banners.

  1. See the shifts rather than guess at them: in Chrome DevTools, Performance → enable 'Layout Shift Regions' under Rendering, then reload. Every region that flashes after the first paint is a shift being counted.
  2. Work down the list. The causes are a short list and they repeat on nearly every site: images without dimensions, adverts and embeds in unreserved space, late-arriving banners, and fonts that swap and re-flow the text.
  3. Reserve space for each one, in the component that emits it rather than on this page alone.
  4. Re-record after each change. CLS is a burst score, so removing the largest shift can drop the number below the threshold on its own.

If it goes wrong: Each reservation is a local style or attribute change, revertible independently. Verify the visual result at more than one screen width before considering it done.

Check it worked:

  • In Chrome DevTools → Rendering, enable Layout Shift Regions and reload: nothing should flash after the first paint
  • In DevTools → Performance, record a load and read the Layout Shift track for the remaining contributors

Test again re-runs this check immediately, but the number it reports comes from the Chrome UX Report's trailing 28-day window of real visits — so it will not move until the fix has been in front of your visitors for most of a month. Nothing is wrong if it is unchanged tomorrow. Use the lab section of this report, which re-measures on the spot, to confirm the change actually landed.

web.dev — Cumulative Layout Shift (CLS) · web.dev — Defining the Core Web Vitals metrics thresholds

Get the first byte out faster

Time to First Byte is how long a visitor waits before the server sends anything at all. Google's guidance is 800 ms, with anything over 1.8 s counted as poor. It is not itself a Core Web Vital, but every other timing on the page starts after it, so time spent here cannot be recovered by any amount of front-end work. The field number includes redirects, DNS, connection setup and TLS from the visitor's own network, which is why it is routinely much worse than a synthetic test from a data centre.

Who makes this change: You — this is a change on your own site. Your origin server, your application code and your CDN. Where the site is on managed hosting, the origin's speed is partly the host's — but the redirects, the caching policy and the application's own work are still yours.

nginx — the origin server

Where: The server and location blocks under /etc/nginx/, and the application behind them.

  1. Remove redirects on entry first. A hop from `http://` to `https://` to `www.` costs a full round trip each and sits inside this number — the redirect section of this report lists the chain.
  2. Measure where the time goes before changing anything: `curl -sSo /dev/null -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} redirects=%{num_redirects}\n' https://<host>/`. That separates network cost from application cost.
  3. If the application is the cost, profile it there — slow database queries, uncached template rendering and synchronous calls to third-party APIs are the usual three.
  4. Enable HTTP/2 (`listen 443 ssl; http2 on;` on modern builds) and keep the certificate chain short, so connection setup is not part of the wait.
  5. Cache the HTML at the edge where the page allows it. A document served from a CDN point of presence removes the origin from the critical path entirely.
  6. Test the configuration before it is live: `sudo nginx -t && sudo systemctl reload nginx`. A reload replaces workers gracefully; a restart drops connections.

If it goes wrong: Copy each file before editing (`sudo cp site.conf{,.bak}`) and restore it with the same command followed by `nginx -t` and a reload. `nginx -t` refuses to reload a file with a syntax error, so the running configuration survives a bad edit — but read its output, because a failed test means your change silently did not apply.

WordPress — the application's own work

Where: The plugin list, the object cache, and the page-cache plugin's settings.

  1. Turn on full-page caching so a request for an anonymous visitor does not run PHP or touch the database at all. This is normally the single largest change available on a WordPress site.
  2. Add a persistent object cache (Redis or Memcached) if the host supports one, so repeated queries within a request are not repeated across requests.
  3. Find the slow plugin with Query Monitor or your host's profiler rather than by intuition; one plugin making an external HTTP request during page generation can account for the whole number.
  4. Check that the cache is actually being hit for anonymous visitors — a session cookie set on every visitor defeats page caching entirely, and consent tools sometimes do exactly that.

If it goes wrong: A page cache serving stale or wrong-user content is the risk here, not an outage: purge the cache and disable the plugin to return to the previous behaviour immediately. Test a logged-in view and a form submission before leaving it on.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Cloudflare — cached at the edge

Where: Caching → Configuration, and the Cache Rules page, in the Cloudflare dashboard for this zone.

  1. Check what is happening now with the `cf-cache-status` response header. `DYNAMIC` means the HTML is not being cached and every visit reaches your origin.
  2. Where pages are the same for every anonymous visitor, add a cache rule for them on the Cache Rules page (Create rule). Exclude anything personalised, and exclude the paths that set or read a session cookie.
  3. Turn on Tiered Cache so a miss is filled from another Cloudflare location rather than from your origin where possible.
  4. Remember that a cached HTML page is a real trade: a visitor can receive a version that is minutes old. Decide the maximum staleness you accept per path rather than site-wide.

If it goes wrong: Disable or delete the cache rule, then purge the cache for the affected paths. Until the purge, visitors continue to receive whatever the rule caused to be stored — reverting the rule alone is not enough.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell what serves this site

Where: The origin server and whatever sits in front of it — this check does not detect your stack.

  1. Split the time before changing anything: `curl -sSo /dev/null -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} redirects=%{num_redirects}\n' https://<host>/`.
  2. A large `num_redirects` is the cheapest fix on this list: send visitors to the final URL directly.
  3. A large gap between `time_appconnect` and `time_starttransfer` is the application thinking. That is where to profile.
  4. A large `time_connect` from where your visitors are, but not from where you are, is geography: a CDN or a second region is the answer, not faster code.
  5. Compare your result against the field number in this report. Ours is one request from a data centre and is the optimistic end of what real visitors experience.

If it goes wrong: Measurement changes nothing. Whichever fix the measurement points at, make it in one place at a time so a regression is attributable — and keep the previous configuration file.

Check it worked:

  • curl -sSo /dev/null -w 'ttfb=%{time_starttransfer}s redirects=%{num_redirects}\n' https://<host>/ — a synthetic lower bound, not what visitors see
  • Repeat the same command from a network far from your origin, or from a hosted shell in another region, to approximate a distant visitor

Test again re-runs this check immediately, but the number it reports comes from the Chrome UX Report's trailing 28-day window of real visits — so it will not move until the fix has been in front of your visitors for most of a month. Nothing is wrong if it is unchanged tomorrow. Use the lab section of this report, which re-measures on the spot, to confirm the change actually landed.

Our own synthetic measurement of your server is a single well-connected request and will normally be much faster than the field number above it. Where the two disagree, the field number is the one your visitors live with.

web.dev — Time to First Byte (TTFB) · nginx — ngx_http_headers_module (expires, add_header)

Paint something sooner

First Contentful Paint is the moment the browser draws the first text or image — not the main content, which is measured separately, but the first sign of life. Google's good threshold is 1.8 s and poor begins above 3 s. A blank screen is where visitors decide the site is broken and press back, so getting anything painted early buys patience for the rest.

Who makes this change: You — this is a change on your own site. Split between your server and your page's critical path. The gap between your first byte and your first paint is what the critical path costs you, and that part is entirely in your markup.

Page HTML — the critical rendering path

Where: The `<head>` of the page: stylesheets, scripts, fonts and preconnects.

  1. Compare this number with the server-response number first. If they are close, the server is the whole story and nothing in the head will help until that is fixed.
  2. Take scripts out of the parser's way: `defer` on every `<script>` in the head that does not have to run before parsing continues. `type="module"` defers by default. Use `async` only for genuinely independent scripts, since it can still interrupt the parser when it arrives.
  3. Inline the small amount of CSS that the above-the-fold content needs, and load the rest without blocking rendering.
  4. Split stylesheets by media where they apply conditionally: a `media="print"` sheet, or one whose media query does not match, does not block rendering.
  5. Preconnect to the origins the critical resources come from, so DNS, connection and TLS are not serialised after the browser discovers the resource.
  6. Self-host or preload web fonts and set `font-display: swap`, so text is painted in a fallback face rather than waiting for the download.

If it goes wrong: Deferring a script changes when it runs and can break code that assumed it had already run — inline scripts that call into a deferred library are the usual casualty. Change one script at a time and check the page works, not only that it paints.

nginx — what the server sends first

Where: The server block serving the document, under /etc/nginx/.

  1. Turn on compression for text responses if it is not already on; the document itself is usually the first thing that has to arrive in full.
  2. Serve HTTP/2 or HTTP/3 so the stylesheet and the document are not queued behind each other on one connection.
  3. Avoid a redirect on the entry URL. Every hop is a full round trip before the first byte of the real document.
  4. Test and reload rather than restart: `sudo nginx -t && sudo systemctl reload nginx`.

If it goes wrong: Keep a copy of the file before editing and restore it with `nginx -t` followed by a reload. Compression is safe to turn off again; a broken configuration file is refused by `nginx -t` before it can take the site down.

We could not tell how this page is served

Where: The document's `<head>` and whatever serves it — this check does not detect your stack.

  1. Find out how much of this is server time: if the first byte already accounts for most of it, that is the fix, and the rest of these steps will not move the number.
  2. Then look at what blocks the first paint. The render-blocking section of this report lists the specific stylesheets and scripts that did.
  3. Remove or defer each one, starting with the largest, and re-measure after each change.
  4. Check fonts last: a page that paints quickly in a fallback face and swaps later is measured as fast; a page that waits for the font is not.

If it goes wrong: All of these are reversible markup or configuration edits. Keep the previous version of any file you change in the server's configuration.

Check it worked:

  • In Chrome DevTools → Performance, reload and read the FCP marker
  • Check the render-blocking section of this report has cleared after the change

Test again re-runs this check immediately, but the number it reports comes from the Chrome UX Report's trailing 28-day window of real visits — so it will not move until the fix has been in front of your visitors for most of a month. Nothing is wrong if it is unchanged tomorrow. Use the lab section of this report, which re-measures on the spot, to confirm the change actually landed.

web.dev — First Contentful Paint (FCP) · web.dev — Defining the Core Web Vitals metrics thresholds · web.dev — Optimize resource loading and the critical rendering path

Take stylesheets and scripts off the critical path

A render-blocking resource stops the browser drawing anything until it has been downloaded and processed. While that happens the visitor sees a blank page, however fast your server was. We report this when the blocking resources cost at least 100 milliseconds between them — below that the change is not worth your afternoon, and we would rather not raise a finding you cannot feel.

Who makes this change: You — this is a change on your own site. The `<head>` of your own page. Where the blocking resource belongs to a third party — a tag manager, a font service, a chat widget — the loading decision is still yours.

Page HTML — your own markup

Where: The `<link>` and `<script>` elements in the document's `<head>`.

  1. Add `defer` to every `<script>` in the head that does not have to run before parsing continues; `type="module"` defers by default. Reserve `async` for genuinely independent scripts, since an async script can still interrupt the parser when it arrives.
  2. Inline the CSS the above-the-fold content needs and load the rest without blocking. The usual pattern is a `<link rel="preload" as="style">` switched to a stylesheet once loaded, with a `<noscript>` fallback.
  3. Split conditional stylesheets by media. `media="print"`, and any media query that does not match the current device, does not block rendering.
  4. Delete what is unused. On most sites the majority of a framework's CSS bundle is not used by any given page; DevTools' Coverage panel measures it per file.
  5. Move third-party widgets — chat, analytics, tag managers — off the critical path entirely. They are frequently the blocking resources and almost never need to run before the first paint.

If it goes wrong: A deferred script runs later, so anything that depended on it having already run will break — an inline `<script>` calling a deferred library is the classic case. Change one at a time and test the page's behaviour. Inlining critical CSS can also leave a flash of unstyled content if the split is wrong; reverting is restoring the original `<link>`.

WordPress — the theme and its plugins

Where: `wp_enqueue_script`/`wp_enqueue_style` calls in the theme, and any optimisation plugin's settings.

  1. Find what is enqueued and by whom before changing anything — a plugin that loads its stylesheet on every page usually offers a setting to load it only where its shortcode appears.
  2. Use an optimisation plugin's defer setting rather than editing the theme, where one is installed; it applies the same change in a supported way.
  3. Exclude the scripts that must run early from the defer list. jQuery and consent tools are the two that most often break when deferred.
  4. Clear every cache after the change, then load the page as a logged-out visitor — an optimisation plugin's output is often bypassed for administrators, so the page you see is not what the check sees.

If it goes wrong: Turn the plugin setting off; it rewrites the output rather than the theme, so the previous markup returns immediately after a cache purge. Test a form and a logged-in page before leaving it on.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell how this page is built

Where: Whichever component writes the document head — this check does not detect your stack.

  1. Read the list in this finding: it names the specific files that blocked rendering and what each cost.
  2. For each stylesheet, decide whether the page needs it before the first paint. Most do not, and those can be loaded without blocking.
  3. For each script, add `defer` unless it must run during parsing. Almost nothing must.
  4. Re-run the check after each change rather than after all of them, so a regression is attributable to one edit.

If it goes wrong: Every change here is a per-element attribute or a load order, revertible individually and with no effect on the page's content.

Check it worked:

  • In Chrome DevTools → Network, filter to Doc, CSS and JS and confirm nothing before the first paint is still marked as blocking
  • Re-run this check: the finding clears when the remaining blocking cost is under 100 ms

Test again re-runs the lab measurement now, so this finding should clear on the next run once the fix is deployed. It is a single page load: a small change in the number between runs is noise, and the resource facts beside it — transfer sizes, which files blocked rendering — are the stable part to judge by.

web.dev — Optimize resource loading and the critical rendering path · web.dev — First Contentful Paint (FCP)

Serve images at the size and in the format the visitor needs

Images are usually the largest thing a page downloads, and two kinds of waste are easy to fix. An image served much larger than it is displayed makes the visitor pay for pixels they never see — we report it when at least 50 KB could be saved. An image in JPEG or PNG where AVIF or WebP would do costs the same way, reported at the same 50 KB floor. Neither changes what the page looks like when fixed properly; both change how long it takes to arrive. The alt text on these images is a separate matter and is reported by the SEO check.

Who makes this change: You — this is a change on your own site. Your own images, your own markup, and whichever pipeline produces them. Where images are uploaded by other people, the fix belongs in that pipeline rather than in an audit of what is already there.

Page HTML — markup and the image pipeline

Where: The template that emits `<img>` elements, and the build step or image service that produces the files.

  1. Generate several widths of each image and offer them with `srcset`, using `sizes` to tell the browser how wide the image will be at each breakpoint. Without `sizes` the browser assumes full viewport width and picks too large a file.
  2. Cap the largest variant at roughly twice the maximum displayed CSS width. Past about 2× device pixel ratio the difference is not visible and the bytes are wasted.
  3. Offer AVIF and WebP through `<picture>` and `<source type>`, and keep the original as the `<img>` fallback — a `<picture>` with only AVIF sources renders nothing where AVIF is unsupported. Both formats are supported by well over 95% of browsers today, with Opera Mini the notable exception for both.
  4. Set `width` and `height` on every image. It costs nothing and prevents the layout shift that is reported separately.
  5. Remember background images. A CSS `background-image` is not covered by `srcset` and needs `image-set()` or a media query — and if it conveys meaning rather than decoration, it should be an `<img>` instead, so that it can carry alt text at all.

If it goes wrong: Converting a format changes the picture: AVIF at a given quality number is not the same image as JPEG at that number. Keep the originals, compare once side by side, and settle on a default — an over-compressed hero image is a worse outcome than a slow one.

WordPress — the media library and an image plugin

Where: The media library's alt-text field, and the settings of whichever image optimisation plugin is installed.

  1. WordPress generates several sizes automatically and emits `srcset`; if this finding persists, a theme or page builder is usually emitting a fixed full-size URL instead. Fix it in the template that does.
  2. Use an optimisation plugin or an image CDN to serve AVIF or WebP with a fallback, rather than converting the library by hand — hand-conversion does not survive the next upload.
  3. Check the registered image sizes against how images are actually displayed. A theme that displays a 400-pixel thumbnail from the 2048-pixel original is the most common cause of the oversized finding.

If it goes wrong: An optimisation plugin that rewrites images can usually restore the originals, but only if you kept them — check that the plugin's 'keep originals' setting is on *before* running a bulk conversion. That one is not reversible afterwards.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell how these images are produced

Where: The template emitting the `<img>` elements, and whatever stores or serves the files.

  1. Read the list in this finding: it names the specific images and what each would save.
  2. For each one, compare the file's real dimensions with the size it is displayed at — DevTools shows both when you hover over an image in the Elements panel.
  3. Resize at the source and serve the right variant per breakpoint, rather than scaling in CSS.
  4. Convert the largest offenders to AVIF or WebP first; the top few images usually account for most of the saving.
  5. If your CDN or image service supports format negotiation on the `Accept` header, turn it on — that converts everything at once, including images added later.

If it goes wrong: Keep the original files. Re-uploading them restores the previous state; a lossy conversion performed in place does not, which is why the originals matter more than the settings.

Check it worked:

  • curl -sSI -H 'Accept: image/avif,image/webp,*/*' https://<host>/path/to/image — check the content-type that comes back
  • In Chrome DevTools → Network → Img, compare each transferred size against the rendered size in the Elements panel
  • Re-run this check: each finding clears when its remaining saving falls below 50 KB

Test again re-runs the lab measurement now, so this finding should clear on the next run once the fix is deployed. It is a single page load: a small change in the number between runs is noise, and the resource facts beside it — transfer sizes, which files blocked rendering — are the stable part to judge by.

Both findings are reported against a 50 KB floor of our own, so halving the waste on a page that started at 60 KB clears the finding without the images being right. The list in the finding names the individual files and what each would save; that is the number to work from rather than the presence or absence of the finding.

MDN — Responsive images

Compress text responses

Text compresses extremely well — a stylesheet or a script typically drops by two thirds — and every byte saved is a byte the visitor does not wait for. We report this when at least 10 KB could be saved across the page's subresources. Compression is applied by whatever serves the file, so the setting lives in your web server, your CDN or your object store rather than in the page.

Who makes this change: You — this is a change on your own site. Whoever serves the assets. That may be you, your CDN, or an object store you upload to; the paths in this finding tell you which, because they say where the files come from.

nginx — nginx

Where: The `http`, `server` or `location` block under /etc/nginx/ that serves these files.

  1. Turn gzip on and list the types: `gzip on;` plus `gzip_types text/css application/javascript application/json image/svg+xml;`. `text/html` is always compressed by nginx and does not need listing.
  2. `gzip_types` **replaces** the default rather than adding to it, so list every type you want compressed in one directive — a second directive at an inner level silently drops the outer list.
  3. Set `gzip_vary on;` so caches store the compressed and uncompressed variants separately. Without it, a shared cache can hand a compressed body to a client that did not ask for one.
  4. Leave `gzip_min_length` at its default of 20 bytes or raise it modestly; compressing a 100-byte response costs more than it saves.
  5. Do not compress images, video or WOFF2 fonts. They are already compressed, and the second pass costs CPU for nothing.
  6. For Brotli you need the third-party `ngx_brotli` module, which is not part of nginx: `brotli on; brotli_types …;` once built in. Check your build first — and note the module's public repository has had no upstream commit since May 2024, so treat it as stable rather than actively maintained.
  7. Test and reload: `sudo nginx -t && sudo systemctl reload nginx`.

If it goes wrong: Copy the file before editing and restore it with `nginx -t` and a reload. `nginx -t` refuses a file with a syntax error, so a typo cannot take the site down — but compressing a response type that should not be compressed can corrupt downloads, so change one `gzip_types` list at a time and fetch one file of each type afterwards.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Apache — Apache httpd

Where: The virtual host or `.htaccess` for the directory these files are served from.

  1. Enable the module (`sudo a2enmod deflate` on Debian-derived systems) and add: `AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript`.
  2. For Brotli, `sudo a2enmod brotli` and `AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/xml text/css text/javascript application/javascript`. Apache ships mod_brotli with 2.4; unlike nginx, no third-party module is needed.
  3. Exclude already-compressed files where you use `SetOutputFilter` for a whole directory: `SetEnvIfNoCase Request_URI "\.(?:gif|jpe?g|png)$" no-gzip`.
  4. Check the configuration before restarting: `sudo apachectl configtest`, then `sudo systemctl reload apache2` (or `httpd`).

If it goes wrong: `apachectl configtest` catches a syntax error before a reload; keep the previous file and restore it the same way. If compression breaks a download, `a2dismod deflate` (or removing the filter line) returns to uncompressed responses immediately.

Caddy — Caddy

Where: The site block in your Caddyfile.

  1. Add `encode` to the site block. On its own it enables zstd and gzip with Caddy's default set of compressible content types, which already covers `text/*`, JavaScript, JSON and SVG.
  2. Write `encode zstd gzip` if you want to be explicit about the order. That order is only a tie-break for clients that express no preference — a client's own q-values win.
  3. Use `minimum_length` to raise the 512-byte default if you are compressing many tiny responses.
  4. Reload without dropping connections: `caddy reload --config /etc/caddy/Caddyfile`, which validates the file first.

If it goes wrong: `caddy reload` validates before applying, so an invalid Caddyfile leaves the running configuration in place. Removing the `encode` line and reloading returns to uncompressed responses.

We could not tell what serves these files

Where: Whatever serves the paths listed in this finding — your web server, your CDN, or an object store.

  1. Find out who is answering. `curl -sSI https://<host>/path/to/app.js` shows the `server` header and any CDN headers, which tells you where the setting lives.
  2. Ask for compression explicitly and see what comes back: `curl -sSI -H 'Accept-Encoding: br, gzip' https://<host>/path/to/app.js | grep -i content-encoding`. An empty result means nothing is compressing it.
  3. Enable compression for text types where the answer points: `text/*`, JavaScript, JSON and SVG. Never for images, video or WOFF2 — they are already compressed.
  4. Make sure `Vary: Accept-Encoding` is sent, so a shared cache does not serve a compressed body to a client that cannot read it.
  5. If the assets sit on an object store that cannot compress on the fly, pre-compress at build time and upload both variants with the correct `Content-Encoding`.

If it goes wrong: Compression is a per-response transformation, not a change to your files. Turning it off returns exactly to what is being served now — but confirm downloads of each content type work while it is on, since a misconfigured `Content-Encoding` is the one failure mode that produces corrupt files rather than slow ones.

Check it worked:

  • curl -sSI -H 'Accept-Encoding: br, gzip' https://<host>/path/to/app.js | grep -i 'content-encoding' — expect br or gzip
  • curl -sSI https://<host>/path/to/app.js | grep -i '^vary' — expect Accept-Encoding
  • curl -sS -H 'Accept-Encoding: gzip' --compressed -o /dev/null -w '%{size_download}\n' https://<host>/path/to/app.js — compare with the uncompressed size

Test again re-runs the lab measurement now, so this finding should clear on the next run once the fix is deployed. It is a single page load: a small change in the number between runs is noise, and the resource facts beside it — transfer sizes, which files blocked rendering — are the stable part to judge by.

We measure the page's subresources and exclude the main document, which is graded separately. So a site whose HTML is uncompressed but whose assets are fine will not raise this finding — check the document yourself with the first command above against the page URL.

nginx — ngx_http_gzip_module · Apache httpd 2.4 — mod_deflate · Apache httpd 2.4 — mod_brotli · Caddy — the encode directive

Ship less JavaScript

JavaScript costs twice: once to download and again to parse and execute, and the second cost falls on the visitor's CPU, which on a mid-range phone is a great deal slower than yours. We report this when at least 200 KB of the JavaScript delivered to the page went unused during the load. Unused code is not free — it was downloaded, parsed and compiled before the browser could know it was not needed.

Who makes this change: You — this is a change on your own site. Your bundle and the third-party scripts you include. A tag manager that loads six vendors is one line of your markup.

Page HTML — your own bundle

Where: The bundler configuration and the entry points it builds.

  1. Split by route, so a visitor to one page does not download the code for the rest of the site. Most bundlers do this with one setting once routes are separate modules.
  2. Load interaction-only code on the interaction: a dynamic `import()` inside the event handler is enough, and the network cost then lands when the visitor has already committed to the feature.
  3. Measure before removing. DevTools' Coverage panel shows the unused share per file; anything above 80% unused on this page is a candidate for splitting or deferral.
  4. Check for duplicated dependencies across chunks. Shipping two copies of the same library is common and invisible without a bundle analyser.
  5. Defer everything not needed for the first render, so parsing does not compete with the initial paint.

If it goes wrong: Code splitting changes when modules load, which surfaces import-order assumptions — a module with a side effect that something else relied on is the usual break. Ship the split behind a staging deploy and check the interactive paths, not only the first paint.

WordPress — plugins and tag managers

Where: The plugin list, and the tag manager container loading vendors on this page.

  1. Audit third-party scripts against what they earn. A tag manager with a dozen tags is a dozen vendors' code on every page, most of which nobody reviews after it is added.
  2. Stop plugins loading their assets site-wide where they are only used on a few pages. Many offer a setting; where none exists, conditional dequeuing in the theme does it.
  3. Remove the plugins that are no longer used rather than deactivating and leaving them. Deactivated plugins cost nothing, but the half-used ones are the ones worth finding.
  4. Re-measure after each removal so you know which one mattered.

If it goes wrong: Deactivating a plugin can remove content or shortcodes from pages, not only scripts. Do it on a staging copy, check the pages that use the feature, and reactivate if anything depends on it.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell how this page is built

Where: Whatever produces and includes the scripts listed in this finding.

  1. Read the list: this finding names the files and how much of each went unused.
  2. Separate yours from theirs. Third-party scripts are usually the faster win, because removing one is a decision rather than a refactor.
  3. For your own code, split by route first and by interaction second — those two changes account for most of the achievable saving on a typical site.
  4. Confirm with DevTools' Coverage panel rather than by bundle size alone: a smaller bundle that is still entirely unused on this page has not helped the visitor.

If it goes wrong: Removing a third-party script removes its feature — analytics stop recording, a chat widget disappears. That is the intended trade, but make it deliberately and know how to put the snippet back.

Check it worked:

  • In Chrome DevTools, open Coverage, reload, and confirm the unused bytes figure has fallen
  • Re-run this check: the finding clears when unused JavaScript drops below 200 KB

Test again re-runs the lab measurement now, so this finding should clear on the next run once the fix is deployed. It is a single page load: a small change in the number between runs is noise, and the resource facts beside it — transfer sizes, which files blocked rendering — are the stable part to judge by.

web.dev — Optimize long tasks · web.dev — Optimize resource loading and the critical rendering path

Let returning visitors reuse your static files

A static file with a short cache lifetime is downloaded again on the visitor's next page view, even though it has not changed. We report this when at least 100 KB of the page's static assets carry a lifetime short enough to matter. The fix is free in bandwidth terms and has one real hazard: a long lifetime on a file whose URL never changes means you cannot ship a fix to anyone who already has it.

Who makes this change: You — this is a change on your own site. Whatever serves the assets sets the header — your web server, your CDN, or your object store. Where a CDN overrides the origin's header, the CDN is the one that decides.

nginx — nginx

Where: The `location` block matching your static assets, under /etc/nginx/.

  1. Fingerprint the file names first. `app.4f3a9c2e.js` rather than `app.js` is what makes a long lifetime safe, because a new build is a new URL. Most bundlers do this with one setting.
  2. For fingerprinted assets, `add_header Cache-Control "public, max-age=31536000, immutable";` in their location block. `immutable` tells the browser not even to revalidate.
  3. Beware the inheritance rule: `add_header` directives at one level are inherited **only if** that level declares none of its own. A location block that sets one header drops every header inherited from the server block, so repeat them there.
  4. `expires 1y;` is the shorter way to write the same lifetime and emits `Cache-Control: max-age=…` plus an `Expires` header. Use one mechanism or the other, not both.
  5. Leave anything **not** fingerprinted on a short lifetime, and never give the HTML document a long one — it is what tells the browser about the new asset URLs.
  6. Test and reload: `sudo nginx -t && sudo systemctl reload nginx`.

If it goes wrong: This is the change on this page with a real trap: a long `max-age, immutable` on a stable URL cannot be recalled — browsers that already cached it will not ask again until it expires. Reverting the configuration fixes new visitors only. If it happens, ship the asset under a new URL; that is the only reliable remedy, and it is why fingerprinting comes first rather than last.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Apache — Apache httpd

Where: The virtual host or `.htaccess` for the static asset directory.

  1. Enable mod_expires (`sudo a2enmod expires`) and set `ExpiresActive On` plus, for example, `ExpiresByType text/css "access plus 1 year"`. mod_expires emits both `Expires` and the matching `Cache-Control: max-age`.
  2. Pick one module for `Cache-Control` — mod_expires or mod_headers — rather than both. The Apache documentation does not define which wins when they disagree, so having two sources is a debugging problem waiting to happen.
  3. Use `Header set Cache-Control` only if you need `immutable`, which mod_expires does not emit.
  4. Scope the directives to the asset directory, not the whole site: the HTML document must keep a short lifetime.
  5. Check and reload: `sudo apachectl configtest && sudo systemctl reload apache2`.

If it goes wrong: The same warning applies: what a browser has already cached under a long lifetime cannot be recalled. Reverting the configuration affects future requests only. Keep the previous file, and change asset URLs if you need to force a refresh.

Caddy — Caddy

Where: The site block, or a path matcher inside it, in your Caddyfile.

  1. Match the asset paths and set the header: a matcher such as `@static path *.css *.js *.woff2` followed by `header @static Cache-Control "public, max-age=31536000, immutable"`.
  2. Use `header ?Cache-Control` instead if you only want to supply a value when the upstream has not set one; `?` writes the header only when it is absent.
  3. Leave the HTML document out of the matcher.
  4. Reload with validation: `caddy reload --config /etc/caddy/Caddyfile`.

If it goes wrong: `caddy reload` validates first, so a bad Caddyfile does not take effect. The cached-asset warning above still applies — a long lifetime already handed out cannot be withdrawn.

We could not tell what serves these files

Where: Whatever serves the paths named in this finding, and any CDN in front of it.

  1. See what is being sent: `curl -sSI https://<host>/path/to/app.js | grep -iE 'cache-control|expires|age|server'`.
  2. Decide per URL, not per site. Fingerprinted file names can take a year; a stable URL like `/logo.png` should not, because you cannot change it later.
  3. Set `Cache-Control: public, max-age=31536000, immutable` on the fingerprinted assets wherever the header is produced.
  4. Apply the same policy at the CDN as at the origin. A CDN that overrides the origin's header is the usual reason this finding survives a server change.
  5. Leave the HTML document on a short lifetime — it is what tells the browser about the new asset URLs, and caching it long is how a site appears not to update.

If it goes wrong: A long lifetime cannot be taken back from browsers that already have the file. Before setting one, be sure the URL changes when the content does; if you get it wrong, publishing the asset under a new name is the only dependable fix.

Check it worked:

  • curl -sSI https://<host>/path/to/app.<hash>.js | grep -i cache-control — expect public, max-age=31536000, immutable
  • curl -sSI https://<host>/ | grep -i cache-control — the document should NOT carry a long lifetime

Test again re-runs the lab measurement now, so this finding should clear on the next run once the fix is deployed. It is a single page load: a small change in the number between runs is noise, and the resource facts beside it — transfer sizes, which files blocked rendering — are the stable part to judge by.

MDN — Cache-Control · nginx — ngx_http_headers_module (expires, add_header) · Apache httpd 2.4 — mod_expires · Caddy — the header directive

About the website speed test

A speed test answers two different questions that are usually blurred together. The first is what your actual visitors experience, measured on their own devices and networks over time — field data. The second is what happens on one controlled page load, where every byte and every blocking resource can be attributed to a specific file — a lab measurement. Field data tells you whether there is a problem; lab data tells you what to change. This tool reports them separately and labels which is which, because a fix is judged on the first and found in the second.

The field half comes from the Chrome User Experience Report: the 75th percentile of Largest Contentful Paint, Interaction to Next Paint, Cumulative Layout Shift, Time to First Byte and First Contentful Paint over a trailing 28-day window, from Chrome users who opted in to sharing it. Google's own thresholds are used, and the 75th percentile is Google's own choice of statistic — it means three visits in four were at least this good, so a site with a fast median can still fail. Field data also lags: a fix deployed yesterday will not move these numbers until it has been in front of visitors for most of the window. A site with too little traffic has no dataset at all, which is reported as insufficient data rather than as a pass.

The lab half is one real page load in a headless Chromium on our own probe host, with the network and the CPU throttled to resemble a mid-range phone on a slow connection. It observes First Contentful Paint, Largest Contentful Paint, Cumulative Layout Shift, total blocking time and server response time directly, and it inspects the resources the page actually fetched: which stylesheets and scripts blocked rendering, which images are far larger than the box they are displayed in or are in a format a modern encoder would shrink, which text resources arrived uncompressed, how much of the shipped JavaScript went unused, and which static files are barely cached. Text compression is measured by actually compressing the bytes rather than estimating a ratio.

**We deliberately do not publish a performance score.** A single 0-100 number of the kind PageSpeed Insights reports is only meaningful under the exact conditions its scoring curve assumes — a particular simulated-throttling model, on hardware with at least two dedicated cores and above a stated speed benchmark. Our measurement uses real network and CPU throttling rather than that simulation, and runs on modest shared hardware, so a number produced from it would not be comparable to Google's even though it would look identical sitting next to it. Rather than print a figure we would have to caveat into meaninglessness — or leave an unexplained gap, which is its own kind of dishonesty — every run states in words that no score is published and which condition was not met. If you want a directly comparable score, run PageSpeed Insights against the same URL; it executes on Google's own infrastructure.

For the same reason, only the field measurements affect the report's score. The lab findings are byte counts and resource lists: reproducible, specific and actionable, but a model of what a change would save rather than a measurement of what your visitors got. Scoring both would count one problem twice and do it with the weaker evidence. It is also one run rather than a median of several, so treat a lab timing as indicative and a lab byte count as exact.

One more boundary worth knowing: this measures one page — the address you submit, after redirects — not your whole site. A slow template is usually visible from the home page, but a heavy product page or a checkout flow has to be submitted on its own.

Common questions

Why is there no performance score?
Because ours would not be comparable to Google's. A 0-100 score only means anything under the throttling model and the hardware its scoring curve assumes, and our measurement matches neither. We publish the opportunities instead, and every run says which condition was not met.
How do I get a score I can compare?
Run PageSpeed Insights against the same URL. It runs on Google's own infrastructure under the conditions the score was calibrated for, which is exactly why we point at it instead of imitating it.
What are Core Web Vitals?
Largest Contentful Paint (how long until the main content appears), Interaction to Next Paint (how quickly the page responds when someone interacts) and Cumulative Layout Shift (how much the page jumps around as it loads). Google treats good as LCP under 2.5s, INP under 200ms and CLS under 0.1.
What is the difference between field data and lab data?
Field data is what real Chrome visitors experienced over the last 28 days. Lab data is one page load we performed under controlled throttling. Field data tells you whether there is a problem; lab data tells you which file is causing it.
Why does it say there is not enough data?
The Chrome User Experience Report only publishes a dataset once a URL or an origin has enough visits to report without identifying anyone. A low-traffic site has no field data, which is a fact about sample size, not a fault.
I fixed something and the numbers have not moved.
Field data covers a trailing 28-day window, so a change takes most of a month to show fully. Use the lab measurements to confirm the fix actually landed, and expect the field numbers to follow.
Does it measure the whole site?
No. One page per run — the address you submit, after any redirects. Field data may fall back to an origin-wide dataset when the specific URL has too little traffic, and the result says when it did.
Do the lab findings affect the score?
No. Only the field measurements are scored. The lab findings are estimates of what a change would save, and scoring both would count one problem twice using the weaker evidence.
What this tool checks (18 rules)
  • perf.crux.cls-poor — The page jumps around while it loads for real visitors
  • perf.crux.fcp-slow — Real visitors stare at a blank page for too long
  • perf.crux.field-data — Real-user experience over the last 28 days
  • perf.crux.inp-poor — The page is slow to respond when real visitors interact with it
  • perf.crux.insufficient-data — Not enough real-user traffic for field data
  • perf.crux.lcp-poor — Real visitors wait too long for the main content to appear
  • perf.crux.not-configured — Real-user performance data was not requested
  • perf.crux.query-failed — Real-user performance data could not be retrieved
  • perf.crux.ttfb-slow — The server takes too long to send the first byte to real visitors
  • perf.lab.excessive-javascript — The page ships more JavaScript than it uses
  • perf.lab.images-legacy-format — Images are served in older, heavier formats
  • perf.lab.images-oversized — Images are much larger than the space they are shown in
  • perf.lab.metrics — Lab measurement of one page load
  • perf.lab.render-blocking-resources — Stylesheets and scripts stop the page from rendering
  • perf.lab.score-withheld — This report never publishes a headline performance score
  • perf.lab.static-assets-short-cache — Static files are re-downloaded because they are barely cached
  • perf.lab.subresources-uncompressed — Scripts and stylesheets are sent without compression
  • perf.lab.unavailable — The page was not measured in a browser