Cases / #5 · 2026-09-30 · Hard
Four Chromium builds against common bot checks, headless and headful
chromium, Chrome, Patchright and CloakBrowser through the checks detection scripts run first — and why headless mode is the loudest signal.
Chromium Google Chrome Patchright CloakBrowser
Run on production on 2026-09-30: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
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. |
cloakbrowser | Chromium with CloakBrowser's source-level fingerprint 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 eight combinations (4 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', 'cloakbrowser'];
// 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", "cloakbrowser"]
# 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", "cloakbrowser")) {
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", "cloakbrowser" };
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", "cloakbrowser"}
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 | — |
| cloakbrowser | headless | no | no | 5 | no | ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 3080/PCIe/SSE2, OpenGL 4.5.0 NVIDIA 565.77) |
| cloakbrowser | headful | no | no | 5 | no | ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 5070 Ti Laptop GPU/PCIe/SSE2, OpenGL 4.5.0 NVIDIA 565.77) |
From the Node.js run on 2026-09-30. 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": "153.0.8010.36",
"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": "153.0.8010.36",
"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": null,
"notification_permission_mismatch": false,
"outer_minus_inner_height": 85
},
{
"build": "cloakbrowser",
"mode": "headless",
"version": "146.0.7680.177",
"webdriver": false,
"headless_in_ua": false,
"window_chrome": true,
"plugins": 5,
"languages": "en-US",
"webgl_renderer": "ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 3080/PCIe/SSE2, OpenGL 4.5.0 NVIDIA 565.77)",
"notification_permission_mismatch": false,
"outer_minus_inner_height": 85
},
{
"build": "cloakbrowser",
"mode": "headful",
"version": "146.0.7680.177",
"webdriver": false,
"headless_in_ua": false,
"window_chrome": true,
"plugins": 5,
"languages": "en-US",
"webgl_renderer": "ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 5070 Ti Laptop GPU/PCIe/SSE2, OpenGL 4.5.0 NVIDIA 565.77)",
"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. - CloakBrowser passes in both modes — no headless marker, 5 plugins, consistent permissions.
- The GPU is the next tell: chromium, Chrome and Patchright report Google SwiftShader (a software renderer — "this is a VM"), or hide the renderer entirely. Only CloakBrowser 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.