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.
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
- Launch headless Chromium behind a proxy.
- For each page: new context, CDP session,
Performance.enable, load, wait 1.5 s for late content. - 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:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// 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();
}
# 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()
// 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();
}
}
}
}
// 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 get github.com/playwright-community/[email protected]
// 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
evaluateaway. Observers withbuffered: 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; for slow networks, case 36. The CDP part needs a Chromium-based browser.