Cases / #68 · 2026-10-24 · Easy
CAPTCHA demo pages: what reCAPTCHA v3 scores and hCaptcha show seven browsers, and why a demo can't rank them
Google's public reCAPTCHA v3 score demo and hCaptcha's demo widget, loaded once per cell by seven browser setups through your own proxy and a sticky cdpfleet-resi exit. The score each setup got, whether the hCaptcha widget rendered, and why vendors' demo pages tell you little about how a real site will treat your browser.
Chromium Google Chrome Microsoft Edge Brave Camoufox Firefox WebKit
Run on production on 2026-10-24: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
reCAPTCHA v3 gives every visitor a score from 0.0 (likely a bot) to 1.0 (likely a person) without showing anything, and the site decides what to do with it. People often open Google's public v3 demo to "check their score", and hCaptcha's demo to see whether a challenge appears. This case loads both demos once per browser setup and network and records what they report. Nothing is clicked, solved or retried; it is a dated measurement on the vendors' own demo pages, not an evasion guide. For the signals a page can read, see What a page can read about your browser.
What we used, and why
| What | Why |
|---|---|
| Seven setups | Headless Chromium; headful Google Chrome, Microsoft Edge and Brave; Camoufox and Firefox (both based on Firefox source; not affiliated with Mozilla); headful WebKit. Every launch uses record: false. |
proxy: your own vs cdpfleet-resi-country-us-session-<id> | Your own rotating proxy against one sticky residential exit shared by every setup, so a difference between rows on that network is the browser, not the IP. |
proxy_updatable on WebKit | WebKit takes an authenticated HTTP proxy through the updatable route (see WebKit). |
| A fresh context per page | Every load is a first visit, with no Google or hCaptcha cookies from an earlier page. |
page.evaluate() once a second | Google's demo prints its server's verify response, including "score", on the page; the hCaptcha demo is checked for its widget frame. Nothing on either page is touched. |
How it works
- For each network (your own proxy, then the sticky Tier 1 exit) and each of the seven setups, launch one session.
- Load Google's reCAPTCHA v3 demo in a fresh context and wait up to 20 s for the score it prints.
- Load hCaptcha's demo in another fresh context and wait up to 12 s for its widget to render. Do not click it.
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, webkit } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const RUN = randomBytes(3).toString('hex');
// The sticky residential exit is shared by every setup, so a difference between rows is the browser.
const NETWORKS = {
own: { label: 'own proxy (rotating)', proxy: process.env.PROXY_URL },
tier1: { label: 'cdpfleet-resi Tier 1, sticky US', proxy: `cdpfleet-resi-country-us-session-c68${RUN}-lifetime-30` },
};
const SETUPS = {
chromium: { setup: 'Chromium, headless', engine: 'chromium', type: chromium, headless: true },
chrome: { setup: 'Google Chrome, headful', engine: 'chrome', type: chromium, headless: false },
edge: { setup: 'Microsoft Edge, headful', engine: 'edge', type: chromium, headless: false },
brave: { setup: 'Brave, headful', engine: 'brave', type: chromium, headless: false },
camoufox: { setup: 'Camoufox, defaults', engine: 'camoufox', type: firefox, headless: true },
firefox: { setup: 'Firefox, headful', engine: 'firefox', type: firefox, headless: false },
webkit: { setup: 'WebKit, headful', engine: 'webkit', type: webkit, headless: false },
};
// Vendors' own public demo pages. Google's v3 demo asks for a score as soon as it loads and prints its
// server's verify response (with the score) on the page; hCaptcha's demo shows a checkbox widget.
const RECAPTCHA = 'https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php';
const HCAPTCHA = 'https://accounts.hcaptcha.com/demo';
// Read what the page shows; the same expressions run in every language.
const SCORE = `(() => { const m = /"score"\\s*:\\s*([0-9.]+)/.exec(document.body ? document.body.innerText : ''); return m ? Number(m[1]) : null; })()`;
const HC_WIDGET = `(() => { const f = document.querySelector('iframe[src*="hcaptcha.com"]'); return f ? (f.getBoundingClientRect().height > 0) : false; })()`;
async function launch(s, network) {
const body = { proxy: NETWORKS[network].proxy, headless: s.headless, record: false };
// WebKit takes an authenticated HTTP proxy through the updatable route (see the WebKit page).
if (s.engine === 'webkit' && /^https?:/.test(body.proxy)) body.proxy_updatable = true;
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(body),
});
if (res.ok) return s.type.connect((await res.json()).wsUrl, { headers: { 'x-api-key': KEY } });
if (![429, 503].includes(res.status) || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// One load; then wait up to 20 s for the score the demo prints. Nothing on the page is touched.
async function recaptcha(context) {
const tab = await context.newPage();
try {
await tab.goto(RECAPTCHA, { waitUntil: 'domcontentloaded', timeout: 45000 });
} catch {
await tab.close();
return { outcome: 'error', score: null };
}
let score = null;
for (let i = 0; i < 20 && score === null; i++) { await sleep(1000); score = await tab.evaluate(SCORE).catch(() => null); }
await tab.close().catch(() => {});
return { outcome: score === null ? 'no score' : 'score', score };
}
// hCaptcha only decides when someone clicks its checkbox, so a load shows whether the widget renders, nothing more.
async function hcaptcha(context) {
const tab = await context.newPage();
try {
await tab.goto(HCAPTCHA, { waitUntil: 'domcontentloaded', timeout: 45000 });
} catch {
await tab.close();
return { outcome: 'error' };
}
let shown = false;
for (let i = 0; i < 12 && !shown; i++) { await sleep(1000); shown = await tab.evaluate(HC_WIDGET).catch(() => false); }
await tab.close().catch(() => {});
return { outcome: shown ? 'widget shown' : 'no widget' };
}
async function row(setupKey, network) {
const s = SETUPS[setupKey];
const out = { setup: s.setup, network: NETWORKS[network].label };
let browser;
try {
browser = await launch(s, network);
} catch (err) {
return { ...out, recaptcha: { outcome: 'error', score: null }, hcaptcha: { outcome: 'error' } };
}
// A session the server ends (e.g. its memory limit) leaves the remaining cells as errors.
for (const [key, fn] of [['recaptcha', recaptcha], ['hcaptcha', hcaptcha]]) {
try {
const context = await browser.newContext(); // a fresh context: every load is a first visit
out[key] = await fn(context);
await context.close();
} catch {
out[key] = key === 'recaptcha' ? { outcome: 'error', score: null } : { outcome: 'error' };
}
}
await browser.close().catch(() => {});
return out;
}
const rows = [];
for (const network of ['own', 'tier1']) {
for (const setupKey of Object.keys(SETUPS)) rows.push(await row(setupKey, network));
}
console.log(JSON.stringify({ measured_at: new Date().toISOString().slice(0, 10), rows }, 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 re
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)
# The sticky residential exit is shared by every setup, so a difference between rows is the browser.
NETWORKS = {
"own": {"label": "own proxy (rotating)", "proxy": os.environ["PROXY_URL"]},
"tier1": {"label": "cdpfleet-resi Tier 1, sticky US", "proxy": f"cdpfleet-resi-country-us-session-c68{RUN}-lifetime-30"},
}
SETUPS = {
"chromium": {"setup": "Chromium, headless", "engine": "chromium", "type": "chromium", "headless": True},
"chrome": {"setup": "Google Chrome, headful", "engine": "chrome", "type": "chromium", "headless": False},
"edge": {"setup": "Microsoft Edge, headful", "engine": "edge", "type": "chromium", "headless": False},
"brave": {"setup": "Brave, headful", "engine": "brave", "type": "chromium", "headless": False},
"camoufox": {"setup": "Camoufox, defaults", "engine": "camoufox", "type": "firefox", "headless": True},
"firefox": {"setup": "Firefox, headful", "engine": "firefox", "type": "firefox", "headless": False},
"webkit": {"setup": "WebKit, headful", "engine": "webkit", "type": "webkit", "headless": False},
}
# Vendors' own public demo pages. Google's v3 demo asks for a score as soon as it loads and prints its
# server's verify response (with the score) on the page; hCaptcha's demo shows a checkbox widget.
RECAPTCHA = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php"
HCAPTCHA = "https://accounts.hcaptcha.com/demo"
# Read what the page shows; the same expressions run in every language.
SCORE = r"""(() => { const m = /"score"\s*:\s*([0-9.]+)/.exec(document.body ? document.body.innerText : ''); return m ? Number(m[1]) : null; })()"""
HC_WIDGET = """(() => { const f = document.querySelector('iframe[src*="hcaptcha.com"]'); return f ? (f.getBoundingClientRect().height > 0) : false; })()"""
def launch(p, s, network):
body = {"proxy": NETWORKS[network]["proxy"], "headless": s["headless"], "record": False}
# WebKit takes an authenticated HTTP proxy through the updatable route (see the WebKit page).
if s["engine"] == "webkit" and re.match(r"^https?:", body["proxy"]):
body["proxy_updatable"] = True
for attempt in range(1, 6):
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})
if res.status_code not in (429, 503) or attempt == 5:
raise RuntimeError(f"launch {res.status_code} {res.text}")
time.sleep(int(res.headers.get("retry-after") or 5))
def safe(tab, expr, default):
try:
return tab.evaluate(expr)
except Exception:
return default
# One load; then wait up to 20 s for the score the demo prints. Nothing on the page is touched.
def recaptcha(context):
tab = context.new_page()
try:
tab.goto(RECAPTCHA, wait_until="domcontentloaded", timeout=45000)
except Exception:
tab.close()
return {"outcome": "error", "score": None}
score = None
for _ in range(20):
time.sleep(1)
score = safe(tab, SCORE, None)
if score is not None:
break
tab.close()
return {"outcome": "no score" if score is None else "score", "score": score}
# hCaptcha only decides when someone clicks its checkbox, so a load shows whether the widget renders, nothing more.
def hcaptcha(context):
tab = context.new_page()
try:
tab.goto(HCAPTCHA, wait_until="domcontentloaded", timeout=45000)
except Exception:
tab.close()
return {"outcome": "error"}
shown = False
for _ in range(12):
time.sleep(1)
shown = safe(tab, HC_WIDGET, False)
if shown:
break
tab.close()
return {"outcome": "widget shown" if shown else "no widget"}
def row(p, setup_key, network):
s = SETUPS[setup_key]
out = {"setup": s["setup"], "network": NETWORKS[network]["label"]}
try:
browser = launch(p, s, network)
except Exception:
return {**out, "recaptcha": {"outcome": "error", "score": None}, "hcaptcha": {"outcome": "error"}}
# A session the server ends (e.g. its memory limit) leaves the remaining cells as errors.
for key, fn in (("recaptcha", recaptcha), ("hcaptcha", hcaptcha)):
try:
context = browser.new_context() # a fresh context: every load is a first visit
out[key] = fn(context)
context.close()
except Exception:
out[key] = {"outcome": "error", "score": None} if key == "recaptcha" else {"outcome": "error"}
try:
browser.close()
except Exception:
pass
return out
with sync_playwright() as p:
rows = [row(p, setup_key, network) for network in ("own", "tier1") for setup_key in SETUPS]
print(json.dumps({"measured_at": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "rows": rows}, 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 Network(String label, String proxy) {}
record Setup(String name, String engine, String type, boolean headless) {}
// The sticky residential exit is shared by every setup, so a difference between rows is the browser.
static final Map<String, Network> NETWORKS = Map.of(
"own", new Network("own proxy (rotating)", System.getenv("PROXY_URL")),
"tier1", new Network("cdpfleet-resi Tier 1, sticky US", "cdpfleet-resi-country-us-session-c68" + RUN + "-lifetime-30"));
static final Map<String, Setup> SETUPS = new LinkedHashMap<>();
static {
SETUPS.put("chromium", new Setup("Chromium, headless", "chromium", "chromium", true));
SETUPS.put("chrome", new Setup("Google Chrome, headful", "chrome", "chromium", false));
SETUPS.put("edge", new Setup("Microsoft Edge, headful", "edge", "chromium", false));
SETUPS.put("brave", new Setup("Brave, headful", "brave", "chromium", false));
SETUPS.put("camoufox", new Setup("Camoufox, defaults", "camoufox", "firefox", true));
SETUPS.put("firefox", new Setup("Firefox, headful", "firefox", "firefox", false));
SETUPS.put("webkit", new Setup("WebKit, headful", "webkit", "webkit", false));
}
// Vendors' own public demo pages. Google's v3 demo asks for a score as soon as it loads and prints its
// server's verify response (with the score) on the page; hCaptcha's demo shows a checkbox widget.
static final String RECAPTCHA = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php";
static final String HCAPTCHA = "https://accounts.hcaptcha.com/demo";
// Read what the page shows; the same expressions run in every language.
static final String SCORE = "(() => { const m = /\"score\"\\s*:\\s*([0-9.]+)/.exec(document.body ? document.body.innerText : ''); return m ? Number(m[1]) : null; })()";
static final String HC_WIDGET = "(() => { const f = document.querySelector('iframe[src*=\"hcaptcha.com\"]'); return f ? (f.getBoundingClientRect().height > 0) : false; })()";
static Playwright playwright;
static Browser launch(Setup s, String network) throws Exception {
JsonObject body = new JsonObject();
String proxy = NETWORKS.get(network).proxy();
body.addProperty("proxy", proxy);
body.addProperty("headless", s.headless());
body.addProperty("record", false);
// WebKit takes an authenticated HTTP proxy through the updatable route (see the WebKit page).
if (s.engine().equals("webkit") && proxy.matches("^https?:.*")) body.addProperty("proxy_updatable", true);
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() : s.type().equals("webkit") ? playwright.webkit() : playwright.chromium();
return type.connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
}
if ((res.statusCode() != 429 && res.statusCode() != 503) || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
}
}
static Object safe(Page tab, String expr) {
try { return tab.evaluate(expr); } catch (PlaywrightException err) { return null; }
}
static boolean open(Page tab, String url) {
try {
tab.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(45000));
return true;
} catch (PlaywrightException err) {
return false;
}
}
// One load; then wait up to 20 s for the score the demo prints. Nothing on the page is touched.
static JsonObject recaptcha(BrowserContext context) throws Exception {
Page tab = context.newPage();
JsonObject cell = new JsonObject();
if (!open(tab, RECAPTCHA)) {
tab.close();
cell.addProperty("outcome", "error");
cell.add("score", JsonNull.INSTANCE);
return cell;
}
Object score = null;
for (int i = 0; i < 20 && score == null; i++) { Thread.sleep(1000); score = safe(tab, SCORE); }
try { tab.close(); } catch (PlaywrightException err) { }
cell.addProperty("outcome", score == null ? "no score" : "score");
if (score == null) cell.add("score", JsonNull.INSTANCE); else cell.addProperty("score", ((Number) score).doubleValue());
return cell;
}
// hCaptcha only decides when someone clicks its checkbox, so a load shows whether the widget renders, nothing more.
static JsonObject hcaptcha(BrowserContext context) throws Exception {
Page tab = context.newPage();
JsonObject cell = new JsonObject();
if (!open(tab, HCAPTCHA)) {
tab.close();
cell.addProperty("outcome", "error");
return cell;
}
boolean shown = false;
for (int i = 0; i < 12 && !shown; i++) { Thread.sleep(1000); shown = Boolean.TRUE.equals(safe(tab, HC_WIDGET)); }
try { tab.close(); } catch (PlaywrightException err) { }
cell.addProperty("outcome", shown ? "widget shown" : "no widget");
return cell;
}
static JsonObject row(String setupKey, String network) throws Exception {
Setup s = SETUPS.get(setupKey);
JsonObject out = new JsonObject();
out.addProperty("setup", s.name());
out.addProperty("network", NETWORKS.get(network).label());
Browser browser;
try {
browser = launch(s, network);
} catch (Exception err) {
JsonObject rc = new JsonObject();
rc.addProperty("outcome", "error");
rc.add("score", JsonNull.INSTANCE);
JsonObject hc = new JsonObject();
hc.addProperty("outcome", "error");
out.add("recaptcha", rc);
out.add("hcaptcha", hc);
return out;
}
// A session the server ends (e.g. its memory limit) leaves the remaining cells as errors.
try {
BrowserContext context = browser.newContext(); // a fresh context: every load is a first visit
out.add("recaptcha", recaptcha(context));
context.close();
} catch (PlaywrightException err) {
JsonObject rc = new JsonObject();
rc.addProperty("outcome", "error");
rc.add("score", JsonNull.INSTANCE);
out.add("recaptcha", rc);
}
try {
BrowserContext context = browser.newContext();
out.add("hcaptcha", hcaptcha(context));
context.close();
} catch (PlaywrightException err) {
JsonObject hc = new JsonObject();
hc.addProperty("outcome", "error");
out.add("hcaptcha", hc);
}
try { browser.close(); } catch (PlaywrightException err) { }
return out;
}
public static void main(String[] args) throws Exception {
playwright = Playwright.create();
JsonArray rows = new JsonArray();
for (String network : List.of("own", "tier1")) for (String setupKey : SETUPS.keySet()) rows.add(row(setupKey, network));
playwright.close();
JsonObject result = new JsonObject();
result.addProperty("measured_at", Instant.now().toString().substring(0, 10));
result.add("rows", rows);
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.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
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();
// The sticky residential exit is shared by every setup, so a difference between rows is the browser.
var networks = new Dictionary<string, (string Label, string Proxy)>
{
["own"] = ("own proxy (rotating)", Environment.GetEnvironmentVariable("PROXY_URL")!),
["tier1"] = ("cdpfleet-resi Tier 1, sticky US", $"cdpfleet-resi-country-us-session-c68{run}-lifetime-30"),
};
var setups = new List<(string Key, string Name, string Engine, IBrowserType Type, bool Headless)>
{
("chromium", "Chromium, headless", "chromium", playwright.Chromium, true),
("chrome", "Google Chrome, headful", "chrome", playwright.Chromium, false),
("edge", "Microsoft Edge, headful", "edge", playwright.Chromium, false),
("brave", "Brave, headful", "brave", playwright.Chromium, false),
("camoufox", "Camoufox, defaults", "camoufox", playwright.Firefox, true),
("firefox", "Firefox, headful", "firefox", playwright.Firefox, false),
("webkit", "WebKit, headful", "webkit", playwright.Webkit, false),
};
// Vendors' own public demo pages. Google's v3 demo asks for a score as soon as it loads and prints its
// server's verify response (with the score) on the page; hCaptcha's demo shows a checkbox widget.
const string Recaptcha = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php";
const string Hcaptcha = "https://accounts.hcaptcha.com/demo";
// Read what the page shows; the same expressions run in every language.
const string Score = """(() => { const m = /"score"\s*:\s*([0-9.]+)/.exec(document.body ? document.body.innerText : ''); return m ? Number(m[1]) : null; })()""";
const string HcWidget = """(() => { const f = document.querySelector('iframe[src*="hcaptcha.com"]'); return f ? (f.getBoundingClientRect().height > 0) : false; })()""";
async Task<IBrowser> Launch((string Key, string Name, string Engine, IBrowserType Type, bool Headless) s, string network)
{
var proxy = networks[network].Proxy;
var body = new JsonObject { ["proxy"] = proxy, ["headless"] = s.Headless, ["record"] = false };
// WebKit takes an authenticated HTTP proxy through the updatable route (see the WebKit page).
if (s.Engine == "webkit" && Regex.IsMatch(proxy, "^https?:")) body["proxy_updatable"] = true;
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;
if ((code != 429 && code != 503) || attempt == 5) throw new Exception($"launch {code} {await res.Content.ReadAsStringAsync()}");
await Task.Delay(TimeSpan.FromSeconds(res.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5));
}
}
async Task<bool> Open(IPage tab, string url)
{
try { await tab.GotoAsync(url, new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 45000 }); return true; }
catch (PlaywrightException) { return false; }
}
// One load; then wait up to 20 s for the score the demo prints. Nothing on the page is touched.
async Task<JsonObject> RecaptchaCell(IBrowserContext context)
{
var tab = await context.NewPageAsync();
if (!await Open(tab, Recaptcha)) { await tab.CloseAsync(); return new JsonObject { ["outcome"] = "error", ["score"] = null }; }
double? score = null;
for (var i = 0; i < 20 && score == null; i++)
{
await Task.Delay(1000);
try
{
var v = await tab.EvaluateAsync<JsonElement>(Score);
if (v.ValueKind == JsonValueKind.Number) score = v.GetDouble();
}
catch (PlaywrightException) { }
}
try { await tab.CloseAsync(); } catch (PlaywrightException) { }
return new JsonObject { ["outcome"] = score == null ? "no score" : "score", ["score"] = score };
}
// hCaptcha only decides when someone clicks its checkbox, so a load shows whether the widget renders, nothing more.
async Task<JsonObject> HcaptchaCell(IBrowserContext context)
{
var tab = await context.NewPageAsync();
if (!await Open(tab, Hcaptcha)) { await tab.CloseAsync(); return new JsonObject { ["outcome"] = "error" }; }
var shown = false;
for (var i = 0; i < 12 && !shown; i++)
{
await Task.Delay(1000);
try { shown = await tab.EvaluateAsync<bool>(HcWidget); } catch (PlaywrightException) { }
}
try { await tab.CloseAsync(); } catch (PlaywrightException) { }
return new JsonObject { ["outcome"] = shown ? "widget shown" : "no widget" };
}
async Task<JsonObject> Row((string Key, string Name, string Engine, IBrowserType Type, bool Headless) s, string network)
{
var output = new JsonObject { ["setup"] = s.Name, ["network"] = networks[network].Label };
IBrowser browser;
try { browser = await Launch(s, network); }
catch (Exception)
{
output["recaptcha"] = new JsonObject { ["outcome"] = "error", ["score"] = null };
output["hcaptcha"] = new JsonObject { ["outcome"] = "error" };
return output;
}
// A session the server ends (e.g. its memory limit) leaves the remaining cells as errors.
try
{
var context = await browser.NewContextAsync(); // a fresh context: every load is a first visit
output["recaptcha"] = await RecaptchaCell(context);
await context.CloseAsync();
}
catch (PlaywrightException) { output["recaptcha"] = new JsonObject { ["outcome"] = "error", ["score"] = null }; }
try
{
var context = await browser.NewContextAsync();
output["hcaptcha"] = await HcaptchaCell(context);
await context.CloseAsync();
}
catch (PlaywrightException) { output["hcaptcha"] = new JsonObject { ["outcome"] = "error" }; }
try { await browser.CloseAsync(); } catch (PlaywrightException) { }
return output;
}
var rows = new JsonArray();
foreach (var network in new[] { "own", "tier1" })
foreach (var s in setups) rows.Add(await Row(s, network));
var result = new JsonObject { ["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"), ["rows"] = rows };
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"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"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) }()
type network struct{ Label, Proxy string }
// The sticky residential exit is shared by every setup, so a difference between rows is the browser.
var networks = map[string]network{
"own": {"own proxy (rotating)", os.Getenv("PROXY_URL")},
"tier1": {"cdpfleet-resi Tier 1, sticky US", "cdpfleet-resi-country-us-session-c68" + runID + "-lifetime-30"},
}
type setup struct {
Name, Engine, Type string
Headless bool
}
var setups = []setup{
{"Chromium, headless", "chromium", "chromium", true},
{"Google Chrome, headful", "chrome", "chromium", false},
{"Microsoft Edge, headful", "edge", "chromium", false},
{"Brave, headful", "brave", "chromium", false},
{"Camoufox, defaults", "camoufox", "firefox", true},
{"Firefox, headful", "firefox", "firefox", false},
{"WebKit, headful", "webkit", "webkit", false},
}
// Vendors' own public demo pages. Google's v3 demo asks for a score as soon as it loads and prints its
// server's verify response (with the score) on the page; hCaptcha's demo shows a checkbox widget.
const recaptchaURL = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php"
const hcaptchaURL = "https://accounts.hcaptcha.com/demo"
// Read what the page shows; the same expressions run in every language.
const scoreJS = `(() => { const m = /"score"\s*:\s*([0-9.]+)/.exec(document.body ? document.body.innerText : ''); return m ? Number(m[1]) : null; })()`
const hcWidgetJS = `(() => { const f = document.querySelector('iframe[src*="hcaptcha.com"]'); return f ? (f.getBoundingClientRect().height > 0) : false; })()`
type rcCell struct {
Outcome string `json:"outcome"`
Score *float64 `json:"score"`
}
type hcCell struct {
Outcome string `json:"outcome"`
}
type row struct {
Setup string `json:"setup"`
Network string `json:"network"`
Recaptcha rcCell `json:"recaptcha"`
Hcaptcha hcCell `json:"hcaptcha"`
}
var pw *playwright.Playwright
func launch(s setup, net string) (playwright.Browser, error) {
proxy := networks[net].Proxy
body := map[string]any{"proxy": proxy, "headless": s.Headless, "record": false}
// WebKit takes an authenticated HTTP proxy through the updatable route (see the WebKit page).
if s.Engine == "webkit" && regexp.MustCompile(`^https?:`).MatchString(proxy) {
body["proxy_updatable"] = true
}
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 {
return nil, 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
} else if s.Type == "webkit" {
bt = pw.WebKit
}
return bt.Connect(session.WsURL, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
}
if (res.StatusCode != 429 && res.StatusCode != 503) || attempt == 5 {
return nil, errors.New("launch " + strconv.Itoa(res.StatusCode) + " " + string(msg))
}
wait, _ := strconv.Atoi(res.Header.Get("retry-after"))
if wait == 0 {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
func open(tab playwright.Page, url string) bool {
_, err := tab.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateDomcontentloaded, Timeout: playwright.Float(45000)})
return err == nil
}
// One load; then wait up to 20 s for the score the demo prints. Nothing on the page is touched.
func recaptcha(ctx playwright.BrowserContext) rcCell {
tab, err := ctx.NewPage()
if err != nil {
return rcCell{Outcome: "error"}
}
defer tab.Close()
if !open(tab, recaptchaURL) {
return rcCell{Outcome: "error"}
}
for i := 0; i < 20; i++ {
time.Sleep(time.Second)
v, err := tab.Evaluate(scoreJS)
if err != nil || v == nil {
continue
}
var score float64
switch n := v.(type) {
case float64:
score = n
case int:
score = float64(n)
default:
continue
}
return rcCell{Outcome: "score", Score: &score}
}
return rcCell{Outcome: "no score"}
}
// hCaptcha only decides when someone clicks its checkbox, so a load shows whether the widget renders, nothing more.
func hcaptcha(ctx playwright.BrowserContext) hcCell {
tab, err := ctx.NewPage()
if err != nil {
return hcCell{Outcome: "error"}
}
defer tab.Close()
if !open(tab, hcaptchaURL) {
return hcCell{Outcome: "error"}
}
for i := 0; i < 12; i++ {
time.Sleep(time.Second)
if v, err := tab.Evaluate(hcWidgetJS); err == nil && v == true {
return hcCell{Outcome: "widget shown"}
}
}
return hcCell{Outcome: "no widget"}
}
func measure(s setup, net string) row {
r := row{Setup: s.Name, Network: networks[net].Label}
browser, err := launch(s, net)
if err != nil {
r.Recaptcha, r.Hcaptcha = rcCell{Outcome: "error"}, hcCell{Outcome: "error"}
return r
}
defer browser.Close()
// A session the server ends (e.g. its memory limit) leaves the remaining cells as errors.
r.Recaptcha, r.Hcaptcha = rcCell{Outcome: "error"}, hcCell{Outcome: "error"}
if ctx, err := browser.NewContext(); err == nil { // a fresh context: every load is a first visit
r.Recaptcha = recaptcha(ctx)
ctx.Close()
}
if ctx, err := browser.NewContext(); err == nil {
r.Hcaptcha = hcaptcha(ctx)
ctx.Close()
}
return r
}
func main() {
var err error
pw, err = playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
rows := []row{}
for _, net := range []string{"own", "tier1"} {
for _, s := range setups {
rows = append(rows, measure(s, net))
}
}
out, _ := json.MarshalIndent(map[string]any{"measured_at": time.Now().UTC().Format("2006-01-02"), "rows": rows}, "", " ")
fmt.Println(string(out))
}
What we got
| Setup | Network | reCAPTCHA v3 demo score | hCaptcha demo | Measured |
|---|---|---|---|---|
| Chromium, headless | own proxy (rotating) | no score | no widget | 2026-10-24 |
| Google Chrome, headful | own proxy (rotating) | 0.9 | no widget | 2026-10-24 |
| Microsoft Edge, headful | own proxy (rotating) | 0.9 | widget shown | 2026-10-24 |
| Brave, headful | own proxy (rotating) | 0.9 | widget shown | 2026-10-24 |
| Camoufox, defaults | own proxy (rotating) | 0.9 | widget shown | 2026-10-24 |
| Firefox, headful | own proxy (rotating) | 0.9 | widget shown | 2026-10-24 |
| WebKit, headful | own proxy (rotating) | 0.9 | widget shown | 2026-10-24 |
| Chromium, headless | cdpfleet-resi Tier 1, sticky US | 0.9 | widget shown | 2026-10-24 |
| Google Chrome, headful | cdpfleet-resi Tier 1, sticky US | 0.9 | widget shown | 2026-10-24 |
| Microsoft Edge, headful | cdpfleet-resi Tier 1, sticky US | 0.9 | widget shown | 2026-10-24 |
| Brave, headful | cdpfleet-resi Tier 1, sticky US | 0.9 | widget shown | 2026-10-24 |
| Camoufox, defaults | cdpfleet-resi Tier 1, sticky US | 0.9 | widget shown | 2026-10-24 |
| Firefox, headful | cdpfleet-resi Tier 1, sticky US | 0.9 | widget shown | 2026-10-24 |
| WebKit, headful | cdpfleet-resi Tier 1, sticky US | 0.9 | widget shown | 2026-10-24 |
From the Node.js run on 2026-10-24. 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-24",
"rows": [
{
"setup": "Chromium, headless",
"network": "own proxy (rotating)",
"recaptcha": {
"outcome": "no score",
"score": null
},
"hcaptcha": {
"outcome": "no widget"
}
},
{
"setup": "Google Chrome, headful",
"network": "own proxy (rotating)",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "no widget"
}
},
{
"setup": "Microsoft Edge, headful",
"network": "own proxy (rotating)",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "Brave, headful",
"network": "own proxy (rotating)",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "Camoufox, defaults",
"network": "own proxy (rotating)",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "Firefox, headful",
"network": "own proxy (rotating)",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "WebKit, headful",
"network": "own proxy (rotating)",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "Chromium, headless",
"network": "cdpfleet-resi Tier 1, sticky US",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "Google Chrome, headful",
"network": "cdpfleet-resi Tier 1, sticky US",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "Microsoft Edge, headful",
"network": "cdpfleet-resi Tier 1, sticky US",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "Brave, headful",
"network": "cdpfleet-resi Tier 1, sticky US",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "Camoufox, defaults",
"network": "cdpfleet-resi Tier 1, sticky US",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "Firefox, headful",
"network": "cdpfleet-resi Tier 1, sticky US",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
},
{
"setup": "WebKit, headful",
"network": "cdpfleet-resi Tier 1, sticky US",
"recaptcha": {
"outcome": "score",
"score": 0.9
},
"hcaptcha": {
"outcome": "widget shown"
}
}
]
}Takeaways
- Every score Google's v3 demo returned was 0.9, for every setup, headless Chromium included. On 2026-10-24, 60 of 70 cells across five runs got a score and all of them were 0.9: headless Chromium, headful Chrome, Edge and Brave, Camoufox, Firefox and headful WebKit, on your own proxy and on a sticky residential exit alike. The demo page itself says its score "is not a reflection on your Google account or type of traffic", so treat it as a demo, not a verdict.
- The misses were network, not browser. 10 cells got no score within 20 s: 9 of them on the rotating own proxy, where the demo's scripts sometimes didn't load in time, and one headless Chromium cell on the residential exit.
- hCaptcha's demo showed its widget to every setup. It rendered in 68 of 70 cells (the 2 misses were in one run, on your own proxy); nothing was clicked, so this records only that the widget appears, not whether a challenge would follow.
- Demo widgets don't separate browsers; real protections do. The same headless Chromium that scored 0.9 here was blocked by Akamai and challenged by HUMAN (case 67), stuck on Cloudflare's interstitial (case 64) and blocked by DataDome (case 65). Judge a setup by the site you care about, not by a CAPTCHA demo. Turnstile's demo modes are in case 64; which vendor a site uses is case 62 (which bot protection is this); what pages can read about a browser is /docs/fingerprinting.