cdpfleet Docs GitHub Dashboard

Cases / #55 · 2026-10-17 · Easy

Core Web Vitals from a remote browser: LCP, CLS and TTFB in lab runs

Read LCP, CLS, FCP and TTFB from the page's performance timeline, plus Chromium's own counters over a CDP session — and see why TTFB through a proxy is mostly the proxy.

Chromium

Run on production on 2026-10-17: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

QA and performance teams want Core Web Vitals in CI and in monitoring: did this release make the largest paint slower, does a late banner push the page around? Lighthouse does it locally, but a fleet browser can do it from many places at once, behind the proxy and country your users see. Which numbers can you read from a remote browser, how, and what do they actually measure?

What we used, and why

WhatWhy
PerformanceObserver with buffered: trueRun after the load, it still returns the largest-contentful-paint and layout-shift entries the page recorded earlier — no init script needed.
Navigation TimingresponseStart is TTFB; connectEnd − domainLookupStart is the time to reach the server (DNS, TCP, TLS — through the proxy), responseStart − requestStart the server's own wait.
context.newCDPSession(page) + Performance.getMetricsChromium's counters over the normal Playwright connection: script time, layout count, DOM nodes, JS heap.
A fresh context per page, 1280×800Cold cache and the same viewport every time, as a lab run should be.
A made-up shifting page via context.routeA banner that arrives 600 ms late and pushes the text down: a layout shift on purpose, served without a network.

How it works

  1. Launch headless Chromium behind a proxy.
  2. For each page: new context, CDP session, Performance.enable, load, wait 1.5 s for late content.
  3. Read FCP, LCP (and its element), CLS and the TTFB breakdown in the page; read the CDP counters.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const PAGES = [
  ['example.com', 'https://example.com/'],
  ['books.toscrape.com', 'https://books.toscrape.com/'],
  ['Wikipedia article', 'https://en.wikipedia.org/wiki/Web_browser'],
  ['shifting demo', 'https://shift.test/'], // served by page.route below
];
// A made-up page whose banner arrives late and pushes the text down: a layout shift on purpose.
const SHIFTING = `<!doctype html><meta charset="utf-8"><title>shift</title>
<body style="margin:0;font:18px sans-serif"><div id="slot"></div>
<h1>Article</h1>${'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>'.repeat(30)}
<script>setTimeout(() => { document.getElementById('slot').innerHTML = '<div style="height:300px;background:#fc0">Late banner</div>'; }, 600);</script>`;

// Reads the vitals from the page's own performance timeline. `buffered: true` returns the
// entries recorded before the observer existed, so this runs after the load.
const VITALS = `new Promise((resolve) => {
  const nav = performance.getEntriesByType('navigation')[0];
  const fcp = performance.getEntriesByName('first-contentful-paint')[0];
  let lcp = null, cls = 0;
  new PerformanceObserver((l) => { for (const e of l.getEntries()) lcp = e; }).observe({ type: 'largest-contentful-paint', buffered: true });
  new PerformanceObserver((l) => { for (const e of l.getEntries()) if (!e.hadRecentInput) cls += e.value; }).observe({ type: 'layout-shift', buffered: true });
  setTimeout(() => resolve({
    ttfb_ms: Math.round(nav.responseStart),
    connect_ms: Math.round(nav.connectEnd - nav.domainLookupStart), // DNS + TCP + TLS (through the proxy)
    wait_ms: Math.round(nav.responseStart - nav.requestStart), // request sent → first byte
    fcp_ms: fcp ? Math.round(fcp.startTime) : null,
    lcp_ms: lcp ? Math.round(lcp.startTime) : null,
    lcp_element: lcp && lcp.element ? lcp.element.tagName.toLowerCase() : null,
    cls: Math.round(cls * 1000) / 1000,
    load_ms: Math.round(nav.loadEventEnd),
  }), 200);
})`;

const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
  method: 'POST',
  headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
  body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: 'new' }),
});
if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });

try {
  const rows = [];
  for (const [name, url] of PAGES) {
    const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); // cold cache
    await context.route('https://shift.test/**', (route) => route.fulfill({ contentType: 'text/html; charset=utf-8', body: SHIFTING }));
    const page = await context.newPage();
    // A CDP session over the normal Playwright connection: the browser's own counters.
    const cdp = await context.newCDPSession(page);
    await cdp.send('Performance.enable');
    await page.goto(url, { waitUntil: 'load', timeout: 60000 });
    await page.waitForTimeout(1500); // let late content (and late shifts) land
    const vitals = await page.evaluate(VITALS);
    const metrics = Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map((m) => [m.name, m.value]));
    rows.push({
      page: name, ...vitals,
      script_ms: Math.round(metrics.ScriptDuration * 1000),
      layouts: metrics.LayoutCount,
      dom_nodes: metrics.Nodes,
      js_heap_mb: Math.round(metrics.JSHeapUsedSize / 1048576 * 10) / 10,
    });
    await context.close();
  }
  console.log(JSON.stringify({ rows }, null, 2));
} finally {
  await browser.close();
}

What we got

PageTTFB (ms)…connect (ms)…server wait (ms)FCP (ms)LCP (ms)LCP elementCLSLoad (ms)Script (ms)DOM nodesJS heap (MB)
example.com445295150480592p05823601.1
books.toscrape.com85663921713481352img0.00215661817722
Wikipedia article72646526111241124p0.095751017194958.8
shifting demo360366464p0.234451871.1

From the Node.js run on 2026-10-17. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)
{
  "rows": [
    {
      "page": "example.com",
      "ttfb_ms": 445,
      "connect_ms": 295,
      "wait_ms": 150,
      "fcp_ms": 480,
      "lcp_ms": 592,
      "lcp_element": "p",
      "cls": 0,
      "load_ms": 582,
      "script_ms": 3,
      "layouts": 3,
      "dom_nodes": 60,
      "js_heap_mb": 1.1
    },
    {
      "page": "books.toscrape.com",
      "ttfb_ms": 856,
      "connect_ms": 639,
      "wait_ms": 217,
      "fcp_ms": 1348,
      "lcp_ms": 1352,
      "lcp_element": "img",
      "cls": 0.002,
      "load_ms": 1566,
      "script_ms": 18,
      "layouts": 14,
      "dom_nodes": 1772,
      "js_heap_mb": 2
    },
    {
      "page": "Wikipedia article",
      "ttfb_ms": 726,
      "connect_ms": 465,
      "wait_ms": 261,
      "fcp_ms": 1124,
      "lcp_ms": 1124,
      "lcp_element": "p",
      "cls": 0.095,
      "load_ms": 7510,
      "script_ms": 171,
      "layouts": 13,
      "dom_nodes": 9495,
      "js_heap_mb": 8.8
    },
    {
      "page": "shifting demo",
      "ttfb_ms": 36,
      "connect_ms": 0,
      "wait_ms": 36,
      "fcp_ms": 64,
      "lcp_ms": 64,
      "lcp_element": "p",
      "cls": 0.234,
      "load_ms": 45,
      "script_ms": 1,
      "layouts": 2,
      "dom_nodes": 87,
      "js_heap_mb": 1.1
    }
  ]
}

Takeaways