Cases / #16 · 2026-10-04 · Medium
What headless gives away, what headful fixes, and what neither does
Twelve classic detection signals read from inside the page in headless Chromium, headful Chromium, Patchright and Camoufox. Headful removes the obvious tells; the GPU and the host still talk.
Run on production on 2026-10-04: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Detection scripts don't need your TLS fingerprint to know a browser is automated: a handful of JavaScript properties give headless browsers away in a millisecond — HeadlessChrome in the user agent, an empty plugin list, a notification permission that says *denied* while the permissions API says *prompt*, a zero-sized outer window, navigator.webdriver. Running headful (the fleet's default, 2 threads) is the usual fix. But which signals does it actually fix, what do the stealth builds change on top, and what still identifies a server-side browser?
What we used, and why
| What | Why |
|---|---|
chromium with headless: "new" and with headless: false | The same build, with and without a display, so the difference is the display alone. |
patchright, headful | Playwright's Chromium with the automation leaks patched — what changes beyond headful. |
camoufox, headful, os: "windows" | A build that spoofs the device at the C++ level: screen, GPU, cores and all. |
main_world_eval: true and the mw: prefix | Camoufox runs page.evaluate in an isolated world; the page's own world is what a detection script sees. |
navigator.permissions.query vs Notification.permission | The classic headless contradiction: *prompt* from one API, *denied* from the other. |
WEBGL_debug_renderer_info | The unmasked GPU renderer string — the signal that outlives headful. |
How it works
- Launch the four browsers through the same proxy.
- Open a page and read twelve properties the way a detection script would: user agent,
navigator.webdriver, plugins, the notification contradiction, outer window size, screen, WebGL renderer, cores, memory, languages,window.chrome. - Count which of the five hard tells fire for each browser and print one row per browser.
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, firefox } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;
// Four ways to run a browser; the same twelve signals read from inside the page.
const TARGETS = [
{ label: 'Chromium, headless', engine: 'chromium', type: chromium, body: { headless: 'new' } },
{ label: 'Chromium, headful', engine: 'chromium', type: chromium, body: { headless: false } },
{ label: 'Patchright, headful', engine: 'patchright', type: chromium, body: { headless: false } },
// Camoufox: read the page's own world ("mw:" needs main_world_eval), like a site would.
{ label: 'Camoufox, headful', engine: 'camoufox', type: firefox, body: { headless: false, os: 'windows', main_world_eval: true }, prefix: 'mw:' },
];
// The classic headless and automation tells, read the way a detection script reads them.
const SIGNALS = `(async () => {
let query = null;
try { query = (await navigator.permissions.query({ name: 'notifications' })).state; } catch { query = 'error'; }
const gl = (() => {
try { const g = document.createElement('canvas').getContext('webgl'); const d = g.getExtension('WEBGL_debug_renderer_info'); return g.getParameter(d ? d.UNMASKED_RENDERER_WEBGL : g.RENDERER); } catch { return null; }
})();
return {
ua_says_headless: /Headless/.test(navigator.userAgent),
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
notification_mismatch: typeof Notification !== 'undefined' && Notification.permission === 'denied' && query === 'prompt',
outer_window_zero: outerWidth === 0 || outerHeight === 0,
screen: screen.width + 'x' + screen.height,
webgl_renderer: gl,
cores: navigator.hardwareConcurrency,
memory_gb: navigator.deviceMemory ?? null,
languages: navigator.languages.join(','),
chrome_object: typeof window.chrome === 'object' && window.chrome !== null,
};
})()`;
async function inspect({ label, engine, type, body, prefix = '' }) {
const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: PROXY, ...body }),
});
if (!res.ok) return { label, error: `launch ${res.status} ${await res.text()}` };
const { wsUrl, weight } = await res.json();
const browser = await type.connect(wsUrl, { headers: { 'x-api-key': KEY } });
try {
const page = await browser.newPage();
await page.goto('https://example.com/', { timeout: 60000 });
const signals = await page.evaluate(prefix + SIGNALS);
const tells = ['ua_says_headless', 'webdriver', 'notification_mismatch', 'outer_window_zero'].filter((k) => signals[k]);
return { label, threads: weight, ...signals, tells: tells.length ? tells.join(', ') : 'none' };
} finally {
await browser.close();
}
}
console.log(JSON.stringify(await Promise.all(TARGETS.map(inspect)), 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"]
PROXY = os.environ["PROXY_URL"]
# Four ways to run a browser; the same twelve signals read from inside the page.
TARGETS = [
{"label": "Chromium, headless", "engine": "chromium", "family": "chromium", "body": {"headless": "new"}},
{"label": "Chromium, headful", "engine": "chromium", "family": "chromium", "body": {"headless": False}},
{"label": "Patchright, headful", "engine": "patchright", "family": "chromium", "body": {"headless": False}},
# Camoufox: read the page's own world ("mw:" needs main_world_eval), like a site would.
{"label": "Camoufox, headful", "engine": "camoufox", "family": "firefox",
"body": {"headless": False, "os": "windows", "main_world_eval": True}, "prefix": "mw:"},
]
# The classic headless and automation tells, read the way a detection script reads them.
SIGNALS = """(async () => {
let query = null;
try { query = (await navigator.permissions.query({ name: 'notifications' })).state; } catch { query = 'error'; }
const gl = (() => {
try { const g = document.createElement('canvas').getContext('webgl'); const d = g.getExtension('WEBGL_debug_renderer_info'); return g.getParameter(d ? d.UNMASKED_RENDERER_WEBGL : g.RENDERER); } catch { return null; }
})();
return {
ua_says_headless: /Headless/.test(navigator.userAgent),
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
notification_mismatch: typeof Notification !== 'undefined' && Notification.permission === 'denied' && query === 'prompt',
outer_window_zero: outerWidth === 0 || outerHeight === 0,
screen: screen.width + 'x' + screen.height,
webgl_renderer: gl,
cores: navigator.hardwareConcurrency,
memory_gb: navigator.deviceMemory ?? null,
languages: navigator.languages.join(','),
chrome_object: typeof window.chrome === 'object' && window.chrome !== null,
};
})()"""
TELLS = ["ua_says_headless", "webdriver", "notification_mismatch", "outer_window_zero"]
def inspect(target):
res = requests.post(f"https://starter.cdpfleet.com/{target['engine']}/session", headers={"x-api-key": KEY},
json={"proxy": PROXY, **target["body"]}, timeout=60)
if not res.ok:
return {"label": target["label"], "error": f"launch {res.status_code} {res.text}"}
session = res.json()
with sync_playwright() as p: # the sync API is per thread
browser = getattr(p, target["family"]).connect(session["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_page()
page.goto("https://example.com/", timeout=60000)
signals = page.evaluate(target.get("prefix", "") + SIGNALS)
tells = [k for k in TELLS if signals[k]]
return {"label": target["label"], "threads": session["weight"], **signals,
"tells": ", ".join(tells) if tells else "none"}
finally:
browser.close()
with ThreadPoolExecutor(len(TARGETS)) as pool:
print(json.dumps(list(pool.map(inspect, TARGETS)), indent=2, ensure_ascii=False))
// 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 String PROXY = System.getenv("PROXY_URL");
// Nulls matter here (no deviceMemory in Firefox, no WebGL renderer): keep them in the JSON.
static final Gson GSON = new GsonBuilder().serializeNulls().disableHtmlEscaping().create();
// Four ways to run a browser; the same twelve signals read from inside the page.
record Target(String label, String engine, String family, String body, String prefix) {}
static final Target[] TARGETS = {
new Target("Chromium, headless", "chromium", "chromium", "{\"headless\": \"new\"}", ""),
new Target("Chromium, headful", "chromium", "chromium", "{\"headless\": false}", ""),
new Target("Patchright, headful", "patchright", "chromium", "{\"headless\": false}", ""),
// Camoufox: read the page's own world ("mw:" needs main_world_eval), like a site would.
new Target("Camoufox, headful", "camoufox", "firefox", "{\"headless\": false, \"os\": \"windows\", \"main_world_eval\": true}", "mw:"),
};
// The classic headless and automation tells, read the way a detection script reads them.
static final String SIGNALS = """
(async () => {
let query = null;
try { query = (await navigator.permissions.query({ name: 'notifications' })).state; } catch { query = 'error'; }
const gl = (() => {
try { const g = document.createElement('canvas').getContext('webgl'); const d = g.getExtension('WEBGL_debug_renderer_info'); return g.getParameter(d ? d.UNMASKED_RENDERER_WEBGL : g.RENDERER); } catch { return null; }
})();
return {
ua_says_headless: /Headless/.test(navigator.userAgent),
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
notification_mismatch: typeof Notification !== 'undefined' && Notification.permission === 'denied' && query === 'prompt',
outer_window_zero: outerWidth === 0 || outerHeight === 0,
screen: screen.width + 'x' + screen.height,
webgl_renderer: gl,
cores: navigator.hardwareConcurrency,
memory_gb: navigator.deviceMemory ?? null,
languages: navigator.languages.join(','),
chrome_object: typeof window.chrome === 'object' && window.chrome !== null,
};
})()""";
static final String[] TELLS = {"ua_says_headless", "webdriver", "notification_mismatch", "outer_window_zero"};
static JsonObject inspect(Target t) throws Exception {
JsonObject options = JsonParser.parseString(t.body()).getAsJsonObject();
options.addProperty("proxy", PROXY);
HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + t.engine() + "/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(options))).build(), HttpResponse.BodyHandlers.ofString());
JsonObject out = new JsonObject();
out.addProperty("label", t.label());
if (res.statusCode() != 200) { out.addProperty("error", "launch " + res.statusCode() + " " + res.body()); return out; }
JsonObject session = JsonParser.parseString(res.body()).getAsJsonObject();
try (Playwright playwright = Playwright.create()) { // Playwright objects are per thread
BrowserType type = t.family().equals("firefox") ? playwright.firefox() : playwright.chromium();
Browser browser = type.connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Page page = browser.newPage();
page.navigate("https://example.com/", new Page.NavigateOptions().setTimeout(60000));
JsonObject signals = GSON.toJsonTree(page.evaluate(t.prefix() + SIGNALS)).getAsJsonObject();
out.add("threads", session.get("weight"));
for (String k : signals.keySet()) out.add(k, signals.get(k));
List<String> tells = new ArrayList<>();
for (String k : TELLS) if (signals.has(k) && signals.get(k).isJsonPrimitive() && signals.get(k).getAsJsonPrimitive().isBoolean() && signals.get(k).getAsBoolean()) tells.add(k);
out.addProperty("tells", tells.isEmpty() ? "none" : String.join(", ", tells));
return out;
} finally {
browser.close();
}
}
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(TARGETS.length);
List<Future<JsonObject>> jobs = new ArrayList<>();
for (Target t : TARGETS) jobs.add(pool.submit(() -> inspect(t)));
JsonArray rows = new JsonArray();
for (Future<JsonObject> f : jobs) rows.add(f.get());
pool.shutdown();
System.out.println(new GsonBuilder().serializeNulls().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")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!;
// Four ways to run a browser; the same twelve signals read from inside the page.
var targets = new (string Label, string Engine, string Family, JsonObject Body, string Prefix)[]
{
("Chromium, headless", "chromium", "chromium", new JsonObject { ["headless"] = "new" }, ""),
("Chromium, headful", "chromium", "chromium", new JsonObject { ["headless"] = false }, ""),
("Patchright, headful", "patchright", "chromium", new JsonObject { ["headless"] = false }, ""),
// Camoufox: read the page's own world ("mw:" needs main_world_eval), like a site would.
("Camoufox, headful", "camoufox", "firefox", new JsonObject { ["headless"] = false, ["os"] = "windows", ["main_world_eval"] = true }, "mw:"),
};
// The classic headless and automation tells, read the way a detection script reads them.
const string Signals = """
(async () => {
let query = null;
try { query = (await navigator.permissions.query({ name: 'notifications' })).state; } catch { query = 'error'; }
const gl = (() => {
try { const g = document.createElement('canvas').getContext('webgl'); const d = g.getExtension('WEBGL_debug_renderer_info'); return g.getParameter(d ? d.UNMASKED_RENDERER_WEBGL : g.RENDERER); } catch { return null; }
})();
return {
ua_says_headless: /Headless/.test(navigator.userAgent),
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
notification_mismatch: typeof Notification !== 'undefined' && Notification.permission === 'denied' && query === 'prompt',
outer_window_zero: outerWidth === 0 || outerHeight === 0,
screen: screen.width + 'x' + screen.height,
webgl_renderer: gl,
cores: navigator.hardwareConcurrency,
memory_gb: navigator.deviceMemory ?? null,
languages: navigator.languages.join(','),
chrome_object: typeof window.chrome === 'object' && window.chrome !== null,
};
})()
""";
string[] tellKeys = { "ua_says_headless", "webdriver", "notification_mismatch", "outer_window_zero" };
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
async Task<JsonObject> Inspect(string label, string engine, string family, JsonObject body, string prefix)
{
var options = body.DeepClone().AsObject();
options["proxy"] = proxy;
var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session",
new StringContent(options.ToJsonString(), System.Text.Encoding.UTF8, "application/json"));
if (!res.IsSuccessStatusCode) return new JsonObject { ["label"] = label, ["error"] = $"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}" };
var session = await res.Content.ReadFromJsonAsync<JsonElement>();
var type = family == "firefox" ? playwright.Firefox : playwright.Chromium;
var browser = await type.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var page = await browser.NewPageAsync();
await page.GotoAsync("https://example.com/", new() { Timeout = 60000 });
var signals = JsonNode.Parse((await page.EvaluateAsync<JsonElement>(prefix + Signals)).GetRawText())!.AsObject();
var outp = new JsonObject { ["label"] = label, ["threads"] = session.GetProperty("weight").GetInt32() };
foreach (var (k, v) in signals) outp[k] = v?.DeepClone();
var tells = tellKeys.Where(k => signals[k] is JsonValue v && v.TryGetValue<bool>(out var b) && b).ToList();
outp["tells"] = tells.Count > 0 ? string.Join(", ", tells) : "none";
return outp;
}
finally
{
await browser.CloseAsync();
}
}
var rows = await Task.WhenAll(targets.Select(t => Inspect(t.Label, t.Engine, t.Family, t.Body, t.Prefix)));
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"
"strings"
"sync"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
var proxy = os.Getenv("PROXY_URL")
// Four ways to run a browser; the same twelve signals read from inside the page.
type target struct {
label, engine, family, prefix string
body map[string]any
}
var targets = []target{
{"Chromium, headless", "chromium", "chromium", "", map[string]any{"headless": "new"}},
{"Chromium, headful", "chromium", "chromium", "", map[string]any{"headless": false}},
{"Patchright, headful", "patchright", "chromium", "", map[string]any{"headless": false}},
// Camoufox: read the page's own world ("mw:" needs main_world_eval), like a site would.
{"Camoufox, headful", "camoufox", "firefox", "mw:", map[string]any{"headless": false, "os": "windows", "main_world_eval": true}},
}
// The classic headless and automation tells, read the way a detection script reads them.
const signals = `(async () => {
let query = null;
try { query = (await navigator.permissions.query({ name: 'notifications' })).state; } catch { query = 'error'; }
const gl = (() => {
try { const g = document.createElement('canvas').getContext('webgl'); const d = g.getExtension('WEBGL_debug_renderer_info'); return g.getParameter(d ? d.UNMASKED_RENDERER_WEBGL : g.RENDERER); } catch { return null; }
})();
return {
ua_says_headless: /Headless/.test(navigator.userAgent),
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
notification_mismatch: typeof Notification !== 'undefined' && Notification.permission === 'denied' && query === 'prompt',
outer_window_zero: outerWidth === 0 || outerHeight === 0,
screen: screen.width + 'x' + screen.height,
webgl_renderer: gl,
cores: navigator.hardwareConcurrency,
memory_gb: navigator.deviceMemory ?? null,
languages: navigator.languages.join(','),
chrome_object: typeof window.chrome === 'object' && window.chrome !== null,
};
})()`
var tellKeys = []string{"ua_says_headless", "webdriver", "notification_mismatch", "outer_window_zero"}
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)
}
// Evaluate returns whole numbers as int and others as float64.
func num(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
}
return 0
}
func inspect(pw *playwright.Playwright, t target) map[string]any {
fail := func(err error) map[string]any { return map[string]any{"label": t.label, "error": err.Error()} }
options := map[string]any{"proxy": proxy}
for k, v := range t.body {
options[k] = v
}
session, err := launch(t.engine, options)
if err != nil {
return fail(err)
}
connect := pw.Chromium.Connect
if t.family == "firefox" {
connect = pw.Firefox.Connect
}
browser, err := connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return fail(err)
}
defer browser.Close()
page, err := browser.NewPage()
if err != nil {
return fail(err)
}
if _, err := page.Goto("https://example.com/", playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
return fail(err)
}
v, err := page.Evaluate(t.prefix + signals)
if err != nil {
return fail(err)
}
out := v.(map[string]any)
out["label"] = t.label
out["threads"] = num(session["weight"])
var tells []string
for _, k := range tellKeys {
if b, ok := out[k].(bool); ok && b {
tells = append(tells, k)
}
}
out["tells"] = "none"
if len(tells) > 0 {
out["tells"] = strings.Join(tells, ", ")
}
return out
}
func main() {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
rows := make([]map[string]any, len(targets))
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
go func(i int, t target) { defer wg.Done(); rows[i] = inspect(pw, t) }(i, t)
}
wg.Wait()
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
enc.Encode(rows)
fmt.Print(buf.String())
}
What we got
| Browser | Threads | Tells fired | Headless in UA | webdriver | Plugins | Notification contradiction | Screen | WebGL renderer | Cores | Memory (GB) | window.chrome |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Chromium, headless | 1 | ua_says_headless, notification_mismatch | yes | no | 0 | yes | 1280x720 | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) | 64 | 32 | no |
| Chromium, headful | 2 | none | no | no | 5 | no | 1280x720 | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver) | 64 | 32 | yes |
| Patchright, headful | 2 | none | no | no | 5 | no | 1280x720 | — | 64 | 32 | yes |
| Camoufox, headful | 2 | none | no | no | 5 | no | 2560x1440 | ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar | 16 | — | no |
From the Node.js run on 2026-10-04. 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)
[
{
"label": "Chromium, headless",
"threads": 1,
"ua_says_headless": true,
"webdriver": false,
"plugins": 0,
"notification_mismatch": true,
"outer_window_zero": false,
"screen": "1280x720",
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"cores": 64,
"memory_gb": 32,
"languages": "en-US",
"chrome_object": false,
"tells": "ua_says_headless, notification_mismatch"
},
{
"label": "Chromium, headful",
"threads": 2,
"ua_says_headless": false,
"webdriver": false,
"plugins": 5,
"notification_mismatch": false,
"outer_window_zero": false,
"screen": "1280x720",
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"cores": 64,
"memory_gb": 32,
"languages": "en-US",
"chrome_object": true,
"tells": "none"
},
{
"label": "Patchright, headful",
"threads": 2,
"ua_says_headless": false,
"webdriver": false,
"plugins": 5,
"notification_mismatch": false,
"outer_window_zero": false,
"screen": "1280x720",
"webgl_renderer": null,
"cores": 64,
"memory_gb": 32,
"languages": "en-US",
"chrome_object": true,
"tells": "none"
},
{
"label": "Camoufox, headful",
"threads": 2,
"ua_says_headless": false,
"webdriver": false,
"plugins": 5,
"notification_mismatch": false,
"outer_window_zero": false,
"screen": "2560x1440",
"webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar",
"cores": 16,
"memory_gb": null,
"languages": "en-PT,en",
"chrome_object": false,
"tells": "none"
}
]Takeaways
- Headless Chromium fires two tells on arrival:
HeadlessChromein the user agent and the notification contradiction (Notification.permissionis *denied* whilepermissions.querysays *prompt*). It also has zero plugins and nowindow.chrome. - Headful fixes all of that with the same build: no
Headlessin the UA, five plugins, consistent permissions,window.chromepresent — one extra thread buys it.navigator.webdriverisfalsein every build on the fleet, headless included. - What headful doesn't fix is the hardware: both Chromium modes report the same
SwiftShadersoftware renderer and the server's 64 cores and 32 GB — a desktop that doesn't exist. Sites that score the GPU string see it either way. - Patchright, headful, reports no WebGL renderer at all on the fleet today (no WebGL context is created), which bot.sannysoft.com marks red; its headless mode has WebGL. We are looking into it. Everything else matches headful Chromium.
- Camoufox replaces the hardware story: a consumer GPU string, a desktop-sized screen, 8–32 cores, no
deviceMemory(Firefox never had it) and nowindow.chrome— consistent with the Firefox-on-Windows identity it claims.