Core Web Vitals in practice: how we deliver 95+ in PageSpeed
Blog
Technology

Core Web Vitals in practice: how we deliver 95+ in PageSpeed

Prerendering, JavaScript after the first paint, fonts without layout shift and animations in CSS. The techniques we use on our own site, with code and with their trade-offs.

DualFroz - VulCode CEODualFroz - VulCode CEO·14 September 2026·24 min read

A score of 95 in PageSpeed Insights does not mean a site has good Core Web Vitals. The score is a lab measurement from a single run on an emulated phone, whereas Core Web Vitals are data from real users collected over 28 days. Both numbers are useful, but for different things, and a promise that "the site will have excellent Core Web Vitals" made on the strength of a PageSpeed score alone is a promise nobody can check on the day of handover.

Below we describe how we measure a site before we present the score at handover, and which techniques we use to improve Core Web Vitals and keep LCP, INP and CLS within the thresholds. They all come from working code, including the site you are reading this on. For each one we also say what it costs, because none of them is free.

In short
  • The PageSpeed score is a lab test. Core Web Vitals are field data from Chrome. A new site only has the former.
  • The biggest wins: content in the HTML instead of rendering in the browser, JavaScript that starts after the first paint, and an LCP image fetched at the highest priority.
  • CLS is mostly broken by fonts and by elements without reserved space. INP is broken by long tasks on the main thread.
  • The score most often drops after handover: because of third-party scripts, heavy images from the CMS and cookie banners.

The PageSpeed score and Core Web Vitals are two different measurements

PageSpeed Insights shows two sections on one screen that are easy to mistake for one. At the top, if the site has enough traffic, there is field data from the Chrome User Experience Report (CrUX): how the site behaved for real Chrome users over the last 28 days. Below it is the score from 0 to 100, which is Lighthouse run once on Google's servers, on an emulated phone with an artificially throttled connection and CPU.

The 0 to 100 number is built from five lab metrics: First Contentful Paint, Speed Index, Largest Contentful Paint, Total Blocking Time and Cumulative Layout Shift. LCP, TBT and CLS carry the most weight. Note that INP is not in this set, because nobody clicks anything in a lab. Its stand-in is Total Blocking Time, which measures how long the main thread was busy with long tasks during loading.

Core Web Vitals are assessed at the 75th percentile. A site "passes" when at least three quarters of page views fall within the threshold for each of the three metrics. That is why a developer's fast laptop and the office fibre connection tell you nothing here: what counts is a mid-range phone on congested LTE, because that is what sets the percentile.

Which leads to something that has to be said before the project starts: a new site has no field data. CrUX data only appears once the site has collected enough page views from Chrome, and for small company websites it may never appear at all. The 95+ we show at handover is therefore a lab score. The honest thing is to call it what it is, rather than sell it as "Google rates the site at 95".

The three thresholds and what they actually measure

Google publishes thresholds for each metric. The "good" value has to be reached at the 75th percentile of page views, separately for mobile and desktop.

MetricWhat it measuresGoodNeeds improvementPoor
LCP (Largest Contentful Paint)When the largest element in the viewport was rendered: an image, a block of text, a video posterup to 2.5 s2.5 to 4 sover 4 s
INP (Interaction to Next Paint)How long it takes from a click, tap or key press to the next frame on screen; the worst interactions of the whole visit are usedup to 200 ms200 to 500 msover 500 ms
CLS (Cumulative Layout Shift)How much the content jumps around without user input; the sum of shifts in the worst time windowup to 0.10.1 to 0.25over 0.25

The LCP element is most often the hero image or the H1 heading. LCP breaks down into four parts: the time to the first byte of the server response, the delay before the browser even starts fetching the LCP resource, the download time of that resource, and the render time. When LCP is too high, check which part is long before you start compressing the image. Very often it is the second one: the browser only learns about the image from JavaScript or CSS, so it starts downloading it late.

INP replaced the older FID metric in March 2024. The difference matters: FID measured only the delay before the first interaction was handled, while INP covers every interaction during the visit and the whole time until the response is painted. A store filter that froze the page for 400 ms was invisible to FID unless it was the first click. For INP it becomes the score of the whole page.

CLS does not count shifts that happen within 500 ms of a user interaction, so an accordion expanding after a click does no harm. What does harm it is a cookie banner sliding in above the content, an image without dimensions that pushes the text down once it loads, and a font whose letters are a different width from the fallback font once it is swapped in.

How we measure a site before we show the score at handover

You have to measure the production build deployed at a test address, never the development server. A development server serves uncompressed, unminified code, adds a live reload module and loads every file separately, so its score is lower in a way that says nothing about production. For the same reason the test environment should have the same compression and cache header configuration as production, otherwise the measurement describes a different server from the one the site will live on.

Every important page is measured separately, in mobile mode, several times in a row. The Lighthouse score varies by a few points between runs, because it depends on the load on the testing machine and on the network. A single 96 and a single 91 can describe the same page. What counts is the median of the series, and whether any of the metrics keeps flipping between "good" and "needs improvement".

The home page is not enough. The measurement should cover every type of template: the home page, an offer page, a blog post, the contact page, and in an online store the product page and the category listing. The heaviest page is often one nobody looks at during handover, for example a portfolio gallery with thirty photos or a page with an embedded map.

We show the results to the client before handover. A link to the report works better than a screenshot with one number: PageSpeed Insights makes the result available at a permanent URL, and anyone can rerun the test from the report page, so the client does not have to take the developer's word for it. The 95+ PageSpeed standard applies to websites. How we approach panels behind a login, which PageSpeed cannot reach at all, is covered in the last section.

➜
Tip

Lighthouse in Chrome DevTools, run on your own computer, gives different results from PageSpeed Insights, because it simulates throttling on your hardware, and your browser extensions count too. For comparisons with a client, use PageSpeed Insights, or Lighthouse in an incognito window with no extensions.

Content in the HTML: prerendering every page

The biggest single change for LCP on a site built with React is sending ready-made HTML with the content already in it. An app rendered only in the browser sends an empty container, and the heading and text only appear once the browser has downloaded, parsed and executed all of the JavaScript. On PageSpeed's emulated phone this chain can take several seconds before anything is painted.

That is why we render every page to static HTML at build time. A script walks through the list of routes in all languages, renders each one on the server and saves the result as a separate file. The browser gets the heading, paragraphs and images straight away, and as a bonus the search engine crawler sees the full content without executing any JavaScript. The same mechanism generates the sitemap, so the list of URLs in the sitemap always matches what actually exists.

Alongside the HTML we include the serialised state of the data the page was rendered from. When the app starts in the browser, it reads that state instead of fetching the same data a second time. Without it, once JavaScript started, the page would briefly show a loading state and then insert the content again, which hurts both CLS and the perceived speed.

There is one trap that is easy to fall into. Pages loaded lazily with React.lazy do not wait for their code to load when rendered with renderToString. The first route that touches such a component gets an empty "switch to client rendering" marker in its HTML, and the whole benefit of prerendering quietly disappears, with no error in the console. We solved this with a warm-up pass: first we render all the routes and throw the output away, so that every lazy module has time to load, and only the second pass writes the files.

The cost of prerendering is build time, plus the fact that a content change requires regenerating the pages. On the blog we solved this by making posts Markdown files that the generator reads straight from disk. A new post therefore does not require rebuilding the whole app, only rerunning the HTML generation step.

JavaScript starts only after the first paint

Since the content is in the HTML, JavaScript does not need to block the first render. By default the browser starts downloading the modules referenced in the HTML in parallel with rendering anyway, and parsing and executing them occupies the main thread at exactly the moment it should be painting the page. That pushes up Total Blocking Time in PageSpeed and delays LCP.

So we remove the module script tags and modulepreload hints from the generated HTML, and in their place insert a short script that adds them back only after the First Contentful Paint event. The principle looks like this (the file path is an example; in the build the file name contains a hash):

start-after-fcp.js · javascript
(function () {
  var started = false;

  function start() {
    if (started) return;
    started = true;
    var script = document.createElement('script');
    script.type = 'module';
    script.src = '/assets/app.js';
    document.head.appendChild(script);
  }

  try {
    new PerformanceObserver(function (list) {
      if (list.getEntriesByName('first-contentful-paint').length) {
        setTimeout(start, 0);
      }
    }).observe({ type: 'paint', buffered: true });
  } catch (error) {
    start();
  }

  addEventListener('load', function () {
    if (document.visibilityState === 'hidden') start();
    else setTimeout(start, 3000);
  });
})();

The two safeguards at the bottom are not decoration. A tab opened in the background paints nothing, so the FCP event will not arrive until the user brings the tab into view; in that case we start the scripts right after the load event. The second safeguard, starting three seconds after load, protects against a browser in which the paint observer did not fire for some reason. The try block handles browsers without PerformanceObserver.

You need to understand the trade-off before you ship it. For a fraction of a second after the first render the page is readable but not yet interactive: a button that opens a form will not respond until the app has started. That is why links are plain <a> tags with a real URL, not elements that only work through JavaScript. Clicking a link before the app starts simply takes you to the page.

On top of that, each route gets modulepreload hints in its head only for the chunks of code it actually needs: the shared core, the language dictionary and the module for that page. The contact page does not download the blog code, and a blog post does not download the configurator form.

The LCP image: fetch order matters more than file size

When the LCP element is an image, what matters is the moment the browser starts downloading it. An image set as a CSS background or inserted by a component after the app starts is discovered late, even if it weighs only a few dozen kilobytes. That is why, after rendering each page, our HTML generator looks for the first image in the content, skipping the logo, flags and navigation elements, and adds a high-priority preload hint to the head.

head.html · xml
<link rel="preload" as="image" href="/img/hero.webp" fetchpriority="high">

<img
  src="/img/hero.webp"
  width="1200"
  height="800"
  alt="Order panel on a laptop screen"
  fetchpriority="high"
>

The width and height attributes do not set the display size, which is still controlled by CSS. They let the browser work out the aspect ratio and reserve space before the image downloads, which protects CLS. If the image has several size variants via srcset, the preload hint must have matching imagesrcset and imagesizes attributes, otherwise the browser will download one file for the preload and another for srcset. For that reason our generator skips images with srcset when it adds the automatic preload.

An easy mistake to miss is loading="lazy" set globally on every image, including the one in the hero. Lazy loading waits until the browser has worked out the page layout and checked whether the image is near the viewport, so the LCP image starts late. We use loading="lazy" only below the first screen.

The default format is WebP, and for small cards we generate separate variants 320 pixels wide instead of having the browser scale down a file prepared for the full screen width. A service card a few hundred pixels wide does not need a file that would look good across the full width of a 4K monitor.

Fonts that do not shift the layout

A web font affects two metrics at once. If the browser waits for the font, the text is invisible and LCP is delayed. If it draws the text in a system font and then swaps in the target font, the letters change width, the lines break differently and the whole block shifts down, which counts towards CLS.

The first step is fonts hosted on the same domain in WOFF2 format, split into character subsets with unicode-range. The browser downloads a file only if the page contains a character from its range. There is a detail here that matters for Polish sites: the letters ą, ć, ę, ł, ń, ś, ź and ż sit in the Latin Extended-A range, while ó is in basic Latin-1. A Polish page therefore always downloads two files, the basic one and the extended one, and a Russian page adds a Cyrillic file on top. In the head we preload only the basic subset, because it is the only one needed on every page in every language.

The second step is a fallback font whose metrics are matched to the target font. CSS lets you build your own font definition on top of a system font and rescale its size, ascent and descent so that the text takes up the same space as the target font. Then font-display: swap shows the text immediately, and the swap does not move the lines.

fonts.css · css
@font-face {
  font-family: 'Quicksand';
  font-style: normal;
  font-weight: 300;
  font-display: swap;
  src: url(/fonts/quicksand-300-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-017F;
}

@font-face {
  font-family: 'Quicksand Fallback';
  src: local('Arial');
  ascent-override: 100%;
  descent-override: 25%;
  line-gap-override: 0%;
  size-adjust: 97%;
}

body {
  font-family: 'Quicksand', 'Quicksand Fallback', system-ui, sans-serif;
}

The values of size-adjust and the overrides are not guessed. You find them by rendering the same paragraph in the target font and in the fallback font, one on top of the other, and adjusting the numbers until the lines break in the same places. The values are different for every pair of fonts, so do not copy them from someone else's project.

Finally, fewer variants mean fewer files. If a design uses weights 300, 500 and 700 of one typeface, and the difference between 500 and 700 in headings is barely visible, dropping one weight means one file fewer on every page.

Entrance animations in CSS instead of JavaScript

Our sites have motion: the heading slides in from below, the buttons appear with a delay, the cards come in one after another. If that animation were driven by a JavaScript library, the element would stay in its initial state until the library had downloaded and started. With scripts that start after the first paint, that would mean the hero waits for JavaScript, which is exactly what we wanted to avoid.

That is why the hero entrance animations are plain CSS @keyframes that the browser runs together with the first render. The heading and description animate only transform, without opacity. An element offset by 24 pixels is already visible, and the browser can count it as rendered content, whereas text that starts fully transparent is not visible to the user until it fades in, which pushes LCP to the end of the animation. We keep fade-ins for the buttons, which are not the LCP element.

hero.css · css
@keyframes hero-rise {
  from { transform: translateY(24px); }
  to { transform: translateY(0); }
}

.js .hero-title {
  animation: hero-rise 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
}

@media (prefers-reduced-motion: reduce) {
  .js .hero-title {
    animation: none;
  }
}

The .js class on the <html> element is added by a one-line script in the head, before the stylesheets load. If JavaScript is disabled, the class is not there, the animation does not run and the content sits in place from the start. A user who has reduced motion turned on in their system gets the page without entrance animations.

For elements that should behave like a spring rather than a plain ease, we compute the spring curve once, while writing the styles, into a CSS linear() function with a few dozen points. The browser plays it back like any other timing function, with no physics library running on every frame. Animating translation and scale runs on the compositor layer, so it does not trigger a layout recalculation and does not move neighbouring elements, which means it does not count towards CLS.

The second trap concerns text that changes, for example a rotating slogan in the heading. If the variants have different lengths, the heading takes up one line one moment and two lines the next, and everything below it jumps. The fix is a heading with a fixed height, calculated in em units for the longest variant, with the text positioned inside it.

Fewer bytes over the wire: precompression and code splitting

A server can compress files on the fly for every request, but then it uses a lower compression level, because the highest one is too expensive for the CPU on every request. So we compress static files once, at build time: Brotli at the highest quality and gzip at the highest level for HTML, JavaScript, CSS, JSON, SVG and XML. We skip files smaller than a kilobyte, because the gain is negligible and the response headers weigh something anyway. The server hands the ready .br file to browsers that support it and the .gz file to the rest.

nginx.conf
location /assets/ {
  brotli_static on;
  gzip_static on;
  add_header Cache-Control "public, max-age=31536000, immutable";
}

The gzip_static directive is part of standard nginx, while brotli_static requires the Brotli module. The immutable header with a one-year lifetime is safe only because the file names in the assets directory contain a content hash. Every code change produces a new name, so the browser never uses an old version from its cache. HTML gets a short lifetime, because it is what points to the current file names.

We split the app code into separate bundles according to how often each part changes and where it is used. React and the router go into a shared vendor bundle, the animation library into a separate one, and the Markdown parser with syntax highlighting into yet another, which is downloaded only on the blog. A fix in a home page component does not invalidate the React code in the user's cache, which has not changed for months.

In the syntax highlighting library we manually register only the languages we actually write about, instead of importing the default bundle with all of them. The full import is convenient, but it pulls in definitions for every supported language, most of which no post will ever use. Decisions like this are not visible in a single measurement, but they add up on every page.

The last element is the service worker. It originally served files from its own cache first, which after every deployment led to "I can't see the changes". It now works network-first: it always fetches the fresh version and treats its cache purely as a fallback for when there is no connection. This has no performance cost, because files with a hash in their name come instantly from the browser's HTTP cache anyway.

INP: a click should get a response in the next frame

INP cannot be checked in PageSpeed, because the lab does not click. On a company website it is usually not a problem until something appears that processes data in the browser: a filter on the list of projects, a search box, a quote calculator, a form that validates on every keystroke. Then a single click starts a loop over several thousand elements, and the browser cannot paint a single frame until the loop finishes.

The first rule: show a reaction first, then compute. The button state change, the highlighting of the selected filter and the loading indicator go into the first frame, and heavy computation is split into chunks, handing control back to the browser between them so it can paint the response.

filters.js · javascript
function yieldToMain() {
  if (globalThis.scheduler && typeof scheduler.yield === 'function') {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function applyFilters(items, predicates) {
  const result = [];
  for (let i = 0; i < items.length; i++) {
    if (predicates.every((matches) => matches(items[i]))) {
      result.push(items[i]);
    }
    if (i > 0 && i % 500 === 0) {
      await yieldToMain();
    }
  }
  return result;
}

scheduler.yield() is available in Chrome and in browsers built on the same engine. Where it is missing, setTimeout with a zero delay has the same effect, with a slightly worse resumption priority. How many elements to process before yielding depends on the cost of a single check; the goal is that no chunk takes longer than a few dozen milliseconds.

The second rule concerns what happens in the styles after a click. Expanding a panel by animating height forces a layout recalculation on every frame, and on a longer page that is a real cost. We animate transform and opacity, and to expand content we use techniques that do not recalculate the layout of neighbouring elements on every frame. In event handlers we avoid interleaving reads of dimensions, such as offsetHeight, with style writes, because every such read after a write forces a synchronous layout recalculation.

The third rule: measure on a weak phone. In the Performance tab in DevTools you can slow the CPU down several times over and record a click. Every task longer than 50 ms is marked in red, and the panel shows which function took up the time. On a development machine the same filter can look instant.

What breaks the score after handover

A site with a green score at handover can drop to orange a few months later, even though nobody has changed a line of its code. The reasons repeat themselves, and they almost always come from outside the code we delivered.

  • Third-party scripts added through a tag manager. An ad pixel, a heatmap, a chat widget and an A/B testing tool each load their own JavaScript on the main thread. A/B testing tools can also hide the whole page until they have decided on a variant, which pushes LCP back directly.
  • Photos uploaded straight from the camera. A content panel that does not resize and convert images on upload lets someone put a file of several megabytes into the hero.
  • The cookie consent banner. A banner that slides in at the top of the page after loading and pushes the content down creates a layout shift on every first visit.
  • Embedded videos and maps. A single video player iframe downloads hundreds of kilobytes of scripts before anyone presses play.
  • New fonts and sections added without measuring. Each change on its own looks harmless; together they undo the work done during the build.

For each of these there is a pattern that does not require giving up the tool. Marketing scripts can be loaded after consent and after the load event, not in the head. Video is embedded as a facade: a static image with a play button that is swapped for the player only after a click. The cookie banner should overlay the content in a fixed position at the bottom of the screen instead of pushing it around. The content panel should resize images on upload, before anyone publishes them.

After launch our projects are covered by free monitoring, usually for 90 days. That is exactly the period in which the site gets its first real traffic and the client starts adding their own content and tools, so it is worth agreeing explicitly at handover what we monitor during that time. If the site has too little traffic for Google to show field data for it, you can collect the data yourself with the web-vitals library, sending results from visitors' browsers to your own collection endpoint:

vitals.js · javascript
import { onCLS, onINP, onLCP } from 'web-vitals';

function send(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    page: location.pathname,
  });
  navigator.sendBeacon('/api/vitals', body);
}

onLCP(send);
onINP(send);
onCLS(send);

navigator.sendBeacon sends the data even when the user closes the tab, which is the moment the library reports the final INP and CLS values. The rating field takes the values good, needs-improvement or poor, according to the same thresholds Google publishes. This kind of collection needs consent in line with the site's privacy policy if the data is linked to a user identifier.

When 95 in PageSpeed is not the right goal

A 95+ score is our standard for websites: landing pages, company websites, blogs, offer pages. We do not automatically carry it over to every type of project, because in some of them this number measures something that does not matter.

An admin panel or any system behind a login is out of PageSpeed's reach, because the tool cannot get past the login screen. Even if it could, a panel is first loaded once a day, and then the user works in it for hours. There we measure what the operator experiences: the response time after a click, API query times, the behaviour of tables with thousands of rows. In a panel, INP matters more than LCP.

In an online store the score depends on business decisions we do not fully control: the payment gateway, the reviews system, price comparison integrations and advertising tools. We build the store's code so that on its own it stays within the thresholds, and for every additional script we say what it costs in the measurement before it is added. The decision whether a given ad pixel is worth a few points belongs to the client, but they should make it knowing the price.

If you are planning a website and want to see what this process looks like from brief to handover, we describe it on the How we work page. You will find the scope and starting prices under company websites, from 1,199 PLN, and landing pages, from 499 PLN. If you already have a site and want to know why its score has dropped, write to us with the URL and we will reply within an hour.