Cases / #65 · 2026-10-22 · Medium
DataDome: what gets through? Four browsers, three networks, DataDome's own site
DataDome's own home and pricing pages, which DataDome protects, loaded once per cell by headless Chromium, headful Chrome, headful Edge and Camoufox through your own proxy and sticky cdpfleet-resi exits (Tier 1 and Tier 4). Outcome per cell with the HTTP status DataDome answered, what a returning visitor with a profile sees, and whether Camoufox's `humanize` changes the next page.
Chromium Google Chrome Microsoft Edge Camoufox
Run on production on 2026-10-22: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
DataDome decides on the very first request: an intercepted page comes back as a 403 that embeds a frame from captcha-delivery.com, and that frame is either a device check that passes by itself, a CAPTCHA, or a block. Which one you get depends on the browser, the IP it comes from and that IP's recent history, and on what the visitor did before. This case measures the outcomes on DataDome's own public site (the vendor protects its own pages, so the protection is real and the pages are public), with one load per cell and nothing clicked or solved. It is a dated measurement, not an evasion guide: site owners choose their own DataDome settings, and DataDome changes its models all the time. For the signals a page can read, see What a page can read about your browser.
What we used, and why
| What | Why |
|---|---|
| Four setups | Headless Chromium, headful Google Chrome, headful Microsoft Edge (all Chromium) and Camoufox (based on Firefox source; not affiliated with Mozilla), launched with record: false. |
proxy: your own vs cdpfleet-resi-country-us-session-<id> | Your own proxy (PROXY_URL, rotating) against sticky residential exits. DataDome scores the IP and its history, so every setup gets its own sticky exit: one setup's visits can't change what the next one sees. |
cdpfleet-resi-tier-4-… | The cheaper Tier 4 pool on the home page, with headful Chrome and Camoufox. |
| A fresh context per page | Every load is a first visit: no cookie from one page or setup reaches the next. |
The navigation status and page.evaluate() once a second | A 403 means DataDome intercepted the request. The iframe it embeds tells the rest: /captcha/ is a CAPTCHA (challenge), t=bv a block (blocked), anything else a device check; the real page after a check is cleared, the real page straight away content. Nothing is clicked. |
profile | A returning visitor: the same profile and the same sticky exit in a second session, keeping the datadome cookie the first visit was given. |
humanize | Camoufox moves the cursor along human-like paths when it's on (the default) and jumps when it's off. Three moves on the home page, then the pricing page in the same tab, so DataDome's script can see the movement before the next request. |
How it works
- For each network (your own proxy, then sticky Tier 1 exits) and each of the four setups, launch one session and load the home and pricing pages once each, in a fresh context.
- Record the HTTP status, then check the page once a second for up to 12 s: a CAPTCHA or block ends the watch, a device check gets the full 12 s to pass by itself.
- Load the home page on sticky Tier 4 exits with headful Chrome and Camoufox.
- With headful Chrome and Camoufox, load the home page with a new
profile, end the session, wait for the profile to be saved, and load it again with the same profile and exit. Delete the profiles. - With Camoufox,
humanizeon and then off (each with its own exit): load the home page, move the cursor three times, load the pricing page in the same tab.
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 (one page load per page per setup and network; nothing is clicked or solved)
import { randomBytes } from 'node:crypto';
import { chromium, firefox } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const RUN = randomBytes(3).toString('hex');
// Every setup gets its own sticky residential exit (letter = setup): DataDome scores the IP as well as the browser,
// so one setup's visits must not change what the next one sees.
const NETWORKS = {
own: { label: 'own proxy (rotating)', proxy: () => process.env.PROXY_URL },
tier1: { label: 'cdpfleet-resi Tier 1, sticky US', proxy: (id) => `cdpfleet-resi-country-us-session-d${RUN}${id}-lifetime-30` },
tier4: { label: 'cdpfleet-resi Tier 4, sticky US', proxy: (id) => `cdpfleet-resi-tier-4-country-us-session-d${RUN}${id}-lifetime-30` },
};
const SETUPS = {
chromium: { id: 'a', setup: 'Chromium, headless', engine: 'chromium', type: chromium, headless: true },
chrome: { id: 'b', setup: 'Google Chrome, headful', engine: 'chrome', type: chromium, headless: false },
edge: { id: 'c', setup: 'Microsoft Edge, headful', engine: 'edge', type: chromium, headless: false },
camoufox: { id: 'd', setup: 'Camoufox, defaults', engine: 'camoufox', type: firefox, headless: true },
};
// DataDome's own public site, which DataDome protects: its home page and its pricing page.
const PAGES = {
home: 'https://datadome.co/',
pricing: 'https://datadome.co/pricing/',
};
// What the page shows right now, in the lab's words. DataDome answers an intercepted request with a 403 page that
// embeds an iframe from captcha-delivery.com: a device check that passes by itself, a CAPTCHA, or a block (t=bv).
// The same expression runs in every language.
const STATE = `(() => {
const f = [...document.querySelectorAll('iframe')].map((i) => i.src).find((s) => /captcha-delivery\\.com/.test(s)) || '';
const text = (document.body ? document.body.innerText : '').trim();
return /[?&]t=bv(&|$)/.test(f) ? 'blocked' : /\\/captcha\\//.test(f) ? 'challenge' : f ? 'checking' : text.length > 200 ? 'page' : 'loading';
})()`;
// 503 means the fleet is momentarily busy: wait Retry-After and try again. A profile's second session can land on a
// node with an older build of the browser (409 profile_version_downgrade): ask again for another node.
async function launch(s, network, extra = {}) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`https://starter.cdpfleet.com/${s.engine}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: NETWORKS[network].proxy(extra.exit || s.id), headless: s.headless, record: false, ...(extra.body || {}) }),
});
if (res.ok) return s.type.connect((await res.json()).wsUrl, { headers: { 'x-api-key': KEY } });
const text = await res.text();
const retry = [429, 503].includes(res.status) || (res.status === 409 && text.includes('profile_version_downgrade'));
if (!retry || attempt === 8) throw new Error(`launch ${res.status} ${text}`);
await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Load a page (a network failure is retried once), then watch it once a second for 12 s without touching it.
// content = the page straight away; cleared = DataDome intercepted (403 or its device check) and let the browser
// through by itself; challenge = a CAPTCHA (never solved); blocked = DataDome's block page.
async function visit(tab, url) {
const t0 = Date.now();
let status = null;
try {
try {
status = (await tab.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }))?.status() ?? null;
} catch {
status = (await tab.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }))?.status() ?? null;
}
} catch {
return { outcome: 'error', status: null, seconds: null };
}
let checked = status === 403;
const until = Date.now() + 12000; // the watch starts once the page has loaded, however slow the proxy was
while (Date.now() < until) {
const s = await tab.evaluate(STATE).catch(() => null); // null while the device check reloads the page
if (s === 'blocked' || s === 'challenge') return { outcome: s, status, seconds: null };
if (s === 'checking') checked = true;
if (s === 'page') return checked ? { outcome: 'cleared', status, seconds: Math.round((Date.now() - t0) / 1000) } : { outcome: 'content', status, seconds: null };
await sleep(1000);
}
return { outcome: checked ? 'challenge' : 'error', status, seconds: null };
}
// One session per setup and network; a fresh context per page so every load is a first visit.
async function row(setupKey, network, pages) {
const s = SETUPS[setupKey];
const browser = await launch(s, network);
const out = { setup: s.setup, network: NETWORKS[network].label };
try {
for (const key of pages) {
const context = await browser.newContext();
out[key] = await visit(await context.newPage(), PAGES[key]);
await context.close();
}
} finally {
await browser.close();
}
return out;
}
async function api(method, path) {
const res = await fetch(`https://cdpfleet.com${path}`, { method, headers: { 'x-api-key': KEY } });
return res.json().catch(() => ({}));
}
const profileInfo = async (name) => (await api('GET', '/v1/me/profiles')).profiles?.find((p) => p.name === name);
// A returning visitor: the same profile and the same sticky exit, two sessions a few seconds apart. The profile keeps
// the datadome cookie the first visit was given.
async function returning(setupKey) {
const name = `case65-${RUN}-${SETUPS[setupKey].id}`;
const exit = `${SETUPS[setupKey].id}p`;
const visits = [];
for (let i = 0; i < 2; i++) {
const browser = await launch(SETUPS[setupKey], 'tier1', { exit, body: { profile: name } });
try {
const context = browser.contexts()[0]; // the profile's own context; a newContext() is not saved
visits.push(await visit(await context.newPage(), PAGES.home));
// DataDome's script sets its cookie a moment after the page: give it up to 5 s.
const hasCookie = async () => (await context.cookies()).some((c) => c.name === 'datadome');
for (let j = 0; j < 5 && !(await hasCookie()); j++) await sleep(1000);
visits[i].cookie = await hasCookie();
} finally {
await browser.close();
}
// The node saves the profile after the browser exits: wait until it is free again.
for (let j = 0; j < 60 && (await profileInfo(name))?.in_use !== false; j++) await sleep(2000);
}
await api('DELETE', `/v1/me/profiles/${name}`);
return { setup: SETUPS[setupKey].setup, network: NETWORKS.tier1.label, first: visits[0], second: visits[1] };
}
// Camoufox with humanize on and off: two page views in one context, with three mouse moves between them, so DataDome's
// script on the first page can see how the cursor moves before the second page is requested.
async function humanize(on) {
const s = SETUPS.camoufox;
const browser = await launch(s, 'tier1', { exit: on ? 'h1' : 'h0', body: { humanize: on } });
try {
const tab = await (await browser.newContext()).newPage();
const first = await visit(tab, PAGES.home);
const t0 = Date.now();
for (const [x, y] of [[200, 300], [700, 250], [450, 600]]) {
await Promise.race([tab.mouse.move(x, y), sleep(15000)]);
}
const move_seconds = Math.round((Date.now() - t0) / 100) / 10;
const second = await visit(tab, PAGES.pricing);
return { setup: `Camoufox, humanize ${on ? 'on' : 'off'}`, network: NETWORKS.tier1.label, home: first, pricing: second, move_seconds };
} finally {
await browser.close();
}
}
const matrix = [];
for (const network of ['own', 'tier1']) {
for (const setupKey of ['chromium', 'chrome', 'edge', 'camoufox']) matrix.push(await row(setupKey, network, Object.keys(PAGES)));
}
const tier4 = [];
for (const setupKey of ['chrome', 'camoufox']) tier4.push(await row(setupKey, 'tier4', ['home']));
const profiles = [await returning('chrome'), await returning('camoufox')];
const human = [await humanize(true), await humanize(false)];
console.log(JSON.stringify({ measured_at: new Date().toISOString().slice(0, 10), matrix, tier4, profiles, humanize: human }, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL (one page load per page per setup and network; nothing is clicked or solved)
import json
import os
import secrets
import time
from datetime import datetime, timezone
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
RUN = secrets.token_hex(3)
# Every setup gets its own sticky residential exit (letter = setup): DataDome scores the IP as well as the browser,
# so one setup's visits must not change what the next one sees.
NETWORKS = {
"own": {"label": "own proxy (rotating)", "proxy": lambda exit_id: os.environ["PROXY_URL"]},
"tier1": {"label": "cdpfleet-resi Tier 1, sticky US", "proxy": lambda exit_id: f"cdpfleet-resi-country-us-session-d{RUN}{exit_id}-lifetime-30"},
"tier4": {"label": "cdpfleet-resi Tier 4, sticky US", "proxy": lambda exit_id: f"cdpfleet-resi-tier-4-country-us-session-d{RUN}{exit_id}-lifetime-30"},
}
SETUPS = {
"chromium": {"id": "a", "setup": "Chromium, headless", "engine": "chromium", "type": "chromium", "headless": True},
"chrome": {"id": "b", "setup": "Google Chrome, headful", "engine": "chrome", "type": "chromium", "headless": False},
"edge": {"id": "c", "setup": "Microsoft Edge, headful", "engine": "edge", "type": "chromium", "headless": False},
"camoufox": {"id": "d", "setup": "Camoufox, defaults", "engine": "camoufox", "type": "firefox", "headless": True},
}
# DataDome's own public site, which DataDome protects: its home page and its pricing page.
PAGES = {
"home": "https://datadome.co/",
"pricing": "https://datadome.co/pricing/",
}
# What the page shows right now, in the lab's words. DataDome answers an intercepted request with a 403 page that
# embeds an iframe from captcha-delivery.com: a device check that passes by itself, a CAPTCHA, or a block (t=bv).
# The same expression runs in every language.
STATE = r"""(() => {
const f = [...document.querySelectorAll('iframe')].map((i) => i.src).find((s) => /captcha-delivery\.com/.test(s)) || '';
const text = (document.body ? document.body.innerText : '').trim();
return /[?&]t=bv(&|$)/.test(f) ? 'blocked' : /\/captcha\//.test(f) ? 'challenge' : f ? 'checking' : text.length > 200 ? 'page' : 'loading';
})()"""
# 503 means the fleet is momentarily busy: wait Retry-After and try again. A profile's second session can land on a
# node with an older build of the browser (409 profile_version_downgrade): ask again for another node.
def launch(p, s, network, exit_id=None, extra=None):
body = {"proxy": NETWORKS[network]["proxy"](exit_id or s["id"]), "headless": s["headless"], "record": False, **(extra or {})}
for attempt in range(1, 9):
res = requests.post(f"https://starter.cdpfleet.com/{s['engine']}/session", headers={"x-api-key": KEY}, json=body, timeout=120)
if res.ok:
return getattr(p, s["type"]).connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
retry = res.status_code in (429, 503) or (res.status_code == 409 and "profile_version_downgrade" in res.text)
if not retry or attempt == 8:
raise RuntimeError(f"launch {res.status_code} {res.text}")
time.sleep(int(res.headers.get("retry-after") or 5))
def goto(tab, url):
response = tab.goto(url, wait_until="domcontentloaded", timeout=45000)
return response.status if response else None
# Load a page (a network failure is retried once), then watch it once a second for 12 s without touching it.
# content = the page straight away; cleared = DataDome intercepted (403 or its device check) and let the browser
# through by itself; challenge = a CAPTCHA (never solved); blocked = DataDome's block page.
def visit(tab, url):
t0 = time.time()
try:
try:
status = goto(tab, url)
except Exception:
status = goto(tab, url)
except Exception:
return {"outcome": "error", "status": None, "seconds": None}
checked = status == 403
until = time.time() + 12 # the watch starts once the page has loaded, however slow the proxy was
while time.time() < until:
try:
s = tab.evaluate(STATE)
except Exception:
s = None # while the device check reloads the page
if s in ("blocked", "challenge"):
return {"outcome": s, "status": status, "seconds": None}
if s == "checking":
checked = True
if s == "page":
if checked:
return {"outcome": "cleared", "status": status, "seconds": round(time.time() - t0)}
return {"outcome": "content", "status": status, "seconds": None}
time.sleep(1)
return {"outcome": "challenge" if checked else "error", "status": status, "seconds": None}
# One session per setup and network; a fresh context per page so every load is a first visit.
def row(p, setup_key, network, pages):
s = SETUPS[setup_key]
browser = launch(p, s, network)
out = {"setup": s["setup"], "network": NETWORKS[network]["label"]}
try:
for key in pages:
context = browser.new_context()
out[key] = visit(context.new_page(), PAGES[key])
context.close()
finally:
browser.close()
return out
def api(method, path):
try:
return requests.request(method, f"https://cdpfleet.com{path}", headers={"x-api-key": KEY}, timeout=60).json()
except Exception:
return {}
def profile_info(name):
return next((x for x in api("GET", "/v1/me/profiles").get("profiles", []) if x["name"] == name), None)
# A returning visitor: the same profile and the same sticky exit, two sessions a few seconds apart. The profile keeps
# the datadome cookie the first visit was given.
def returning(p, setup_key):
s = SETUPS[setup_key]
name = f"case65-{RUN}-{s['id']}"
visits = []
for _ in range(2):
browser = launch(p, s, "tier1", exit_id=f"{s['id']}p", extra={"profile": name})
try:
context = browser.contexts[0] # the profile's own context; a new_context() is not saved
cell = visit(context.new_page(), PAGES["home"])
# DataDome's script sets its cookie a moment after the page: give it up to 5 s.
has_cookie = lambda: any(c["name"] == "datadome" for c in context.cookies())
for _ in range(5):
if has_cookie():
break
time.sleep(1)
cell["cookie"] = has_cookie()
visits.append(cell)
finally:
browser.close()
# The node saves the profile after the browser exits: wait until it is free again.
for _ in range(60):
info = profile_info(name)
if info is not None and info.get("in_use") is False:
break
time.sleep(2)
api("DELETE", f"/v1/me/profiles/{name}")
return {"setup": s["setup"], "network": NETWORKS["tier1"]["label"], "first": visits[0], "second": visits[1]}
# Camoufox with humanize on and off: two page views in one context, with three mouse moves between them, so DataDome's
# script on the first page can see how the cursor moves before the second page is requested.
def humanize(p, on):
browser = launch(p, SETUPS["camoufox"], "tier1", exit_id="h1" if on else "h0", extra={"humanize": on})
try:
tab = browser.new_context().new_page()
first = visit(tab, PAGES["home"])
t0 = time.time()
for x, y in [(200, 300), (700, 250), (450, 600)]:
tab.mouse.move(x, y)
move_seconds = round(time.time() - t0, 1)
second = visit(tab, PAGES["pricing"])
return {"setup": f"Camoufox, humanize {'on' if on else 'off'}", "network": NETWORKS["tier1"]["label"], "home": first, "pricing": second, "move_seconds": move_seconds}
finally:
browser.close()
with sync_playwright() as p:
matrix = [row(p, k, n, list(PAGES)) for n in ("own", "tier1") for k in ("chromium", "chrome", "edge", "camoufox")]
tier4 = [row(p, k, "tier4", ["home"]) for k in ("chrome", "camoufox")]
profiles = [returning(p, "chrome"), returning(p, "camoufox")]
human = [humanize(p, True), humanize(p, False)]
print(json.dumps({"measured_at": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "matrix": matrix, "tier4": tier4, "profiles": profiles, "humanize": human}, 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 (one page load per page per setup and network; nothing is clicked or solved)
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.WaitUntilState;
import java.net.URI;
import java.net.http.*;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
static final HttpClient HTTP = HttpClient.newHttpClient();
static final String RUN = String.format("%06x", new SecureRandom().nextInt(1 << 24));
record Setup(String id, String name, String engine, String type, boolean headless) {}
// Every setup gets its own sticky residential exit (letter = setup): DataDome scores the IP as well as the browser,
// so one setup's visits must not change what the next one sees.
static final Map<String, String> NETWORKS = Map.of(
"own", "own proxy (rotating)",
"tier1", "cdpfleet-resi Tier 1, sticky US",
"tier4", "cdpfleet-resi Tier 4, sticky US");
static String proxy(String network, String exit) {
return switch (network) {
case "tier1" -> "cdpfleet-resi-country-us-session-d" + RUN + exit + "-lifetime-30";
case "tier4" -> "cdpfleet-resi-tier-4-country-us-session-d" + RUN + exit + "-lifetime-30";
default -> System.getenv("PROXY_URL");
};
}
static final Map<String, Setup> SETUPS = Map.of(
"chromium", new Setup("a", "Chromium, headless", "chromium", "chromium", true),
"chrome", new Setup("b", "Google Chrome, headful", "chrome", "chromium", false),
"edge", new Setup("c", "Microsoft Edge, headful", "edge", "chromium", false),
"camoufox", new Setup("d", "Camoufox, defaults", "camoufox", "firefox", true));
// DataDome's own public site, which DataDome protects: its home page and its pricing page.
static final Map<String, String> PAGES = new LinkedHashMap<>();
static {
PAGES.put("home", "https://datadome.co/");
PAGES.put("pricing", "https://datadome.co/pricing/");
}
// What the page shows right now, in the lab's words. DataDome answers an intercepted request with a 403 page that
// embeds an iframe from captcha-delivery.com: a device check that passes by itself, a CAPTCHA, or a block (t=bv).
// The same expression runs in every language.
static final String STATE = """
(() => {
const f = [...document.querySelectorAll('iframe')].map((i) => i.src).find((s) => /captcha-delivery\\.com/.test(s)) || '';
const text = (document.body ? document.body.innerText : '').trim();
return /[?&]t=bv(&|$)/.test(f) ? 'blocked' : /\\/captcha\\//.test(f) ? 'challenge' : f ? 'checking' : text.length > 200 ? 'page' : 'loading';
})()""";
static Playwright playwright;
// 503 means the fleet is momentarily busy: wait Retry-After and try again. A profile's second session can land on a
// node with an older build of the browser (409 profile_version_downgrade): ask again for another node.
static Browser launch(Setup s, String network, String exit, Map<String, Object> extra) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("proxy", proxy(network, exit == null ? s.id() : exit));
body.addProperty("headless", s.headless());
body.addProperty("record", false);
if (extra != null) for (var e : extra.entrySet()) body.add(e.getKey(), GSON.toJsonTree(e.getValue()));
for (int attempt = 1; ; attempt++) {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + s.engine() + "/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) {
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
BrowserType type = s.type().equals("firefox") ? playwright.firefox() : playwright.chromium();
return type.connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
}
boolean retry = res.statusCode() == 429 || res.statusCode() == 503 || (res.statusCode() == 409 && res.body().contains("profile_version_downgrade"));
if (!retry || attempt == 8) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
}
}
static Integer goTo(Page tab, String url) {
Response r = tab.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(45000));
return r == null ? null : r.status();
}
static JsonObject cell(String outcome, Integer status, Integer seconds) {
JsonObject c = new JsonObject();
c.addProperty("outcome", outcome);
c.addProperty("status", status);
c.addProperty("seconds", seconds);
return c;
}
// Load a page (a network failure is retried once), then watch it once a second for 12 s without touching it.
// content = the page straight away; cleared = DataDome intercepted (403 or its device check) and let the browser
// through by itself; challenge = a CAPTCHA (never solved); blocked = DataDome's block page.
static JsonObject visit(Page tab, String url) throws Exception {
long t0 = System.currentTimeMillis();
Integer status;
try {
try {
status = goTo(tab, url);
} catch (PlaywrightException err) {
status = goTo(tab, url);
}
} catch (PlaywrightException err) {
return cell("error", null, null);
}
boolean checked = status != null && status == 403;
long until = System.currentTimeMillis() + 12000; // the watch starts once the page has loaded, however slow the proxy was
while (System.currentTimeMillis() < until) {
String s = null;
try {
s = (String) tab.evaluate(STATE);
} catch (PlaywrightException err) {
// while the device check reloads the page
}
if ("blocked".equals(s) || "challenge".equals(s)) return cell(s, status, null);
if ("checking".equals(s)) checked = true;
if ("page".equals(s)) {
return checked ? cell("cleared", status, (int) Math.round((System.currentTimeMillis() - t0) / 1000.0)) : cell("content", status, null);
}
Thread.sleep(1000);
}
return cell(checked ? "challenge" : "error", status, null);
}
// One session per setup and network; a fresh context per page so every load is a first visit.
static JsonObject row(String setupKey, String network, List<String> pages) throws Exception {
Setup s = SETUPS.get(setupKey);
Browser browser = launch(s, network, null, null);
JsonObject out = new JsonObject();
out.addProperty("setup", s.name());
out.addProperty("network", NETWORKS.get(network));
try {
for (String key : pages) {
BrowserContext context = browser.newContext();
out.add(key, visit(context.newPage(), PAGES.get(key)));
context.close();
}
} finally {
browser.close();
}
return out;
}
static JsonObject api(String method, String path) throws Exception {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://cdpfleet.com" + path))
.header("x-api-key", KEY).method(method, HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
try {
return JsonParser.parseString(res.body()).getAsJsonObject();
} catch (RuntimeException err) {
return new JsonObject();
}
}
static JsonObject profileInfo(String name) throws Exception {
JsonObject body = api("GET", "/v1/me/profiles");
if (!body.has("profiles")) return null;
for (JsonElement p : body.getAsJsonArray("profiles")) if (p.getAsJsonObject().get("name").getAsString().equals(name)) return p.getAsJsonObject();
return null;
}
static boolean hasCookie(BrowserContext context) {
return context.cookies().stream().anyMatch(c -> c.name.equals("datadome"));
}
// A returning visitor: the same profile and the same sticky exit, two sessions a few seconds apart. The profile keeps
// the datadome cookie the first visit was given.
static JsonObject returning(String setupKey) throws Exception {
Setup s = SETUPS.get(setupKey);
String name = "case65-" + RUN + "-" + s.id();
List<JsonObject> visits = new ArrayList<>();
for (int i = 0; i < 2; i++) {
Browser browser = launch(s, "tier1", s.id() + "p", Map.of("profile", name));
try {
BrowserContext context = browser.contexts().get(0); // the profile's own context; a newContext() is not saved
JsonObject c = visit(context.newPage(), PAGES.get("home"));
// DataDome's script sets its cookie a moment after the page: give it up to 5 s.
for (int j = 0; j < 5 && !hasCookie(context); j++) Thread.sleep(1000);
c.addProperty("cookie", hasCookie(context));
visits.add(c);
} finally {
browser.close();
}
// The node saves the profile after the browser exits: wait until it is free again.
for (int j = 0; j < 60; j++) {
JsonObject info = profileInfo(name);
if (info != null && !info.get("in_use").getAsBoolean()) break;
Thread.sleep(2000);
}
}
api("DELETE", "/v1/me/profiles/" + name);
JsonObject out = new JsonObject();
out.addProperty("setup", s.name());
out.addProperty("network", NETWORKS.get("tier1"));
out.add("first", visits.get(0));
out.add("second", visits.get(1));
return out;
}
// Camoufox with humanize on and off: two page views in one context, with three mouse moves between them, so DataDome's
// script on the first page can see how the cursor moves before the second page is requested.
static JsonObject humanize(boolean on) throws Exception {
Browser browser = launch(SETUPS.get("camoufox"), "tier1", on ? "h1" : "h0", Map.of("humanize", on));
try {
Page tab = browser.newContext().newPage();
JsonObject out = new JsonObject();
out.addProperty("setup", "Camoufox, humanize " + (on ? "on" : "off"));
out.addProperty("network", NETWORKS.get("tier1"));
out.add("home", visit(tab, PAGES.get("home")));
long t0 = System.currentTimeMillis();
for (int[] xy : new int[][] {{200, 300}, {700, 250}, {450, 600}}) tab.mouse().move(xy[0], xy[1]);
out.addProperty("move_seconds", Math.round((System.currentTimeMillis() - t0) / 100.0) / 10.0);
out.add("pricing", visit(tab, PAGES.get("pricing")));
return out;
} finally {
browser.close();
}
}
public static void main(String[] args) throws Exception {
JsonObject result = new JsonObject();
try (Playwright pw = Playwright.create()) {
playwright = pw;
JsonArray matrix = new JsonArray();
for (String network : List.of("own", "tier1"))
for (String setupKey : List.of("chromium", "chrome", "edge", "camoufox")) matrix.add(row(setupKey, network, new ArrayList<>(PAGES.keySet())));
JsonArray tier4 = new JsonArray();
for (String setupKey : List.of("chrome", "camoufox")) tier4.add(row(setupKey, "tier4", List.of("home")));
JsonArray profiles = new JsonArray();
profiles.add(returning("chrome"));
profiles.add(returning("camoufox"));
JsonArray human = new JsonArray();
human.add(humanize(true));
human.add(humanize(false));
result.addProperty("measured_at", Instant.now().toString().substring(0, 10));
result.add("matrix", matrix);
result.add("tier4", tier4);
result.add("profiles", profiles);
result.add("humanize", human);
}
System.out.println(GSON.toJson(result));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL (one page load per page per setup and network; nothing is clicked or solved)
using System.Diagnostics;
using System.Net.Http.Json;
using System.Security.Cryptography;
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 run = Convert.ToHexString(RandomNumberGenerator.GetBytes(3)).ToLowerInvariant();
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// Every setup gets its own sticky residential exit (letter = setup): DataDome scores the IP as well as the browser,
// so one setup's visits must not change what the next one sees.
var networks = new Dictionary<string, string>
{
["own"] = "own proxy (rotating)",
["tier1"] = "cdpfleet-resi Tier 1, sticky US",
["tier4"] = "cdpfleet-resi Tier 4, sticky US",
};
string? Proxy(string network, string exit) => network switch
{
"tier1" => $"cdpfleet-resi-country-us-session-d{run}{exit}-lifetime-30",
"tier4" => $"cdpfleet-resi-tier-4-country-us-session-d{run}{exit}-lifetime-30",
_ => Environment.GetEnvironmentVariable("PROXY_URL"),
};
var setups = new Dictionary<string, (string Id, string Name, string Engine, IBrowserType Type, bool Headless)>
{
["chromium"] = ("a", "Chromium, headless", "chromium", playwright.Chromium, true),
["chrome"] = ("b", "Google Chrome, headful", "chrome", playwright.Chromium, false),
["edge"] = ("c", "Microsoft Edge, headful", "edge", playwright.Chromium, false),
["camoufox"] = ("d", "Camoufox, defaults", "camoufox", playwright.Firefox, true),
};
// DataDome's own public site, which DataDome protects: its home page and its pricing page.
var pages = new Dictionary<string, string>
{
["home"] = "https://datadome.co/",
["pricing"] = "https://datadome.co/pricing/",
};
// What the page shows right now, in the lab's words. DataDome answers an intercepted request with a 403 page that
// embeds an iframe from captcha-delivery.com: a device check that passes by itself, a CAPTCHA, or a block (t=bv).
// The same expression runs in every language.
const string State = """
(() => {
const f = [...document.querySelectorAll('iframe')].map((i) => i.src).find((s) => /captcha-delivery\.com/.test(s)) || '';
const text = (document.body ? document.body.innerText : '').trim();
return /[?&]t=bv(&|$)/.test(f) ? 'blocked' : /\/captcha\//.test(f) ? 'challenge' : f ? 'checking' : text.length > 200 ? 'page' : 'loading';
})()
""";
// 503 means the fleet is momentarily busy: wait Retry-After and try again. A profile's second session can land on a
// node with an older build of the browser (409 profile_version_downgrade): ask again for another node.
async Task<IBrowser> Launch(string setupKey, string network, string? exit = null, JsonObject? extra = null)
{
var s = setups[setupKey];
var body = new JsonObject { ["proxy"] = Proxy(network, exit ?? s.Id), ["headless"] = s.Headless, ["record"] = false };
if (extra != null) foreach (var (k, v) in extra) body[k] = v?.DeepClone();
for (var attempt = 1; ; attempt++)
{
var res = await http.PostAsJsonAsync($"https://starter.cdpfleet.com/{s.Engine}/session", body);
if (res.IsSuccessStatusCode)
{
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
return await s.Type.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
}
var code = (int)res.StatusCode;
var text = await res.Content.ReadAsStringAsync();
var retry = code is 429 or 503 || (code == 409 && text.Contains("profile_version_downgrade"));
if (!retry || attempt == 8) throw new Exception($"launch {code} {text}");
await Task.Delay(TimeSpan.FromSeconds(res.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5));
}
}
JsonObject Cell(string outcome, int? status, int? seconds) => new() { ["outcome"] = outcome, ["status"] = status, ["seconds"] = seconds };
async Task<int?> GoTo(IPage tab, string url) =>
(await tab.GotoAsync(url, new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 45000 }))?.Status;
// Load a page (a network failure is retried once), then watch it once a second for 12 s without touching it.
// content = the page straight away; cleared = DataDome intercepted (403 or its device check) and let the browser
// through by itself; challenge = a CAPTCHA (never solved); blocked = DataDome's block page.
async Task<JsonObject> Visit(IPage tab, string url)
{
var clock = Stopwatch.StartNew();
int? status;
try
{
try { status = await GoTo(tab, url); }
catch (PlaywrightException) { status = await GoTo(tab, url); }
}
catch (Exception)
{
return Cell("error", null, null);
}
var checkedByDataDome = status == 403;
var until = clock.ElapsedMilliseconds + 12000; // the watch starts once the page has loaded, however slow the proxy was
while (clock.ElapsedMilliseconds < until)
{
string? s = null;
try { s = await tab.EvaluateAsync<string>(State); }
catch (PlaywrightException) { } // while the device check reloads the page
if (s is "blocked" or "challenge") return Cell(s, status, null);
if (s == "checking") checkedByDataDome = true;
if (s == "page")
return checkedByDataDome ? Cell("cleared", status, (int)Math.Round(clock.ElapsedMilliseconds / 1000.0)) : Cell("content", status, null);
await Task.Delay(1000);
}
return Cell(checkedByDataDome ? "challenge" : "error", status, null);
}
// One session per setup and network; a fresh context per page so every load is a first visit.
async Task<JsonObject> Row(string setupKey, string network, string[] keys)
{
var browser = await Launch(setupKey, network);
var row = new JsonObject { ["setup"] = setups[setupKey].Name, ["network"] = networks[network] };
try
{
foreach (var k in keys)
{
var context = await browser.NewContextAsync();
row[k] = await Visit(await context.NewPageAsync(), pages[k]);
await context.CloseAsync();
}
}
finally
{
await browser.CloseAsync();
}
return row;
}
async Task<JsonElement?> ProfileInfo(string name)
{
var body = await http.GetFromJsonAsync<JsonElement>("https://cdpfleet.com/v1/me/profiles");
if (!body.TryGetProperty("profiles", out var list)) return null;
foreach (var p in list.EnumerateArray()) if (p.GetProperty("name").GetString() == name) return p;
return null;
}
async Task<bool> HasCookie(IBrowserContext context) => (await context.CookiesAsync()).Any(c => c.Name == "datadome");
// A returning visitor: the same profile and the same sticky exit, two sessions a few seconds apart. The profile keeps
// the datadome cookie the first visit was given.
async Task<JsonObject> Returning(string setupKey)
{
var s = setups[setupKey];
var name = $"case65-{run}-{s.Id}";
var visits = new List<JsonObject>();
for (var i = 0; i < 2; i++)
{
var browser = await Launch(setupKey, "tier1", $"{s.Id}p", new JsonObject { ["profile"] = name });
try
{
var context = browser.Contexts[0]; // the profile's own context; a NewContextAsync() is not saved
var cell = await Visit(await context.NewPageAsync(), pages["home"]);
// DataDome's script sets its cookie a moment after the page: give it up to 5 s.
for (var j = 0; j < 5 && !await HasCookie(context); j++) await Task.Delay(1000);
cell["cookie"] = await HasCookie(context);
visits.Add(cell);
}
finally
{
await browser.CloseAsync();
}
// The node saves the profile after the browser exits: wait until it is free again.
for (var j = 0; j < 60; j++)
{
var info = await ProfileInfo(name);
if (info is { } p && !p.GetProperty("in_use").GetBoolean()) break;
await Task.Delay(2000);
}
}
await http.DeleteAsync($"https://cdpfleet.com/v1/me/profiles/{name}");
return new JsonObject { ["setup"] = s.Name, ["network"] = networks["tier1"], ["first"] = visits[0], ["second"] = visits[1] };
}
// Camoufox with humanize on and off: two page views in one context, with three mouse moves between them, so DataDome's
// script on the first page can see how the cursor moves before the second page is requested.
async Task<JsonObject> Humanize(bool on)
{
var browser = await Launch("camoufox", "tier1", on ? "h1" : "h0", new JsonObject { ["humanize"] = on });
try
{
var tab = await (await browser.NewContextAsync()).NewPageAsync();
var row = new JsonObject { ["setup"] = $"Camoufox, humanize {(on ? "on" : "off")}", ["network"] = networks["tier1"] };
row["home"] = await Visit(tab, pages["home"]);
var clock = Stopwatch.StartNew();
foreach (var (x, y) in new[] { (200, 300), (700, 250), (450, 600) }) await tab.Mouse.MoveAsync(x, y);
row["move_seconds"] = Math.Round(clock.ElapsedMilliseconds / 100.0) / 10.0;
row["pricing"] = await Visit(tab, pages["pricing"]);
return row;
}
finally
{
await browser.CloseAsync();
}
}
var matrix = new JsonArray();
foreach (var network in new[] { "own", "tier1" })
foreach (var setupKey in new[] { "chromium", "chrome", "edge", "camoufox" })
matrix.Add(await Row(setupKey, network, pages.Keys.ToArray()));
var tier4 = new JsonArray();
foreach (var setupKey in new[] { "chrome", "camoufox" }) tier4.Add(await Row(setupKey, "tier4", new[] { "home" }));
var profiles = new JsonArray { await Returning("chrome"), await Returning("camoufox") };
var human = new JsonArray { await Humanize(true), await Humanize(false) };
var result = new JsonObject { ["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"), ["matrix"] = matrix, ["tier4"] = tier4, ["profiles"] = profiles, ["humanize"] = human };
Console.WriteLine(result.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 (one page load per page per setup and network; nothing is clicked or solved)
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
var runID = func() string { b := make([]byte, 3); rand.Read(b); return hex.EncodeToString(b) }()
// Every setup gets its own sticky residential exit (letter = setup): DataDome scores the IP as well as the browser,
// so one setup's visits must not change what the next one sees.
var networkLabels = map[string]string{
"own": "own proxy (rotating)",
"tier1": "cdpfleet-resi Tier 1, sticky US",
"tier4": "cdpfleet-resi Tier 4, sticky US",
}
func proxyFor(net, exit string) string {
switch net {
case "tier1":
return "cdpfleet-resi-country-us-session-d" + runID + exit + "-lifetime-30"
case "tier4":
return "cdpfleet-resi-tier-4-country-us-session-d" + runID + exit + "-lifetime-30"
}
return os.Getenv("PROXY_URL")
}
type setup struct {
ID, Name, Engine, Type string
Headless bool
}
var setups = map[string]setup{
"chromium": {"a", "Chromium, headless", "chromium", "chromium", true},
"chrome": {"b", "Google Chrome, headful", "chrome", "chromium", false},
"edge": {"c", "Microsoft Edge, headful", "edge", "chromium", false},
"camoufox": {"d", "Camoufox, defaults", "camoufox", "firefox", true},
}
// DataDome's own public site, which DataDome protects: its home page and its pricing page.
var pages = map[string]string{
"home": "https://datadome.co/",
"pricing": "https://datadome.co/pricing/",
}
// What the page shows right now, in the lab's words. DataDome answers an intercepted request with a 403 page that
// embeds an iframe from captcha-delivery.com: a device check that passes by itself, a CAPTCHA, or a block (t=bv).
// The same expression runs in every language.
const state = `(() => {
const f = [...document.querySelectorAll('iframe')].map((i) => i.src).find((s) => /captcha-delivery\.com/.test(s)) || '';
const text = (document.body ? document.body.innerText : '').trim();
return /[?&]t=bv(&|$)/.test(f) ? 'blocked' : /\/captcha\//.test(f) ? 'challenge' : f ? 'checking' : text.length > 200 ? 'page' : 'loading';
})()`
type cell struct {
Outcome string `json:"outcome"`
Status *int `json:"status"`
Seconds *int `json:"seconds"`
Cookie *bool `json:"cookie,omitempty"`
}
type row struct {
Setup string `json:"setup"`
Network string `json:"network"`
Home *cell `json:"home,omitempty"`
Pricing *cell `json:"pricing,omitempty"`
MoveSeconds *float64 `json:"move_seconds,omitempty"`
}
var pw *playwright.Playwright
// 503 means the fleet is momentarily busy: wait Retry-After and try again. A profile's second session can land on a
// node with an older build of the browser (409 profile_version_downgrade): ask again for another node.
func launch(s setup, net, exit string, extra map[string]any) playwright.Browser {
if exit == "" {
exit = s.ID
}
body := map[string]any{"proxy": proxyFor(net, exit), "headless": s.Headless, "record": false}
for k, v := range extra {
body[k] = v
}
payload, _ := json.Marshal(body)
for attempt := 1; ; attempt++ {
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+s.Engine+"/session", bytes.NewReader(payload))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
msg, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == 200 {
var session struct{ WsURL string }
json.Unmarshal(msg, &session)
bt := pw.Chromium
if s.Type == "firefox" {
bt = pw.Firefox
}
browser, err := bt.Connect(session.WsURL, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
log.Fatal(err)
}
return browser
}
retry := res.StatusCode == 429 || res.StatusCode == 503 || (res.StatusCode == 409 && bytes.Contains(msg, []byte("profile_version_downgrade")))
if !retry || attempt == 8 {
log.Fatalf("launch %d %s", res.StatusCode, msg)
}
wait, _ := strconv.Atoi(res.Header.Get("retry-after"))
if wait == 0 {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
func goTo(tab playwright.Page, url string) (*int, error) {
res, err := tab.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateDomcontentloaded, Timeout: playwright.Float(45000)})
if err != nil {
return nil, err
}
if res == nil {
return nil, nil
}
status := res.Status()
return &status, nil
}
// Load a page (a network failure is retried once), then watch it once a second for 12 s without touching it.
// content = the page straight away; cleared = DataDome intercepted (403 or its device check) and let the browser
// through by itself; challenge = a CAPTCHA (never solved); blocked = DataDome's block page.
func visit(tab playwright.Page, url string) *cell {
t0 := time.Now()
status, err := goTo(tab, url)
if err != nil {
if status, err = goTo(tab, url); err != nil {
return &cell{Outcome: "error"}
}
}
checked := status != nil && *status == 403
until := time.Now().Add(12 * time.Second) // the watch starts once the page has loaded, however slow the proxy was
for time.Now().Before(until) {
st := ""
if v, err := tab.Evaluate(state); err == nil { // fails while the device check reloads the page
st, _ = v.(string)
}
switch st {
case "blocked", "challenge":
return &cell{Outcome: st, Status: status}
case "checking":
checked = true
case "page":
if checked {
seconds := int(math.Round(time.Since(t0).Seconds()))
return &cell{Outcome: "cleared", Status: status, Seconds: &seconds}
}
return &cell{Outcome: "content", Status: status}
}
time.Sleep(time.Second)
}
if checked {
return &cell{Outcome: "challenge", Status: status}
}
return &cell{Outcome: "error", Status: status}
}
func newTab(ctx playwright.BrowserContext) playwright.Page {
tab, err := ctx.NewPage()
if err != nil {
log.Fatal(err)
}
return tab
}
// One session per setup and network; a fresh context per page so every load is a first visit.
func measure(setupKey, net string, keys []string) row {
s := setups[setupKey]
browser := launch(s, net, "", nil)
defer browser.Close()
r := row{Setup: s.Name, Network: networkLabels[net]}
for _, k := range keys {
ctx, err := browser.NewContext()
if err != nil {
log.Fatal(err)
}
c := visit(newTab(ctx), pages[k])
if k == "home" {
r.Home = c
} else {
r.Pricing = c
}
ctx.Close()
}
return r
}
func api(method, path string) map[string]any {
req, _ := http.NewRequest(method, "https://cdpfleet.com"+path, nil)
req.Header.Set("x-api-key", key)
out := map[string]any{}
if res, err := http.DefaultClient.Do(req); err == nil {
json.NewDecoder(res.Body).Decode(&out)
res.Body.Close()
}
return out
}
func profileInfo(name string) map[string]any {
list, _ := api("GET", "/v1/me/profiles")["profiles"].([]any)
for _, p := range list {
if m := p.(map[string]any); m["name"] == name {
return m
}
}
return nil
}
func hasCookie(ctx playwright.BrowserContext) bool {
cookies, _ := ctx.Cookies()
for _, c := range cookies {
if c.Name == "datadome" {
return true
}
}
return false
}
type returnVisit struct {
Setup string `json:"setup"`
Network string `json:"network"`
First *cell `json:"first"`
Second *cell `json:"second"`
}
// A returning visitor: the same profile and the same sticky exit, two sessions a few seconds apart. The profile keeps
// the datadome cookie the first visit was given.
func returning(setupKey string) returnVisit {
s := setups[setupKey]
name := "case65-" + runID + "-" + s.ID
visits := []*cell{}
for i := 0; i < 2; i++ {
browser := launch(s, "tier1", s.ID+"p", map[string]any{"profile": name})
ctx := browser.Contexts()[0] // the profile's own context; a NewContext() is not saved
c := visit(newTab(ctx), pages["home"])
// DataDome's script sets its cookie a moment after the page: give it up to 5 s.
for j := 0; j < 5 && !hasCookie(ctx); j++ {
time.Sleep(time.Second)
}
got := hasCookie(ctx)
c.Cookie = &got
visits = append(visits, c)
browser.Close()
// The node saves the profile after the browser exits: wait until it is free again.
for j := 0; j < 60; j++ {
if info := profileInfo(name); info != nil && info["in_use"] == false {
break
}
time.Sleep(2 * time.Second)
}
}
api("DELETE", "/v1/me/profiles/"+name)
return returnVisit{s.Name, networkLabels["tier1"], visits[0], visits[1]}
}
// Camoufox with humanize on and off: two page views in one context, with three mouse moves between them, so DataDome's
// script on the first page can see how the cursor moves before the second page is requested.
func humanize(on bool) row {
exit, label := "h0", "off"
if on {
exit, label = "h1", "on"
}
browser := launch(setups["camoufox"], "tier1", exit, map[string]any{"humanize": on})
defer browser.Close()
ctx, err := browser.NewContext()
if err != nil {
log.Fatal(err)
}
tab := newTab(ctx)
r := row{Setup: "Camoufox, humanize " + label, Network: networkLabels["tier1"]}
r.Home = visit(tab, pages["home"])
t0 := time.Now()
for _, xy := range [][2]float64{{200, 300}, {700, 250}, {450, 600}} {
tab.Mouse().Move(xy[0], xy[1])
}
moved := math.Round(time.Since(t0).Seconds()*10) / 10
r.MoveSeconds = &moved
r.Pricing = visit(tab, pages["pricing"])
return r
}
func main() {
var err error
pw, err = playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
matrix := []row{}
for _, net := range []string{"own", "tier1"} {
for _, setupKey := range []string{"chromium", "chrome", "edge", "camoufox"} {
matrix = append(matrix, measure(setupKey, net, []string{"home", "pricing"}))
}
}
tier4 := []row{}
for _, setupKey := range []string{"chrome", "camoufox"} {
tier4 = append(tier4, measure(setupKey, "tier4", []string{"home"}))
}
profiles := []returnVisit{returning("chrome"), returning("camoufox")}
human := []row{humanize(true), humanize(false)}
out, _ := json.MarshalIndent(map[string]any{"measured_at": time.Now().UTC().Format("2006-01-02"), "matrix": matrix, "tier4": tier4, "profiles": profiles, "humanize": human}, "", " ")
fmt.Println(string(out))
}
What we got
| Setup | Network | datadome.co | datadome.co/pricing | Measured |
|---|---|---|---|---|
| Chromium, headless | own proxy (rotating) | blocked (HTTP 403) | blocked (HTTP 403) | 2026-10-22 |
| Google Chrome, headful | own proxy (rotating) | content (HTTP 200) | content (HTTP 200) | 2026-10-22 |
| Microsoft Edge, headful | own proxy (rotating) | content (HTTP 200) | challenge (HTTP 403) | 2026-10-22 |
| Camoufox, defaults | own proxy (rotating) | challenge (HTTP 403) | cleared (HTTP 403, 22 s) | 2026-10-22 |
| Chromium, headless | cdpfleet-resi Tier 1, sticky US | blocked (HTTP 403) | blocked (HTTP 403) | 2026-10-22 |
| Google Chrome, headful | cdpfleet-resi Tier 1, sticky US | content (HTTP 200) | content (HTTP 200) | 2026-10-22 |
| Microsoft Edge, headful | cdpfleet-resi Tier 1, sticky US | content (HTTP 200) | content (HTTP 200) | 2026-10-22 |
| Camoufox, defaults | cdpfleet-resi Tier 1, sticky US | content (HTTP 200) | content (HTTP 200) | 2026-10-22 |
| Google Chrome, headful | cdpfleet-resi Tier 4, sticky US | content (HTTP 200) | — | 2026-10-22 |
| Camoufox, defaults | cdpfleet-resi Tier 4, sticky US | content (HTTP 200) | — | 2026-10-22 |
| Google Chrome, headful, returning (profile) | cdpfleet-resi Tier 1, sticky US | 1st visit: challenge (HTTP 403), cookie kept → 2nd: content (HTTP 200) | — | 2026-10-22 |
| Camoufox, defaults, returning (profile) | cdpfleet-resi Tier 1, sticky US | 1st visit: content (HTTP 200), cookie kept → 2nd: content (HTTP 200) | — | 2026-10-22 |
| Camoufox, humanize on (cursor moved 1.8 s) | cdpfleet-resi Tier 1, sticky US | content (HTTP 200) | content (HTTP 200) | 2026-10-22 |
| Camoufox, humanize off (cursor moved 0 s) | cdpfleet-resi Tier 1, sticky US | content (HTTP 200) | content (HTTP 200) | 2026-10-22 |
From the Node.js run on 2026-10-22. 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)
{
"measured_at": "2026-10-22",
"matrix": [
{
"setup": "Chromium, headless",
"network": "own proxy (rotating)",
"home": {
"outcome": "blocked",
"status": 403,
"seconds": null
},
"pricing": {
"outcome": "blocked",
"status": 403,
"seconds": null
}
},
{
"setup": "Google Chrome, headful",
"network": "own proxy (rotating)",
"home": {
"outcome": "content",
"status": 200,
"seconds": null
},
"pricing": {
"outcome": "content",
"status": 200,
"seconds": null
}
},
{
"setup": "Microsoft Edge, headful",
"network": "own proxy (rotating)",
"home": {
"outcome": "content",
"status": 200,
"seconds": null
},
"pricing": {
"outcome": "challenge",
"status": 403,
"seconds": null
}
},
{
"setup": "Camoufox, defaults",
"network": "own proxy (rotating)",
"home": {
"outcome": "challenge",
"status": 403,
"seconds": null
},
"pricing": {
"outcome": "cleared",
"status": 403,
"seconds": 22
}
},
{
"setup": "Chromium, headless",
"network": "cdpfleet-resi Tier 1, sticky US",
"home": {
"outcome": "blocked",
"status": 403,
"seconds": null
},
"pricing": {
"outcome": "blocked",
"status": 403,
"seconds": null
}
},
{
"setup": "Google Chrome, headful",
"network": "cdpfleet-resi Tier 1, sticky US",
"home": {
"outcome": "content",
"status": 200,
"seconds": null
},
"pricing": {
"outcome": "content",
"status": 200,
"seconds": null
}
},
{
"setup": "Microsoft Edge, headful",
"network": "cdpfleet-resi Tier 1, sticky US",
"home": {
"outcome": "content",
"status": 200,
"seconds": null
},
"pricing": {
"outcome": "content",
"status": 200,
"seconds": null
}
},
{
"setup": "Camoufox, defaults",
"network": "cdpfleet-resi Tier 1, sticky US",
"home": {
"outcome": "content",
"status": 200,
"seconds": null
},
"pricing": {
"outcome": "content",
"status": 200,
"seconds": null
}
}
],
"tier4": [
{
"setup": "Google Chrome, headful",
"network": "cdpfleet-resi Tier 4, sticky US",
"home": {
"outcome": "content",
"status": 200,
"seconds": null
}
},
{
"setup": "Camoufox, defaults",
"network": "cdpfleet-resi Tier 4, sticky US",
"home": {
"outcome": "content",
"status": 200,
"seconds": null
}
}
],
"profiles": [
{
"setup": "Google Chrome, headful",
"network": "cdpfleet-resi Tier 1, sticky US",
"first": {
"outcome": "challenge",
"status": 403,
"seconds": null,
"cookie": true
},
"second": {
"outcome": "content",
"status": 200,
"seconds": null,
"cookie": true
}
},
{
"setup": "Camoufox, defaults",
"network": "cdpfleet-resi Tier 1, sticky US",
"first": {
"outcome": "content",
"status": 200,
"seconds": null,
"cookie": true
},
"second": {
"outcome": "content",
"status": 200,
"seconds": null,
"cookie": true
}
}
],
"humanize": [
{
"setup": "Camoufox, humanize on",
"network": "cdpfleet-resi Tier 1, sticky US",
"home": {
"outcome": "content",
"status": 200,
"seconds": null
},
"pricing": {
"outcome": "content",
"status": 200,
"seconds": null
},
"move_seconds": 1.8
},
{
"setup": "Camoufox, humanize off",
"network": "cdpfleet-resi Tier 1, sticky US",
"home": {
"outcome": "content",
"status": 200,
"seconds": null
},
"pricing": {
"outcome": "content",
"status": 200,
"seconds": null
},
"move_seconds": 0
}
]
}Takeaways
- Headless Chromium got DataDome's block page, almost everywhere. On 2026-10-22, across five runs, it was answered with the block page (HTTP 403,
t=bv) in 19 of its 20 cells, through your own proxy and the sticky residential exit alike, and got the real page once. A headless build carries tells a page can read (case 16), and DataDome scores them before the page is served. - Headful Chrome, Edge and Camoufox mostly got through, and nothing guaranteed it. On the same two pages they loaded the real page in most cells, but each of them hit DataDome's CAPTCHA in some runs. Edge happened to get the page in all 10 of its cells on the Tier 1 exits; Chrome and Camoufox had whole runs on the CAPTCHA there. The network made no consistent difference: CAPTCHAs came up through your own rotating proxy, the Tier 1 exits and Tier 4 alike.
- DataDome decides on the first response. Every CAPTCHA and every block came back as HTTP 403 on the page request itself, and every real page as 200. Its device check let a browser through by itself only 3 times in five runs (in 21–33 s from the start of the load); everything else was settled on that first response.
- A returning visitor did no worse. The first visit set the
datadomecookie in every run, and the profile kept it. Chrome's first visit hit the CAPTCHA in 3 of 5 runs, and the second session with the same profile and exit loaded the real page in all three; Camoufox's second visit was as good as or better than its first in every run (one run stayed on the CAPTCHA both times). The cookie is how DataDome recognises a visitor it has already scored, which is why the exit stays sticky (case 40 has profiles in depth). humanizemade no measurable difference here. The page requested after three cursor moves loaded in every run withhumanizeon, and in 4 of 5 with it off; the one miss had already hit the CAPTCHA on the home page, before the cursor moved. Which vendor sits in front of a site is in case 62, what fingerprint test pages see in case 63, Cloudflare's side in case 64, and the signals themselves in What a page can read about your browser. These are dated snapshots of one vendor's own site; other sites run DataDome with their own settings.