Cases / #42 · 2026-10-13 · Easy
Puppeteer on real Google Chrome: cdpUrl, raw CDP and request interception
Drive real Chrome on cdpfleet with Puppeteer (or any CDP client): what the browser reports, raw CDP calls, blocking images, and closing the session.
Run on production on 2026-10-13: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
A lot of browser automation is written for Puppeteer or speaks the Chrome DevTools Protocol directly. With "cdp": true, a cdpfleet launch returns a cdpUrl for exactly those clients — but is the browser on the other end really Google Chrome, can you send raw CDP commands, does Puppeteer's request interception work through it, and what happens to the session when the client closes? Case 26 compared Playwright's own protocol with CDP; this one is about using CDP the way Puppeteer users do.
What we used, and why
| What | Why |
|---|---|
chrome with "cdp": true | Real Google Chrome, headless; the launch response carries cdpUrl next to wsUrl. See CDP & Puppeteer. |
Node: puppeteer.connect({ browserWSEndpoint: cdpUrl, headers }) | Puppeteer (puppeteer-core 24, no browser download). Its version doesn't have to match anything on our side. |
Python, Java, C#, Go: connectOverCDP(cdpUrl) | These languages have no maintained Puppeteer, so they use Playwright's CDP client plus raw CDP sessions (newBrowserCDPSession, newCDPSession) for the same commands. Go's chromedp and Rod connect to cdpUrl too. |
Browser.getVersion, Target.getTargets | Raw CDP at the browser level: the product string, the protocol version and the page targets. |
| Request interception | Puppeteer's setRequestInterception / Playwright's route: abort every image request on books.toscrape.com and compare the bytes the page pulled, from its own resource timing, each load in a fresh context. |
Performance.getMetrics | Page metrics over raw CDP — Puppeteer wraps it as page.metrics(). |
GET /v1/me/threads | After the client closes, how long until the session is gone from the live list. |
How it works
- Launch Chrome with
"cdp": trueand connect tocdpUrl. - Ask the browser for its version and count its page targets over raw CDP.
- Load books.toscrape.com in a fresh context; load it again in another fresh context with images aborted; count targets again.
- Read the first page's performance metrics over CDP, then close the client and watch the session disappear.
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 puppeteer-core@24
// env: CDPFLEET_API_KEY, PROXY_URL
// Puppeteer on real Google Chrome: launch with "cdp": true and connect to cdpUrl. No browser
// download is needed (puppeteer-core), and the Puppeteer version doesn't have to match anything.
import puppeteer from 'puppeteer-core';
const KEY = process.env.CDPFLEET_API_KEY;
const URL = 'https://books.toscrape.com/';
// Bytes the page pulled over the network, from its own resource timing (fresh context: no cache).
const TRANSFER = `(() => {
const all = [...performance.getEntriesByType('navigation'), ...performance.getEntriesByType('resource')];
return { resources: all.length, transfer_kb: Math.round(all.reduce((n, e) => n + (e.transferSize || 0), 0) / 1024) };
})()`;
async function launch() {
const res = await fetch('https://starter.cdpfleet.com/chrome/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: 'new', cdp: true, record: false }),
});
if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
return res.json();
}
async function isLive(sessionId) {
const res = await fetch('https://cdpfleet.com/v1/me/threads', { headers: { 'x-api-key': KEY } });
return (await res.text()).includes(sessionId);
}
async function goto(page) {
for (let attempt = 1; ; attempt++) { // the proxy can drop a tunnel; retry
try { await page.goto(URL, { waitUntil: 'load', timeout: 60000 }); return; } catch (err) { if (attempt === 3) throw err; }
}
}
// One page in a fresh browser context, optionally aborting image requests.
async function load(browser, blockImages) {
const context = await browser.createBrowserContext();
const page = await context.newPage();
let blocked = 0;
if (blockImages) {
await page.setRequestInterception(true);
page.on('request', (req) => {
if (req.resourceType() === 'image') { blocked++; req.abort(); } else req.continue();
});
}
const t = Date.now();
await goto(page);
const loadMs = Date.now() - t;
const transfer = await page.evaluate(TRANSFER);
return { context, page, row: { label: blockImages ? 'images blocked' : 'everything', ...transfer, images_blocked: blocked, load_ms: loadMs } };
}
const s = await launch();
const t = Date.now();
const browser = await puppeteer.connect({ browserWSEndpoint: s.cdpUrl, headers: { 'x-api-key': KEY }, defaultViewport: null });
const connectMs = Date.now() - t;
const out = { client: 'Puppeteer (puppeteer-core 24)', connect_ms: connectMs };
try {
// Raw CDP at the browser level: what's really on the other end.
const cdp = await browser.target().createCDPSession();
const version = await cdp.send('Browser.getVersion');
out.product = version.product;
out.protocol_version = version.protocolVersion;
const pages = async () => (await cdp.send('Target.getTargets')).targetInfos.filter((x) => x.type === 'page').length;
const before = await pages();
const full = await load(browser, false);
const lean = await load(browser, true);
out.targets = { pages_before: before, pages_after: await pages() };
out.loads = [full.row, lean.row];
// Puppeteer wraps Performance.getMetrics as page.metrics().
const m = await full.page.metrics();
out.metrics = { dom_nodes: m.Nodes, js_heap_used_mb: Math.round(m.JSHeapUsedSize / 1048576 * 10) / 10 };
await full.context.close();
await lean.context.close();
} finally {
await browser.close(); // closes the connection, which ends the cdpfleet session
}
const closed = Date.now();
while (await isLive(s.sessionId)) {
if (Date.now() - closed > 60000) break;
await new Promise((r) => setTimeout(r, 1000));
}
out.session_ended_s = (await isLive(s.sessionId)) ? null : Math.round((Date.now() - closed) / 100) / 10;
console.log(JSON.stringify(out, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
# Python has no maintained Puppeteer, so this is Playwright over CDP: connect_over_cdp(cdpUrl)
# plus raw CDP sessions for the same browser- and page-level commands Puppeteer sends.
import json
import os
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
URL = "https://books.toscrape.com/"
# Bytes the page pulled over the network, from its own resource timing (fresh context: no cache).
TRANSFER = """() => {
const all = [...performance.getEntriesByType('navigation'), ...performance.getEntriesByType('resource')];
return { resources: all.length, transfer_kb: Math.round(all.reduce((n, e) => n + (e.transferSize || 0), 0) / 1024) };
}"""
def launch():
res = requests.post(
"https://starter.cdpfleet.com/chrome/session",
headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": "new", "cdp": True, "record": False},
timeout=60,
)
res.raise_for_status()
return res.json()
def is_live(session_id):
return session_id in requests.get("https://cdpfleet.com/v1/me/threads", headers={"x-api-key": KEY}, timeout=30).text
def goto(page):
for attempt in range(1, 4): # the proxy can drop a tunnel; retry
try:
page.goto(URL, wait_until="load", timeout=60000)
return
except Exception:
if attempt == 3:
raise
def load(browser, block_images):
"""One page in a fresh browser context, optionally aborting image requests."""
context = browser.new_context()
page = context.new_page()
blocked = 0
if block_images:
def route(r):
nonlocal blocked
if r.request.resource_type == "image":
blocked += 1
r.abort()
else:
r.continue_()
page.route("**/*", route)
t = time.time()
goto(page)
load_ms = round((time.time() - t) * 1000)
transfer = page.evaluate(TRANSFER)
row = {"label": "images blocked" if block_images else "everything", **transfer, "images_blocked": blocked, "load_ms": load_ms}
return context, page, row
s = launch()
out = {"client": "Playwright 1.60 connect_over_cdp"}
with sync_playwright() as p:
t = time.time()
browser = p.chromium.connect_over_cdp(s["cdpUrl"], headers={"x-api-key": KEY})
out["connect_ms"] = round((time.time() - t) * 1000)
try:
# Raw CDP at the browser level: what's really on the other end.
cdp = browser.new_browser_cdp_session()
version = cdp.send("Browser.getVersion")
out["product"] = version["product"]
out["protocol_version"] = version["protocolVersion"]
def pages():
return len([x for x in cdp.send("Target.getTargets")["targetInfos"] if x["type"] == "page"])
before = pages()
full_ctx, full_page, full_row = load(browser, False)
lean_ctx, _, lean_row = load(browser, True)
out["targets"] = {"pages_before": before, "pages_after": pages()}
out["loads"] = [full_row, lean_row]
# Performance.getMetrics over a page-level CDP session (Puppeteer's page.metrics()).
page_cdp = full_ctx.new_cdp_session(full_page)
page_cdp.send("Performance.enable")
m = {x["name"]: x["value"] for x in page_cdp.send("Performance.getMetrics")["metrics"]}
out["metrics"] = {"dom_nodes": int(m["Nodes"]), "js_heap_used_mb": round(m["JSHeapUsedSize"] / 1048576, 1)}
full_ctx.close()
lean_ctx.close()
finally:
browser.close() # closes the connection, which ends the cdpfleet session
closed = time.time()
while is_live(s["sessionId"]) and time.time() - closed < 60:
time.sleep(1)
out["session_ended_s"] = None if is_live(s["sessionId"]) else round(time.time() - closed, 1)
print(json.dumps(out, indent=2))
// Maven: com.microsoft.playwright:playwright:1.60.0 and com.google.code.gson:gson:2.11.0
// env: CDPFLEET_API_KEY, PROXY_URL
// Java has no maintained Puppeteer, so this is Playwright over CDP: connectOverCDP(cdpUrl)
// plus raw CDP sessions for the same browser- and page-level commands Puppeteer sends.
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 URL = "https://books.toscrape.com/";
static final HttpClient HTTP = HttpClient.newHttpClient();
// Bytes the page pulled over the network, from its own resource timing (fresh context: no cache).
static final String TRANSFER = "() => {"
+ " const all = [...performance.getEntriesByType('navigation'), ...performance.getEntriesByType('resource')];"
+ " return { resources: all.length, transfer_kb: Math.round(all.reduce((n, e) => n + (e.transferSize || 0), 0) / 1024) }; }";
static boolean isLive(String sessionId) throws Exception {
HttpResponse<String> r = HTTP.send(HttpRequest.newBuilder(URI.create("https://cdpfleet.com/v1/me/threads")).header("x-api-key", KEY).build(),
HttpResponse.BodyHandlers.ofString());
return r.body().contains(sessionId);
}
static void gotoWithRetry(Page page) {
for (int attempt = 1; ; attempt++) { // the proxy can drop a tunnel; retry
try {
page.navigate(URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD).setTimeout(60000));
return;
} catch (PlaywrightException e) {
if (attempt == 3) throw e;
}
}
}
record Load(BrowserContext context, Page page, JsonObject row) {}
// One page in a fresh browser context, optionally aborting image requests.
static Load load(Browser browser, boolean blockImages) {
BrowserContext context = browser.newContext();
Page page = context.newPage();
int[] blocked = {0};
if (blockImages) {
page.route("**/*", route -> {
if (route.request().resourceType().equals("image")) { blocked[0]++; route.abort(); } else route.resume();
});
}
long t = System.currentTimeMillis();
gotoWithRetry(page);
long loadMs = System.currentTimeMillis() - t;
JsonObject transfer = new Gson().toJsonTree(page.evaluate(TRANSFER)).getAsJsonObject();
JsonObject row = new JsonObject();
row.addProperty("label", blockImages ? "images blocked" : "everything");
row.addProperty("resources", transfer.get("resources").getAsInt());
row.addProperty("transfer_kb", transfer.get("transfer_kb").getAsInt());
row.addProperty("images_blocked", blocked[0]);
row.addProperty("load_ms", loadMs);
return new Load(context, page, row);
}
public static void main(String[] args) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("proxy", System.getenv("PROXY_URL"));
body.addProperty("headless", "new");
body.addProperty("cdp", true);
body.addProperty("record", false);
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chrome/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
JsonObject s = JsonParser.parseString(res.body()).getAsJsonObject();
String sessionId = s.get("sessionId").getAsString();
JsonObject out = new JsonObject();
out.addProperty("client", "Playwright 1.60 connectOverCDP");
try (Playwright playwright = Playwright.create()) {
long t = System.currentTimeMillis();
Browser browser = playwright.chromium().connectOverCDP(s.get("cdpUrl").getAsString(),
new BrowserType.ConnectOverCDPOptions().setHeaders(Map.of("x-api-key", KEY)));
out.addProperty("connect_ms", System.currentTimeMillis() - t);
try {
// Raw CDP at the browser level: what's really on the other end.
CDPSession cdp = browser.newBrowserCDPSession();
JsonObject version = cdp.send("Browser.getVersion");
out.addProperty("product", version.get("product").getAsString());
out.addProperty("protocol_version", version.get("protocolVersion").getAsString());
int before = pageCount(cdp);
Load full = load(browser, false);
Load lean = load(browser, true);
JsonObject targets = new JsonObject();
targets.addProperty("pages_before", before);
targets.addProperty("pages_after", pageCount(cdp));
out.add("targets", targets);
JsonArray loads = new JsonArray();
loads.add(full.row());
loads.add(lean.row());
out.add("loads", loads);
// Performance.getMetrics over a page-level CDP session (Puppeteer's page.metrics()).
CDPSession pageCdp = full.context().newCDPSession(full.page());
pageCdp.send("Performance.enable");
Map<String, Double> m = new HashMap<>();
for (JsonElement e : pageCdp.send("Performance.getMetrics").getAsJsonArray("metrics")) {
m.put(e.getAsJsonObject().get("name").getAsString(), e.getAsJsonObject().get("value").getAsDouble());
}
JsonObject metrics = new JsonObject();
metrics.addProperty("dom_nodes", m.get("Nodes").intValue());
metrics.addProperty("js_heap_used_mb", Math.round(m.get("JSHeapUsedSize") / 1048576 * 10) / 10.0);
out.add("metrics", metrics);
full.context().close();
lean.context().close();
} finally {
browser.close(); // closes the connection, which ends the cdpfleet session
}
}
long closed = System.currentTimeMillis();
while (isLive(sessionId) && System.currentTimeMillis() - closed < 60000) Thread.sleep(1000);
if (isLive(sessionId)) out.add("session_ended_s", JsonNull.INSTANCE);
else out.addProperty("session_ended_s", Math.round((System.currentTimeMillis() - closed) / 100.0) / 10.0);
System.out.println(new GsonBuilder().setPrettyPrinting().serializeNulls().create().toJson(out));
}
static int pageCount(CDPSession cdp) {
int n = 0;
for (JsonElement e : cdp.send("Target.getTargets").getAsJsonArray("targetInfos")) {
if (e.getAsJsonObject().get("type").getAsString().equals("page")) n++;
}
return n;
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
// .NET has no maintained Puppeteer port on current Chrome, so this is Playwright over CDP:
// ConnectOverCDPAsync(cdpUrl) plus raw CDP sessions for the same commands Puppeteer sends.
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
const string Url = "https://books.toscrape.com/";
// Bytes the page pulled over the network, from its own resource timing (fresh context: no cache).
const string Transfer = """
() => {
const all = [...performance.getEntriesByType('navigation'), ...performance.getEntriesByType('resource')];
return { resources: all.length, transfer_kb: Math.round(all.reduce((n, e) => n + (e.transferSize || 0), 0) / 1024) };
}
""";
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chrome/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = "new", cdp = true, record = false });
if (!res.IsSuccessStatusCode) throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var s = await res.Content.ReadFromJsonAsync<JsonElement>();
var sessionId = s.GetProperty("sessionId").GetString()!;
async Task<bool> IsLive() => (await http.GetStringAsync("https://cdpfleet.com/v1/me/threads")).Contains(sessionId);
var output = new JsonObject { ["client"] = "Playwright 1.60 ConnectOverCDPAsync" };
using (var playwright = await Playwright.CreateAsync())
{
var sw = Stopwatch.StartNew();
var browser = await playwright.Chromium.ConnectOverCDPAsync(s.GetProperty("cdpUrl").GetString()!,
new BrowserTypeConnectOverCDPOptions { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
output["connect_ms"] = sw.ElapsedMilliseconds;
try
{
// Raw CDP at the browser level: what's really on the other end.
var cdp = await browser.NewBrowserCDPSessionAsync();
var version = (await cdp.SendAsync("Browser.getVersion"))!.Value;
output["product"] = version.GetProperty("product").GetString();
output["protocol_version"] = version.GetProperty("protocolVersion").GetString();
async Task<int> Pages() => (await cdp.SendAsync("Target.getTargets"))!.Value.GetProperty("targetInfos")
.EnumerateArray().Count(x => x.GetProperty("type").GetString() == "page");
var before = await Pages();
var full = await Load(browser, false);
var lean = await Load(browser, true);
output["targets"] = new JsonObject { ["pages_before"] = before, ["pages_after"] = await Pages() };
output["loads"] = new JsonArray(full.Row, lean.Row);
// Performance.getMetrics over a page-level CDP session (Puppeteer's page.metrics()).
var pageCdp = await full.Context.NewCDPSessionAsync(full.Page);
await pageCdp.SendAsync("Performance.enable");
var m = (await pageCdp.SendAsync("Performance.getMetrics"))!.Value.GetProperty("metrics").EnumerateArray()
.ToDictionary(x => x.GetProperty("name").GetString()!, x => x.GetProperty("value").GetDouble());
output["metrics"] = new JsonObject { ["dom_nodes"] = (int)m["Nodes"], ["js_heap_used_mb"] = Math.Round(m["JSHeapUsedSize"] / 1048576, 1) };
await full.Context.CloseAsync();
await lean.Context.CloseAsync();
}
finally
{
await browser.CloseAsync(); // closes the connection, which ends the cdpfleet session
}
}
var closed = Stopwatch.StartNew();
while (await IsLive() && closed.ElapsedMilliseconds < 60000) await Task.Delay(1000);
output["session_ended_s"] = await IsLive() ? null : JsonValue.Create(Math.Round(closed.ElapsedMilliseconds / 1000.0, 1));
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
// One page in a fresh browser context, optionally aborting image requests.
async Task<(IBrowserContext Context, IPage Page, JsonObject Row)> Load(IBrowser browser, bool blockImages)
{
var context = await browser.NewContextAsync();
var page = await context.NewPageAsync();
var blocked = 0;
if (blockImages)
{
await page.RouteAsync("**/*", async route =>
{
if (route.Request.ResourceType == "image") { blocked++; await route.AbortAsync(); }
else await route.ContinueAsync();
});
}
var sw = Stopwatch.StartNew();
for (var attempt = 1; ; attempt++) // the proxy can drop a tunnel; retry
{
try { await page.GotoAsync(Url, new() { WaitUntil = WaitUntilState.Load, Timeout = 60000 }); break; }
catch (Exception e) when ((e is PlaywrightException || e is TimeoutException) && attempt < 3) { }
}
var loadMs = sw.ElapsedMilliseconds;
var t = await page.EvaluateAsync<JsonElement>(Transfer);
var row = new JsonObject
{
["label"] = blockImages ? "images blocked" : "everything",
["resources"] = t.GetProperty("resources").GetInt32(),
["transfer_kb"] = t.GetProperty("transfer_kb").GetInt32(),
["images_blocked"] = blocked,
["load_ms"] = loadMs,
};
return (context, page, row);
}
// 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
// Go's CDP-native clients (chromedp, Rod) work on cdpUrl too; this program uses playwright-go's
// ConnectOverCDP plus raw CDP sessions, so it matches the other languages command for command.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
const url = "https://books.toscrape.com/"
// Bytes the page pulled over the network, from its own resource timing (fresh context: no cache).
const transfer = `() => {
const all = [...performance.getEntriesByType('navigation'), ...performance.getEntriesByType('resource')];
return { resources: all.length, transfer_kb: Math.round(all.reduce((n, e) => n + (e.transferSize || 0), 0) / 1024) };
}`
type loadRow struct {
Label string `json:"label"`
Resources int `json:"resources"`
TransferKB int `json:"transfer_kb"`
ImagesBlocked int `json:"images_blocked"`
LoadMs int64 `json:"load_ms"`
}
type targets struct {
PagesBefore int `json:"pages_before"`
PagesAfter int `json:"pages_after"`
}
type output struct {
Client string `json:"client"`
ConnectMs int64 `json:"connect_ms"`
Product string `json:"product"`
ProtocolVersion string `json:"protocol_version"`
Targets targets `json:"targets"`
Loads []loadRow `json:"loads"`
Metrics map[string]any `json:"metrics"`
SessionEndedS *float64 `json:"session_ended_s"`
}
func launch() (map[string]any, error) {
body, _ := json.Marshal(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new", "cdp": true, "record": false})
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/chrome/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", res.Status, msg)
}
var s map[string]any
return s, json.NewDecoder(res.Body).Decode(&s)
}
func isLive(sessionID string) bool {
req, _ := http.NewRequest("GET", "https://cdpfleet.com/v1/me/threads", nil)
req.Header.Set("x-api-key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
return true
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return strings.Contains(string(b), sessionID)
}
// playwright-go hands back JSON numbers as int or float64 depending on the value.
func num(v any) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
}
return 0
}
// One page in a fresh browser context, optionally aborting image requests.
func load(browser playwright.Browser, blockImages bool) (playwright.BrowserContext, playwright.Page, loadRow, error) {
context, err := browser.NewContext()
if err != nil {
return nil, nil, loadRow{}, err
}
page, err := context.NewPage()
if err != nil {
return context, nil, loadRow{}, err
}
blocked := 0
if blockImages {
if err := page.Route("**/*", func(route playwright.Route) {
if route.Request().ResourceType() == "image" {
blocked++
route.Abort()
} else {
route.Continue()
}
}); err != nil {
return context, page, loadRow{}, err
}
}
t := time.Now()
for attempt := 1; ; attempt++ { // the proxy can drop a tunnel; retry
_, err := page.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateLoad, Timeout: playwright.Float(60000)})
if err == nil {
break
}
if attempt == 3 {
return context, page, loadRow{}, err
}
}
loadMs := time.Since(t).Milliseconds()
v, err := page.Evaluate(transfer)
if err != nil {
return context, page, loadRow{}, err
}
m := v.(map[string]any)
label := "everything"
if blockImages {
label = "images blocked"
}
return context, page, loadRow{label, num(m["resources"]), num(m["transfer_kb"]), blocked, loadMs}, nil
}
func pageCount(cdp playwright.CDPSession) (int, error) {
r, err := cdp.Send("Target.getTargets", nil)
if err != nil {
return 0, err
}
n := 0
for _, t := range r.(map[string]any)["targetInfos"].([]any) {
if t.(map[string]any)["type"] == "page" {
n++
}
}
return n, nil
}
func measure(s map[string]any, out *output) error {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
return err
}
defer pw.Stop()
t := time.Now()
browser, err := pw.Chromium.ConnectOverCDP(s["cdpUrl"].(string), playwright.BrowserTypeConnectOverCDPOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return err
}
out.ConnectMs = time.Since(t).Milliseconds()
defer browser.Close() // closes the connection, which ends the cdpfleet session
// Raw CDP at the browser level: what's really on the other end.
cdp, err := browser.NewBrowserCDPSession()
if err != nil {
return err
}
v, err := cdp.Send("Browser.getVersion", nil)
if err != nil {
return err
}
version := v.(map[string]any)
out.Product, out.ProtocolVersion = version["product"].(string), version["protocolVersion"].(string)
before, err := pageCount(cdp)
if err != nil {
return err
}
fullCtx, fullPage, fullRow, err := load(browser, false)
if err != nil {
return err
}
leanCtx, _, leanRow, err := load(browser, true)
if err != nil {
return err
}
after, err := pageCount(cdp)
if err != nil {
return err
}
out.Targets = targets{before, after}
out.Loads = []loadRow{fullRow, leanRow}
// Performance.getMetrics over a page-level CDP session (Puppeteer's page.metrics()).
pageCdp, err := fullCtx.NewCDPSession(fullPage)
if err != nil {
return err
}
if _, err := pageCdp.Send("Performance.enable", nil); err != nil {
return err
}
r, err := pageCdp.Send("Performance.getMetrics", nil)
if err != nil {
return err
}
m := map[string]float64{}
for _, x := range r.(map[string]any)["metrics"].([]any) {
e := x.(map[string]any)
m[e["name"].(string)], _ = e["value"].(float64)
}
out.Metrics = map[string]any{"dom_nodes": int(m["Nodes"]), "js_heap_used_mb": math.Round(m["JSHeapUsedSize"]/1048576*10) / 10}
fullCtx.Close()
leanCtx.Close()
return nil
}
func run() (output, error) {
out := output{Client: "playwright-go 0.6000 ConnectOverCDP"}
s, err := launch()
if err != nil {
return out, err
}
if err := measure(s, &out); err != nil {
return out, err
}
id := s["sessionId"].(string)
closed := time.Now()
for isLive(id) && time.Since(closed) < 60*time.Second {
time.Sleep(time.Second)
}
if !isLive(id) {
ended := math.Round(time.Since(closed).Seconds()*10) / 10
out.SessionEndedS = &ended
}
return out, nil
}
func main() {
out, err := run()
if err != nil {
log.Fatal(err)
}
b, _ := json.MarshalIndent(out, "", " ")
fmt.Println(string(b))
}
What we got
| Measured | Value |
|---|---|
| Client | Puppeteer (puppeteer-core 24) |
| Connect | 202 ms |
| Browser.getVersion | Chrome/155.0.8059.39 (protocol 1.3) |
| Page targets | 0 → 2 |
| Load: everything | 331 KB in 4280 ms |
| Load: images blocked | 152 KB in 1561 ms, 32 images aborted |
| Performance.getMetrics | 1640 DOM nodes, 1.2 MB JS heap |
| Session gone after close | 0.1 s |
From the Node.js run on 2026-10-13. 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)
{
"client": "Puppeteer (puppeteer-core 24)",
"connect_ms": 202,
"product": "Chrome/155.0.8059.39",
"protocol_version": "1.3",
"targets": {
"pages_before": 0,
"pages_after": 2
},
"loads": [
{
"label": "everything",
"resources": 32,
"transfer_kb": 331,
"images_blocked": 0,
"load_ms": 4280
},
{
"label": "images blocked",
"resources": 44,
"transfer_kb": 152,
"images_blocked": 32,
"load_ms": 1561
}
],
"metrics": {
"dom_nodes": 1640,
"js_heap_used_mb": 1.2
},
"session_ended_s": 0.1
}Takeaways
- It's real Google Chrome:
Browser.getVersionansweredChrome/155.0.8059.39(protocol 1.3) for Puppeteer and for Playwright's CDP client alike — notHeadlessChrome, even headless. - Connecting is quick: 0.07–0.26 s from the launch response to a connected client across the five runs.
- Request interception works over the connection: aborting images cut books.toscrape.com from 331 KB to 152 KB transferred in every run — the same with Puppeteer and with Playwright's
route(20–32 image requests aborted per run). See bandwidth diet for more ways to save proxy traffic. - Raw CDP is there when you need it: browser-level (
Target.getTargetssaw the two pages appear) and page-level (Performance.getMetrics) commands both answered. Puppeteer exposes many as methods (page.metrics()); with Playwright you open a CDP session. - Closing the client ends the session: the session left the live list 0.1–0.2 s after
browser.close(), so billing stops there. Don'tdisconnect()and expect the browser to wait — use thekeep_aliveoption for that (dedicated threads).