Skip to content

Frontend web vitals under load

A backend can look healthy (p95 API latency within SLO, error rate near zero) while users get slow page loads, high LCP and layout shifts. Core Web Vitals depend on what happens in the browser: JS execution, render, third-party scripts, and how slow API responses add up in the load time users see. This page shows how to measure front-end timing while the backend is under MaxoPerf protocol load.

Why front-end metrics degrade under backend load

Section titled “Why front-end metrics degrade under backend load”

When the backend is under load:

  • TTFB increases. The server takes longer to respond, so the browser waits longer before it can start rendering.
  • LCP is delayed. If the largest contentful element depends on an API response (JSON data, a dynamic image URL, or a server-side rendered block), a slow API pushes LCP later.
  • INP can worsen. If JavaScript makes API calls on interaction, a slow backend delays the response to each interaction and raises the INP score.
  • CLS may spike. If content placeholders collapse and re-expand when API data finally arrives, layout shifts add up.

A protocol-only load test shows none of these effects. You see them only when you run a browser alongside the protocol load.

The pattern has three steps:

  1. Apply backend load. Run a MaxoPerf protocol load test at realistic concurrency (the load of your expected peak traffic).
  2. Sample the front end. Run a few browser checks during the load to capture web vitals.
  3. Compare idle vs loaded. Compare web vitals under load with your baseline (idle or low-load) measurements to see how much peak load degrades them.

Option A: Taurus selenium executor (in-platform)

Section titled “Option A: Taurus selenium executor (in-platform)”

To run the browser check and the protocol load in a single MaxoPerf run, use the hybrid load architecture. The selenium executor runs browser VUs alongside protocol VUs in the same Taurus YAML. In your Selenium Python script, capture TTFB and LCP from window.performance:

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def test_dashboard_web_vitals(driver):
"""Capture TTFB, load time, and (optionally) LCP while backend is under load."""
driver.get("https://app.example.com/dashboard")
# Wait for the main content to render
WebDriverWait(driver, 20).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "[data-testid='run-list']"))
)
# Capture Navigation Timing (TTFB + load)
timing = driver.execute_script("""
const e = performance.getEntriesByType('navigation')[0];
return {
ttfb: e.responseStart - e.requestStart,
domContentLoaded: e.domContentLoadedEventEnd - e.startTime,
loadComplete: e.loadEventEnd - e.startTime
};
""")
# Log as custom metrics visible in MaxoPerf results (Taurus picks up stdout)
print(f"TTFB: {timing['ttfb']:.0f} ms")
print(f"DOMContentLoaded: {timing['domContentLoaded']:.0f} ms")
print(f"LoadComplete: {timing['loadComplete']:.0f} ms")
# Assert on your SLO
assert timing['ttfb'] < 200, f"TTFB {timing['ttfb']:.0f} ms exceeded 200 ms SLO"
assert timing['loadComplete'] < 3000, f"Load {timing['loadComplete']:.0f} ms exceeded 3 s SLO"

Taurus picks up the print() calls and MaxoPerf shows them in the run output. Assertion failures count as errors in the MaxoPerf result.

Option B: Playwright alongside MaxoPerf protocol load (external)

Section titled “Option B: Playwright alongside MaxoPerf protocol load (external)”

To keep the browser measurement outside MaxoPerf, run it from CI or locally while the MaxoPerf protocol test is running:

scripts/measure-web-vitals-under-load.ts
import { chromium } from 'playwright';
interface WebVitals {
ttfb: number;
lcp: number | null;
cls: number | null;
inp: number | null;
}
async function measureUnderLoad(targetUrl: string): Promise<WebVitals> {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const vitals: Partial<WebVitals> = {};
// Inject web-vitals library before navigation
await page.addInitScript(() => {
(window as any).__vitals = {};
});
// Navigate and wait for load
const start = performance.now();
const response = await page.goto(targetUrl, { waitUntil: 'networkidle' });
const ttfb = (response?.timing().responseStart ?? 0);
vitals.ttfb = ttfb;
// Collect LCP via PerformanceObserver (requires page interaction to trigger)
const lcp = await page.evaluate(() =>
new Promise<number | null>((resolve) => {
let lcpValue: number | null = null;
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
lcpValue = entries[entries.length - 1].startTime;
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// Give time for LCP to finalize
setTimeout(() => resolve(lcpValue), 3000);
})
);
vitals.lcp = lcp;
console.log(`TTFB: ${vitals.ttfb?.toFixed(0)} ms`);
console.log(`LCP: ${vitals.lcp?.toFixed(0)} ms`);
await browser.close();
return vitals as WebVitals;
}
// Run during the load test steady state
measureUnderLoad('https://app.example.com/dashboard')
.then(v => {
if (v.lcp && v.lcp > 2500) {
console.error(`LCP ${v.lcp.toFixed(0)} ms EXCEEDS 2.5 s threshold (poor)`);
process.exit(1);
}
console.log('Web vitals within thresholds.');
});

Use these Google-defined thresholds to grade results:

MetricGoodNeeds improvementPoor
LCP (Largest Contentful Paint)≤ 2.5 s2.5–4 s> 4 s
INP (Interaction to Next Paint)≤ 200 ms200–500 ms> 500 ms
CLS (Cumulative Layout Shift)≤ 0.10.1–0.25> 0.25
TTFB (Time to First Byte)≤ 800 ms800 ms–1.8 s> 1.8 s
FCP (First Contentful Paint)≤ 1.8 s1.8–3 s> 3 s

For full metric definitions, see the Core Web Vitals glossary.

When you run browser VUs (via the selenium executor) alongside protocol VUs in a single MaxoPerf run:

  • Interaction timings appear in the Overview tab as labeled response times. Each driver.get() and WebDriverWait step creates a labeled timing entry.
  • Assertion failures from assert timing['loadComplete'] < 3000 count toward the error rate. That is what you want, because a failed web-vitals assertion is a real failure.
  • Throughput for browser VUs is measured in iterations per second (completed browser flows per second), separate from the protocol VU RPS.
  • Compare the protocol VU latency chart with the browser timing chart. If the two rise together, the backend slowdown is causing the front-end degradation.

Do:

  • Measure web vitals under load, not only at idle. Idle measurements mislead you about production.
  • Assert on TTFB and LCP in your browser script so that a threshold violation shows up as an error in the MaxoPerf result.
  • Run the browser check during the steady-state phase of the load run (after ramp-up), not during ramp-up when load is still building.

Don’t:

  • Assume good protocol-level p95 latency means good LCP. They measure different things.
  • Skip CLS measurement. Protocol tests cannot see layout shifts, and shifts can ruin the user experience when API data arrives slowly.
  • Treat a single browser sample as the truth. Run 3–5 browser iterations and use the median to reduce noise.