[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #55 · 2026-10-17 · Example · Easy · for [testing](https://cdpfleet.com/cross-browser-testing), [monitoring](https://cdpfleet.com/monitoring)

# 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](https://cdpfleet.com/docs/browsers/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

| What | Why |
| --- | --- |
| `PerformanceObserver` with `buffered: true` | Run after the load, it still returns the `largest-contentful-paint` and `layout-shift` entries the page recorded earlier — no init script needed. |
| Navigation Timing | `responseStart` 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.getMetrics` | Chromium's counters over the normal Playwright connection: script time, layout count, DOM nodes, JS heap. |
| A fresh context per page, 1280×800 | Cold cache and the same viewport every time, as a lab run should be. |
| A made-up shifting page via `context.route` | A 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](https://github.com/cdpfleet/cdpfleet/tree/main/cases/55-core-web-vitals-via-cdp), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)
-   `PROXY_URL` — your proxy, e.g. `http://user:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// 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();
}
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
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.
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>" * 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.
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),
    wait_ms: Math.round(nav.responseStart - nav.requestStart),
    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);
})"""

res = requests.post("https://starter.cdpfleet.com/chromium/session",
    headers={"x-api-key": KEY},
    json={"proxy": os.environ["PROXY_URL"], "headless": "new"}, timeout=60)
if not res.ok:
    raise SystemExit(f"launch {res.status_code} {res.text}")

with sync_playwright() as p:
    browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
    try:
        rows = []
        for name, url in PAGES:
            context = browser.new_context(viewport={"width": 1280, "height": 800})  # cold cache
            context.route("https://shift.test/**", lambda route: route.fulfill(content_type="text/html; charset=utf-8", body=SHIFTING))
            page = context.new_page()
            # A CDP session over the normal Playwright connection: the browser's own counters.
            cdp = context.new_cdp_session(page)
            cdp.send("Performance.enable")
            page.goto(url, wait_until="load", timeout=60000)
            page.wait_for_timeout(1500)  # let late content (and late shifts) land
            vitals = page.evaluate(VITALS)
            metrics = {m["name"]: m["value"] for m in cdp.send("Performance.getMetrics")["metrics"]}
            rows.append({
                "page": name, **vitals,
                "script_ms": round(metrics["ScriptDuration"] * 1000),
                "layouts": int(metrics["LayoutCount"]),
                "dom_nodes": int(metrics["Nodes"]),
                "js_heap_mb": round(metrics["JSHeapUsedSize"] / 1048576, 1),
            })
            context.close()
        print(json.dumps({"rows": rows}, indent=2))
    finally:
        browser.close()
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.WaitUntilState;
import java.net.URI;
import java.net.http.*;
import java.util.*;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final String[][] 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.
  static final String SHIFTING = "<!doctype html><meta charset=\"utf-8\"><title>shift</title>\n"
      + "<body style=\"margin:0;font:18px sans-serif\"><div id=\"slot\"></div>\n<h1>Article</h1>"
      + "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>".repeat(30)
      + "\n<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.
  static final String 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(JSON.stringify({
          ttfb_ms: Math.round(nav.responseStart),
          connect_ms: Math.round(nav.connectEnd - nav.domainLookupStart),
          wait_ms: Math.round(nav.responseStart - nav.requestStart),
          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);
      })""";

  public static void main(String[] args) throws Exception {
    Gson gson = new GsonBuilder().serializeNulls().create();
    String body = "{\"proxy\": " + gson.toJson(System.getenv("PROXY_URL")) + ", \"headless\": \"new\"}";
    HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
        .header("x-api-key", KEY).header("content-type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
    String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();

    try (Playwright playwright = Playwright.create()) {
      Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
      try {
        JsonArray rows = new JsonArray();
        for (String[] p : PAGES) {
          BrowserContext context = browser.newContext(new Browser.NewContextOptions().setViewportSize(1280, 800)); // cold cache
          context.route("https://shift.test/**", route -> route.fulfill(new Route.FulfillOptions().setContentType("text/html; charset=utf-8").setBody(SHIFTING)));
          Page page = context.newPage();
          // A CDP session over the normal Playwright connection: the browser's own counters.
          CDPSession cdp = context.newCDPSession(page);
          cdp.send("Performance.enable");
          page.navigate(p[1], new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD).setTimeout(60000));
          page.waitForTimeout(1500); // let late content (and late shifts) land
          JsonObject row = new JsonObject();
          row.addProperty("page", p[0]);
          // The vitals come back as a JSON string, which Gson turns into the row directly.
          JsonObject vitals = JsonParser.parseString((String) page.evaluate(VITALS)).getAsJsonObject();
          for (Map.Entry<String, JsonElement> e : vitals.entrySet()) row.add(e.getKey(), e.getValue());
          Map<String, Double> metrics = new HashMap<>();
          for (JsonElement m : cdp.send("Performance.getMetrics").getAsJsonArray("metrics"))
            metrics.put(m.getAsJsonObject().get("name").getAsString(), m.getAsJsonObject().get("value").getAsDouble());
          row.addProperty("script_ms", Math.round(metrics.get("ScriptDuration") * 1000));
          row.addProperty("layouts", metrics.get("LayoutCount").longValue());
          row.addProperty("dom_nodes", metrics.get("Nodes").longValue());
          row.addProperty("js_heap_mb", Math.round(metrics.get("JSHeapUsedSize") / 1048576 * 10) / 10.0);
          rows.add(row);
          context.close();
        }
        JsonObject out = new JsonObject();
        out.add("rows", rows);
        System.out.println(new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
      } finally {
        browser.close();
      }
    }
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;

var pages = new (string Name, string Url)[]
{
    ("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.
var shifting = "<!doctype html><meta charset=\"utf-8\"><title>shift</title>\n"
    + "<body style=\"margin:0;font:18px sans-serif\"><div id=\"slot\"></div>\n<h1>Article</h1>"
    + string.Concat(Enumerable.Repeat("<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>", 30))
    + "\n<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 string 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),
    wait_ms: Math.round(nav.responseStart - nav.requestStart),
    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);
})
""";

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
    new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = "new" });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;

using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
    var rows = new JsonArray();
    foreach (var (name, url) in pages)
    {
        var context = await browser.NewContextAsync(new() { ViewportSize = new() { Width = 1280, Height = 800 } }); // cold cache
        await context.RouteAsync("https://shift.test/**", route => route.FulfillAsync(new() { ContentType = "text/html; charset=utf-8", Body = shifting }));
        var page = await context.NewPageAsync();
        // A CDP session over the normal Playwright connection: the browser's own counters.
        var cdp = await context.NewCDPSessionAsync(page);
        await cdp.SendAsync("Performance.enable");
        await page.GotoAsync(url, new() { WaitUntil = WaitUntilState.Load, Timeout = 60000 });
        await page.WaitForTimeoutAsync(1500); // let late content (and late shifts) land
        var vitals = await page.EvaluateAsync<JsonElement>(Vitals);
        var row = new JsonObject { ["page"] = name };
        foreach (var p in vitals.EnumerateObject()) row[p.Name] = JsonNode.Parse(p.Value.GetRawText());
        var metrics = (await cdp.SendAsync("Performance.getMetrics"))!.Value.GetProperty("metrics").EnumerateArray()
            .ToDictionary(m => m.GetProperty("name").GetString()!, m => m.GetProperty("value").GetDouble());
        row["script_ms"] = Math.Round(metrics["ScriptDuration"] * 1000);
        row["layouts"] = (long)metrics["LayoutCount"];
        row["dom_nodes"] = (long)metrics["Nodes"];
        row["js_heap_mb"] = Math.Round(metrics["JSHeapUsedSize"] / 1048576, 1);
        rows.Add(row);
        await context.CloseAsync();
    }
    var result = new JsonObject { ["rows"] = rows };
    Console.WriteLine(result.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
    await browser.CloseAsync();
}
```

```go
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"strings"

	"github.com/playwright-community/playwright-go"
)

var key = os.Getenv("CDPFLEET_API_KEY")

var pages = [][2]string{
	{"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.
var 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>` + strings.Repeat("<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>", 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 vitalsJS = `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(JSON.stringify({
    ttfb_ms: Math.round(nav.responseStart),
    connect_ms: Math.round(nav.connectEnd - nav.domainLookupStart),
    wait_ms: Math.round(nav.responseStart - nav.requestStart),
    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);
})`

type row struct {
	Page       string  `json:"page"`
	TTFBMs     int     `json:"ttfb_ms"`
	ConnectMs  int     `json:"connect_ms"`
	WaitMs     int     `json:"wait_ms"`
	FCPMs      *int    `json:"fcp_ms"`
	LCPMs      *int    `json:"lcp_ms"`
	LCPElement *string `json:"lcp_element"`
	CLS        float64 `json:"cls"`
	LoadMs     int     `json:"load_ms"`
	ScriptMs   int64   `json:"script_ms"`
	Layouts    int64   `json:"layouts"`
	DOMNodes   int64   `json:"dom_nodes"`
	JSHeapMB   float64 `json:"js_heap_mb"`
}

func launch(name string, options map[string]any) (map[string]any, error) {
	body, _ := json.Marshal(options)
	req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
	req.Header.Set("x-api-key", key)
	req.Header.Set("content-type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		msg, _ := io.ReadAll(res.Body)
		return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
	}
	var session map[string]any
	return session, json.NewDecoder(res.Body).Decode(&session)
}

func measure(browser playwright.Browser, name, url string) (row, error) {
	r := row{Page: name}
	context, err := browser.NewContext(playwright.BrowserNewContextOptions{Viewport: &playwright.Size{Width: 1280, Height: 800}}) // cold cache
	if err != nil {
		return r, err
	}
	defer context.Close()
	context.Route("https://shift.test/**", func(route playwright.Route) {
		route.Fulfill(playwright.RouteFulfillOptions{ContentType: playwright.String("text/html; charset=utf-8"), Body: shifting})
	})
	page, err := context.NewPage()
	if err != nil {
		return r, err
	}
	// A CDP session over the normal Playwright connection: the browser's own counters.
	cdp, err := context.NewCDPSession(page)
	if err != nil {
		return r, err
	}
	if _, err := cdp.Send("Performance.enable", nil); err != nil {
		return r, err
	}
	if _, err := page.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateLoad, Timeout: playwright.Float(60000)}); err != nil {
		return r, err
	}
	page.WaitForTimeout(1500) // let late content (and late shifts) land
	vitals, err := page.Evaluate(vitalsJS)
	if err != nil {
		return r, err
	}
	if err := json.Unmarshal([]byte(vitals.(string)), &r); err != nil {
		return r, err
	}
	result, err := cdp.Send("Performance.getMetrics", nil)
	if err != nil {
		return r, err
	}
	metrics := map[string]float64{}
	for _, m := range result.(map[string]any)["metrics"].([]any) {
		mm := m.(map[string]any)
		metrics[mm["name"].(string)] = toFloat(mm["value"])
	}
	r.ScriptMs = int64(math.Round(metrics["ScriptDuration"] * 1000))
	r.Layouts = int64(metrics["LayoutCount"])
	r.DOMNodes = int64(metrics["Nodes"])
	r.JSHeapMB = math.Round(metrics["JSHeapUsedSize"]/1048576*10) / 10
	return r, nil
}

func toFloat(v any) float64 {
	switch n := v.(type) {
	case float64:
		return n
	case int:
		return float64(n)
	case int64:
		return float64(n)
	}
	return 0
}

func run() ([]row, error) {
	session, err := launch("chromium", map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new"})
	if err != nil {
		return nil, err
	}
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		return nil, err
	}
	defer pw.Stop()
	browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		return nil, err
	}
	defer browser.Close()
	rows := []row{}
	for _, p := range pages {
		r, err := measure(browser, p[0], p[1])
		if err != nil {
			return rows, err
		}
		rows = append(rows, r)
	}
	return rows, nil
}

func main() {
	rows, err := run()
	if err != nil {
		log.Fatal(err)
	}
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	enc.SetIndent("", "  ")
	enc.Encode(map[string]any{"rows": rows})
	fmt.Print(buf.String())
}
```

## What we got

| Page | TTFB (ms) | …connect (ms) | …server wait (ms) | FCP (ms) | LCP (ms) | LCP element | CLS | Load (ms) | Script (ms) | DOM nodes | JS heap (MB) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| example.com | 445 | 295 | 150 | 480 | 592 | p | 0 | 582 | 3 | 60 | 1.1 |
| books.toscrape.com | 856 | 639 | 217 | 1348 | 1352 | img | 0.002 | 1566 | 18 | 1772 | 2 |
| Wikipedia article | 726 | 465 | 261 | 1124 | 1124 | p | 0.095 | 7510 | 171 | 9495 | 8.8 |
| shifting demo | 36 | 0 | 36 | 64 | 64 | p | 0.234 | 45 | 1 | 87 | 1.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

-   **Vitals are one `evaluate` away.** Observers with `buffered: true`, created after the load, still hand back the LCP and layout-shift entries, so the same few lines of page script work from all five languages — no init script, no Lighthouse.
-   **Through a proxy, TTFB includes the proxy.** TTFB splits cleanly into reaching the server (DNS, TCP, TLS — via the proxy) and the server's own wait. Reaching it took 226–1,975 ms in the published runs, usually more than the server's 89–686 ms wait (57–82 % of TTFB there; one rerun had it the other way round). To catch a slower backend, track `responseStart − requestStart`, not TTFB, and compare runs from the same proxy and country.
-   **CLS catches a late banner:** the made-up page scored 0.234 in every run (above 0.1, Google's "good" line). The real pages barely moved — except the Wikipedia article, which scored 0.001 in three runs and 0.095 and 0.343 in the other two: one lab run is not a verdict, repeat it and look at the 75th percentile, as field data does.
-   **Numbers swing between runs, ranks don't.** The LCP element was always an image on books.toscrape.com and a paragraph on example.com and Wikipedia, but books.toscrape.com's LCP ranged 1.35–4.68 s across five runs on different machines and proxy paths.
-   **CDP counters show bloat the network hides:** the Wikipedia article had ~9,400 DOM nodes (29,518 in one run, where a different variant of the page came back), 104–212 ms of script and 4.8–9.6 MB of JS heap, against ~1,700 nodes and ~20 ms on books.toscrape.com. A release that doubles these is worth a look whatever the network did.
-   **Lab, not field.** These are synthetic loads — no user input (so no INP), the fleet's CPUs and your proxy's path. Use them for regressions and for "how does it load from Germany through a residential exit"; use real-user data for what users get. To slow the CPU down like a phone, see [case 56](https://cdpfleet.com/docs/cases/cpu-throttling-via-cdp); for slow networks, [case 36](https://cdpfleet.com/docs/cases/slow-network). The CDP part needs a Chromium-based browser.

---
Source: https://cdpfleet.com/docs/cases/core-web-vitals-via-cdp · Markdown: https://cdpfleet.com/docs/cases/core-web-vitals-via-cdp.md · All docs for AI tools: https://cdpfleet.com/llms.txt
