[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #5 · 2026-09-30 · Benchmark · Hard · for [web scraping](https://cdpfleet.com/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](https://cdpfleet.com/docs/browsers/chromium) [Google Chrome](https://cdpfleet.com/docs/browsers/chrome) [Patchright](https://cdpfleet.com/docs/browsers/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

1.  Launch all six combinations (3 builds × headless/headful) at once.
2.  Serve a small detection page with `route.fulfill` on an https URL and let its script run.
3.  Collect what the script found.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/05-stealth-builds), with the raw output). Set these environment variables first:

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

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const 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));
```

```python
# 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))
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import 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));
  }
}
```

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

var 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
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"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 `HeadlessChrome` in 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.

---
Source: https://cdpfleet.com/docs/cases/stealth-builds · Markdown: https://cdpfleet.com/docs/cases/stealth-builds.md · All docs for AI tools: https://cdpfleet.com/llms.txt
