Skip to content

Frontend web vitals under load

A backend can appear healthy — p95 API latency within SLO, error rate near zero — while users experience slow page loads, high LCP, and layout shifts. This happens because Core Web Vitals depend on what happens in the browser: JS execution, render, third-party scripts, and the cumulative effect of slow API responses on perceived load time. This page shows you 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; 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 means interaction responses arrive later, raising the INP score.
  • CLS may spike — if content placeholders collapse and re-expand when API data finally arrives, layout shifts accumulate.

None of these effects are visible in a protocol-only load test. 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 that represents your expected peak traffic).
  2. Sample the front end — run a small number of browser checks during the load to capture web vitals.
  3. Compare idle vs loaded — compare web vitals from the loaded state against your baseline (idle or low-load) measurements to find how much degradation peak load introduces.

Option A — Taurus selenium executor (in-platform)

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

If you want both the browser check and the protocol load inside 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 via 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"

The print() calls are picked up by Taurus and surfaced in MaxoPerf’s 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)”

If you prefer to keep the browser measurement outside MaxoPerf and run it from CI or locally while the MaxoPerf protocol test is in progress:

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 cross-links to full metric definitions, see 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 — exactly what you want: 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.
  • Look for correlation between the protocol VU latency chart and 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 just at idle — idle measurements are misleading for production sizing.
  • 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 — layout shifts are invisible in protocol tests and can tank the user experience when API data arrives slowly.
  • Use a single browser sample as ground truth — run 3–5 browser iterations and look at the median to reduce noise.