Examples & benchmarks / #5 · 2026-09-30 · Benchmark · Hard · for web scraping
Three Chromium builds against common bot checks, headless and headful
chromium, Chrome and Patchright through the checks detection scripts run first — and why headless mode is the loudest signal.
Chromium Google Chrome Patchright
Run on production on 2026-10-15: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
A benchmark: the results below are what this setup measured on the dates shown, on our own test runs. Sites, networks and browsers change, so rerun the code before you rely on the numbers.
The problem
cdpfleet offers several Chromium builds with different anti-detection work. Which of them pass the checks a typical detection script runs in its first milliseconds — navigator.webdriver, the user agent, plugins, WebGL renderer, permission consistency, window chrome? And how much does headless mode change the picture?
What we used, and why
| What | Why |
|---|---|
chromium | Plain open-source Chromium: the baseline. |
chrome | Real Google Chrome (stable). |
patchright | Chromium with Patchright's automation-hiding patches. |
headless: true / false | Headless costs 1 thread, headful (a virtual display) 2. Each build runs both. |
A page <script> via page.route | The checks run as a real page script on an https origin, the way a website runs them — not through page.evaluate. |
How it works
- Launch all six combinations (3 builds × headless/headful) at once.
- Serve a small detection page with
route.fulfillon an https URL and let its script run. - Collect what the script found.
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 BUILDS = ['chromium', 'chrome', 'patchright'];
// The checks a bot-detection script typically runs, as a page script (not page.evaluate),
// exactly as a website would run them.
const CHECKS = `<script>
window.__checks = (async () => {
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const perm = await navigator.permissions.query({ name: 'notifications' });
return {
webdriver: navigator.webdriver,
headless_in_ua: /Headless/.test(navigator.userAgent),
window_chrome: typeof window.chrome === 'object',
plugins: navigator.plugins.length,
languages: navigator.languages.join(','),
webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
notification_permission_mismatch: Notification.permission === 'denied' && perm.state === 'prompt',
outer_minus_inner_height: outerHeight - innerHeight,
};
})();
</script>`;
async function check(name, headless) {
const res = await fetch(`https://starter.cdpfleet.com/${name}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless }),
});
const mode = headless ? 'headless' : 'headful';
if (!res.ok) return { build: name, mode, error: `${res.status} ${await res.text()}` };
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
try {
const page = await browser.newPage();
// A real https origin: some APIs (permissions, WebGL info) behave differently on about:blank.
await page.route('https://detect.example/', (route) => route.fulfill({ contentType: 'text/html', body: CHECKS }));
await page.goto('https://detect.example/');
return { build: name, mode, version: browser.version(), ...(await page.evaluate(() => window.__checks)) };
} finally {
await browser.close();
}
}
// Every build twice: headless (1 thread) and headful on a virtual display (2 threads).
console.log(JSON.stringify(await Promise.all(BUILDS.flatMap((b) => [check(b, true), check(b, false)])), null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
from concurrent.futures import ThreadPoolExecutor
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
BUILDS = ["chromium", "chrome", "patchright"]
# The checks a bot-detection script typically runs, as a page script (not page.evaluate),
# exactly as a website would run them.
CHECKS = """<script>
window.__checks = (async () => {
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const perm = await navigator.permissions.query({ name: 'notifications' });
return {
webdriver: navigator.webdriver,
headless_in_ua: /Headless/.test(navigator.userAgent),
window_chrome: typeof window.chrome === 'object',
plugins: navigator.plugins.length,
languages: navigator.languages.join(','),
webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
notification_permission_mismatch: Notification.permission === 'denied' && perm.state === 'prompt',
outer_minus_inner_height: outerHeight - innerHeight,
};
})();
</script>"""
def check(job):
name, headless = job
mode = "headless" if headless else "headful"
res = requests.post(f"https://starter.cdpfleet.com/{name}/session", headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": headless}, timeout=60)
if not res.ok:
return {"build": name, "mode": mode, "error": f"{res.status_code} {res.text}"}
with sync_playwright() as p: # the sync API is per thread
browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_page()
# A real https origin: some APIs (permissions, WebGL info) behave differently on about:blank.
page.route("https://detect.example/", lambda route: route.fulfill(content_type="text/html", body=CHECKS))
page.goto("https://detect.example/")
return {"build": name, "mode": mode, "version": browser.version, **page.evaluate("window.__checks")}
finally:
browser.close()
# Every build twice: headless (1 thread) and headful on a virtual display (2 threads).
jobs = [(b, h) for b in BUILDS for h in (True, False)]
with ThreadPoolExecutor(len(jobs)) as pool:
print(json.dumps(list(pool.map(check, jobs)), indent=2))
// 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 java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.concurrent.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final HttpClient HTTP = HttpClient.newHttpClient();
// The checks a bot-detection script typically runs, as a page script (not page.evaluate),
// exactly as a website would run them.
static final String CHECKS = """
<script>
window.__checks = (async () => {
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const perm = await navigator.permissions.query({ name: 'notifications' });
return {
webdriver: navigator.webdriver,
headless_in_ua: /Headless/.test(navigator.userAgent),
window_chrome: typeof window.chrome === 'object',
plugins: navigator.plugins.length,
languages: navigator.languages.join(','),
webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
notification_permission_mismatch: Notification.permission === 'denied' && perm.state === 'prompt',
outer_minus_inner_height: outerHeight - innerHeight,
};
})();
</script>""";
static JsonObject check(String name, boolean headless) throws Exception {
String mode = headless ? "headless" : "headful";
String body = "{\"proxy\": " + new Gson().toJson(System.getenv("PROXY_URL")) + ", \"headless\": " + headless + "}";
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + name + "/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
JsonObject out = new JsonObject();
out.addProperty("build", name);
out.addProperty("mode", mode);
if (res.statusCode() != 200) { out.addProperty("error", res.statusCode() + " " + res.body()); return out; }
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
try (Playwright playwright = Playwright.create()) { // Playwright objects are per thread
Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Page page = browser.newPage();
// A real https origin: some APIs (permissions, WebGL info) behave differently on about:blank.
page.route("https://detect.example/", route -> route.fulfill(new Route.FulfillOptions().setContentType("text/html").setBody(CHECKS)));
page.navigate("https://detect.example/");
out.addProperty("version", browser.version());
JsonObject checks = new Gson().toJsonTree(page.evaluate("window.__checks")).getAsJsonObject();
for (String k : checks.keySet()) out.add(k, checks.get(k));
return out;
} finally {
browser.close();
}
}
}
public static void main(String[] args) throws Exception {
// Every build twice: headless (1 thread) and headful on a virtual display (2 threads).
List<Callable<JsonObject>> jobs = new ArrayList<>();
for (String b : List.of("chromium", "chrome", "patchright")) {
jobs.add(() -> check(b, true));
jobs.add(() -> check(b, false));
}
ExecutorService pool = Executors.newFixedThreadPool(jobs.size());
JsonArray rows = new JsonArray();
for (Future<JsonObject> f : pool.invokeAll(jobs)) rows.add(f.get());
pool.shutdown();
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(rows));
}
}
// 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 key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// The checks a bot-detection script typically runs, as a page script (not page.evaluate),
// exactly as a website would run them.
const string Checks = """
<script>
window.__checks = (async () => {
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const perm = await navigator.permissions.query({ name: 'notifications' });
return {
webdriver: navigator.webdriver,
headless_in_ua: /Headless/.test(navigator.userAgent),
window_chrome: typeof window.chrome === 'object',
plugins: navigator.plugins.length,
languages: navigator.languages.join(','),
webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
notification_permission_mismatch: Notification.permission === 'denied' && perm.state === 'prompt',
outer_minus_inner_height: outerHeight - innerHeight,
};
})();
</script>
""";
async Task<JsonObject> Check(string name, bool headless)
{
var mode = headless ? "headless" : "headful";
var res = await http.PostAsJsonAsync($"https://starter.cdpfleet.com/{name}/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless });
if (!res.IsSuccessStatusCode) return new JsonObject { ["build"] = name, ["mode"] = mode, ["error"] = $"{(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}" };
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var page = await browser.NewPageAsync();
// A real https origin: some APIs (permissions, WebGL info) behave differently on about:blank.
await page.RouteAsync("https://detect.example/", route => route.FulfillAsync(new() { ContentType = "text/html", Body = Checks }));
await page.GotoAsync("https://detect.example/");
var checks = JsonNode.Parse((await page.EvaluateAsync<JsonElement>("window.__checks")).GetRawText())!.AsObject();
var outp = new JsonObject { ["build"] = name, ["mode"] = mode, ["version"] = browser.Version };
foreach (var (k, v) in checks) outp[k] = v?.DeepClone();
return outp;
}
finally
{
await browser.CloseAsync();
}
}
// Every build twice: headless (1 thread) and headful on a virtual display (2 threads).
var builds = new[] { "chromium", "chrome", "patchright" };
var rows = await Task.WhenAll(builds.SelectMany(b => new[] { Check(b, true), Check(b, false) }));
Console.WriteLine(new JsonArray(rows.ToArray<JsonNode?>()).ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// 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"
"net/http"
"os"
"sync"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
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)
}
// The checks a bot-detection script typically runs, as a page script (not page.evaluate),
// exactly as a website would run them.
const checks = `<script>
window.__checks = (async () => {
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const perm = await navigator.permissions.query({ name: 'notifications' });
return {
webdriver: navigator.webdriver,
headless_in_ua: /Headless/.test(navigator.userAgent),
window_chrome: typeof window.chrome === 'object',
plugins: navigator.plugins.length,
languages: navigator.languages.join(','),
webgl_renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
notification_permission_mismatch: Notification.permission === 'denied' && perm.state === 'prompt',
outer_minus_inner_height: outerHeight - innerHeight,
};
})();
</script>`
func check(pw *playwright.Playwright, name string, headless bool) map[string]any {
mode := map[bool]string{true: "headless", false: "headful"}[headless]
fail := func(err error) map[string]any {
return map[string]any{"build": name, "mode": mode, "error": err.Error()}
}
session, err := launch(name, map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": headless})
if err != nil {
return fail(err)
}
browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return fail(err)
}
defer browser.Close()
page, _ := browser.NewPage()
// A real https origin: some APIs (permissions, WebGL info) behave differently on about:blank.
page.Route("https://detect.example/", func(route playwright.Route) {
route.Fulfill(playwright.RouteFulfillOptions{ContentType: playwright.String("text/html"), Body: checks})
})
if _, err := page.Goto("https://detect.example/"); err != nil {
return fail(err)
}
v, err := page.Evaluate("window.__checks")
if err != nil {
return fail(err)
}
out := v.(map[string]any)
out["build"], out["mode"], out["version"] = name, mode, browser.Version()
return out
}
func main() {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
// Every build twice: headless (1 thread) and headful on a virtual display (2 threads).
builds := []string{"chromium", "chrome", "patchright"}
results := make([]map[string]any, len(builds)*2)
var wg sync.WaitGroup
for i, b := range builds {
for j, headless := range []bool{true, false} {
wg.Add(1)
go func(k int, b string, headless bool) { defer wg.Done(); results[k] = check(pw, b, headless) }(i*2+j, b, headless)
}
}
wg.Wait()
out, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(out))
}
What we got
| Build | Mode | webdriver | Headless in UA | Plugins | Permission mismatch | WebGL renderer |
|---|---|---|---|---|---|---|
| chromium | headless | no | yes | 0 | yes | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) |
| chromium | headful | no | no | 5 | no | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) |
| chrome | headless | no | yes | 5 | no | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) |
| chrome | headful | no | no | 5 | no | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) |
| patchright | headless | no | yes | 0 | yes | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) |
| patchright | headful | no | no | 5 | no | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) |
From the Node.js run on 2026-10-15. 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)
[
{
"build": "chromium",
"mode": "headless",
"version": "148.0.7778.96",
"webdriver": false,
"headless_in_ua": true,
"window_chrome": false,
"plugins": 0,
"languages": "en-US",
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"notification_permission_mismatch": true,
"outer_minus_inner_height": 0
},
{
"build": "chromium",
"mode": "headful",
"version": "148.0.7778.96",
"webdriver": false,
"headless_in_ua": false,
"window_chrome": true,
"plugins": 5,
"languages": "en-US",
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"notification_permission_mismatch": false,
"outer_minus_inner_height": 85
},
{
"build": "chrome",
"mode": "headless",
"version": "155.0.8059.79",
"webdriver": false,
"headless_in_ua": true,
"window_chrome": true,
"plugins": 5,
"languages": "en-US,en",
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"notification_permission_mismatch": false,
"outer_minus_inner_height": 0
},
{
"build": "chrome",
"mode": "headful",
"version": "155.0.8059.79",
"webdriver": false,
"headless_in_ua": false,
"window_chrome": true,
"plugins": 5,
"languages": "en-US,en",
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"notification_permission_mismatch": false,
"outer_minus_inner_height": 85
},
{
"build": "patchright",
"mode": "headless",
"version": "148.0.7778.96",
"webdriver": false,
"headless_in_ua": true,
"window_chrome": false,
"plugins": 0,
"languages": "en-US",
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"notification_permission_mismatch": true,
"outer_minus_inner_height": 0
},
{
"build": "patchright",
"mode": "headful",
"version": "148.0.7778.96",
"webdriver": false,
"headless_in_ua": false,
"window_chrome": true,
"plugins": 5,
"languages": "en-US",
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"notification_permission_mismatch": false,
"outer_minus_inner_height": 85
}
]Takeaways
- No build exposes
navigator.webdriver— in either mode. - Headless is the loudest tell: chromium, Chrome and Patchright put
HeadlessChromein the user agent when headless; chromium and Patchright also report 0 plugins and a notification-permission mismatch. The same builds headful pass those checks. Budget 2 threads for headful when a site checks. - The GPU is the next tell: chromium, Chrome and Patchright all report Google SwiftShader (a software renderer — "this is a VM") — none of them reports a real-looking GPU.
- These are first-line checks. Serious vendors add many more (canvas, audio, timing, behaviour); treat this as a floor, not a guarantee.