Testing for Performance: How to Ensure Your Web Vitals Stay Green

Posted . Visible to the public.

Frontend performance and user experience are orthogonal to feature development. If care is not taken, adding features usually degrades frontend performance over time.

Many years, frontend user experience has been hard to quantify. However, Google has developed some metrics to capture user experience on the web: the Web Vitals Show archive.org snapshot . The Core Web Vitals are about "felt loading speed" (Largest Contentful Paint), reactivity (Interaction to Next Paint) and visual stability (Content Layout Shift).

I have recently been optimizing an application for Largest Contentful Paint and Content Layout Shift. The achieved scores should not be lost again, so I fixed them with tests.

How to test Web Vitals

In a test:

  1. Build & open the (stereotype) page you want to ensure Web Vital scores for
  2. Throttle test browser I/O
  3. Expect Web Vital scores

For this to work, you'll need some performance steps/helpers that you'll find below.

Helpers

# performance_steps.rb
# Cucumber steps for performance testing
# Can easily be converted to an RSpec helper

Given(/^I throttle the test browser I\/O to (\d+)kbps$/) do |kbps|
  browser = Capybara.current_session.driver.browser

  # https://chromedevtools.github.io/devtools-protocol/tot/Network/
  browser.execute_cdp('Network.enable')
  browser.execute_cdp('Network.clearBrowserCache')
  browser.execute_cdp('Network.emulateNetworkConditions',
    offline: false,
    latency: -1, # ms, unlimited
    downloadThroughput: kbps * 1024 / 8,
    uploadThroughput: kbps * 1024 / 8,
  )
end

Then(/^the image "(.*)" should be prioritized$/) do |image|
  img = page.find(image_selector(file: image))
  expect(img[:class]).to_not match(/lazyload(ed)?|lazyautosizes/), 'Image should not be lazyloaded'
  expect(img[:fetchpriority]).to eq 'high'
  expect(img[:decoding]).to eq 'sync'
end

Then(/^the video should be prioritized$/) do
  # Videos cannot be prioritized themselves, but we can prioritize the poster image
  video = page.find('video')
  preload_link = page.find("link[rel=preload][href='#{video[:poster]}']", visible: :hidden)
  expect(preload_link[:fetchpriority]).to eq 'high'
end

Then 'the Cumulative Layout Shift score should be good' do
  # Cumulative Layout Shift seems to be better in tests than in reality, so
  # we use a lower value than the official "good" threshold of 0.1
  good_threshold = 0.05

  cls = nil
  patiently do
    raw_cls = evaluate_script 'webVitals.cls' # See performance.js
    expect(raw_cls).to be_present, 'Could not retrieve CLS value, window.webVitals.cls is blank'

    cls = raw_cls.to_f.round(4)
  end

  expect(cls).to be <= good_threshold
  # puts "CLS: #{cls}"
end

Then(/^the Largest Contentful Paint score should be (good|ok)$/) do |rating|
  # LCP seems to be worse in tests than in reality, so we use a higher value
  # than the official "good" threshold of 2.5
  threshold = {
    good: 3,
    ok: 4.5,
  }[rating.to_sym]

  lcp = nil
  patiently do
    raw_lcp = evaluate_script 'webVitals.lcp' # See performance.js
    expect(raw_lcp).to be_present, 'Could not retrieve LCP value, window.webVitals.lcp is blank'

    lcp = raw_lcp.to_f.round(4)
  end

  expect(lcp).to be <= threshold
  # puts "LCP: #{lcp}"
end
// performance.js
// Compute web vital scores.
// Taken from https://github.com/nucliweb/webperf-snippets
window.webVitals = {}

// Only reveal values after the load event. Tests will have to wait for their presence.
window.onload = function() {
  window.webVitals.cls = cls
  window.webVitals.lcp = lcp
}

// CLS /////////////////////////////////////////////////////////////////////////
let cls = 0
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (!entry.hadRecentInput) {
      cls += entry.value
      // console.log('Current CLS value:', cls, entry)
    }
  }
}).observe({ type: 'layout-shift', buffered: true })

// LCP /////////////////////////////////////////////////////////////////////////
let lcp = 0
new PerformanceObserver((entryList) => {
  let entries = entryList.getEntries()
  entries = dedupe(entries, 'startTime')

  // Debugging
  // entries.forEach((item, i) => {
  //   console.dir(item)
  //   console.log(
  //     `${i + 1} current LCP item : ${item.element}: ${Math.max(item.startTime - getActivationStart(), 0)}`,
  //   )
  //   if (item.element) item.element.style = 'border: 5px dotted lime'
  // })

  const lastEntry = entries[entries.length - 1] // In milliseconds
  lcp = Math.max(lastEntry.startTime - getActivationStart(), 0) / 1000
  console.log(`LCP is: ${lcp}`)
}).observe({ type: 'largest-contentful-paint', buffered: true })

// Helpers /////////////////////////////////////////////////////////////////////

function dedupe(arr, key) {
  return [...new Map(arr.map((item) => [item[key], item])).values()]
}

function getActivationStart() {
  const navEntry = getNavigationEntry()
  return (navEntry && navEntry.activationStart) || 0
}

function getNavigationEntry() {
  const navigationEntry =
    self.performance &&
    performance.getEntriesByType &&
    performance.getEntriesByType('navigation')[0]

  if (
    navigationEntry &&
    navigationEntry.responseStart > 0 &&
    navigationEntry.responseStart < performance.now()
  ) {
    return navigationEntry
  }
}
Dominik Schöler
Last edit
Michael Leimstädtner
License
Source code in this card is licensed under the MIT License.
Posted by Dominik Schöler to makandra dev (2025-07-15 06:29)