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:
- Build & open the (stereotype) page you want to ensure Web Vital scores for
- Throttle test browser I/O
- 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
}
}