Examples & benchmarks / #74 · 2026-10-28 · Benchmark · Medium · for geo testing, web scraping
Does the browser agree with its IP? Timezone, language and WebRTC through exits in three countries
Five setups through residential exits in the US, Germany and Japan: which ones keep timezone, language, Accept-Language, Intl formats and the WebRTC address consistent with the exit IP, and which mismatches a site can check.
Run on production on 2026-10-28: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
A benchmark: the results below are what this setup measured on the dates shown, on our own test runs. Sites, networks and browsers change, so rerun the code before you rely on the numbers.
The problem
A site that knows your IP can compare it with what the browser says: is the browser's timezone the one the IP is in, does its language fit the country, does Accept-Language say the same as navigator.language, and does WebRTC reveal another address? Any disagreement is a cheap signal that something is off. This case measures five setups against those checks, through one sticky residential exit per country. See what a page can read about your browser for the signals in general.
What we used, and why
| What | Why |
|---|---|
cdpfleet-resi-country-us|de|jp-session-… | One sticky residential exit per country, shared by that country's five setups. |
geoip | Camoufox matches timezone, language and location to the exit IP at launch (on by default). |
locale | Camoufox's language and formats, set by hand (here a deliberate mismatch, pt-BR). |
browser.newContext({ locale, timezoneId }) | For Chrome: set language and timezone per context, here from a first lookup of the exit. |
main_world_eval + "mw:" | Reads the values in the page's own JavaScript world on Camoufox, as the site sees them. |
How it works
- For each country, open one sticky residential exit and run the five setups one after another on it.
- Open the exit-lookup page (ip-api.com's JSON, which carries the exit's country, timezone and IP).
- In that page, read
navigator.languages, the Accept-Language the browser sends (echoed by httpbin.org), Intl's timezone and locale, a formatted number, and the WebRTC public address, compared with the exit IP and never printed. - List each check that disagrees with the exit.
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)
// npm install [email protected]
// env: CDPFLEET_API_KEY (residential exits come from cdpfleet-resi tokens)
import { chromium, firefox } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const RUN = Date.now().toString(36).slice(-4);
const COUNTRIES = [
{ country: 'US', locale: 'en-US' },
{ country: 'DE', locale: 'de-DE' },
{ country: 'JP', locale: 'ja-JP' },
];
// [label, engine, launch options, context options from the exit ("match" = exit's locale + timezone)]
const SETUPS = [
['Camoufox (geoip on, default)', 'camoufox', { geoip: true }],
['Camoufox, geoip: false', 'camoufox', { geoip: false }],
['Camoufox, locale: "pt-BR"', 'camoufox', { geoip: true, locale: 'pt-BR' }],
['Chrome headful, no locale', 'chrome', { headless: false }],
['Chrome headful, context locale + timezoneId from the exit', 'chrome', { headless: false }, 'match'],
];
// What a site can read and compare, run on the exit-lookup page itself (ip-api.com's JSON, so the
// page knows the exit): exit country and timezone, languages, Accept-Language (echoed by httpbin),
// Intl's timezone and locale, a formatted number, and the WebRTC public address (compared with the
// exit, never returned).
const CHECK = `(async () => {
const exit = JSON.parse(document.body.innerText);
const headers = await fetch('https://httpbin.org/headers').then((r) => r.json()).catch(() => ({ headers: {} }));
const srflx = await new Promise((done) => {
const ips = new Set();
try {
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
pc.createDataChannel('x');
pc.onicecandidate = (e) => { const c = e.candidate && e.candidate.candidate; if (c && c.includes(' srflx ')) ips.add(c.split(' ')[4]); };
pc.createOffer().then((o) => pc.setLocalDescription(o));
setTimeout(() => { pc.close(); done([...ips]); }, 4000);
} catch { done([]); }
});
const intl = Intl.DateTimeFormat().resolvedOptions();
const acceptLanguage = (headers.headers['Accept-Language'] || '').split(',')[0];
const out = {
exit_country: exit.countryCode, exit_timezone: exit.timezone,
languages: navigator.languages.join(', '), accept_language: acceptLanguage,
timezone: intl.timeZone, intl_locale: intl.locale, number: (1234567.891).toLocaleString(),
webrtc: srflx.length === 0 ? 'none' : srflx.every((ip) => ip === exit.query) ? 'exit IP' : 'another IP',
};
out.mismatches = [
out.timezone !== exit.timezone && 'timezone ≠ IP timezone',
(navigator.language.split('-')[1] || '').toUpperCase() !== exit.countryCode && 'language region ≠ IP country',
acceptLanguage.toLowerCase() !== navigator.language.toLowerCase() && 'Accept-Language ≠ navigator.language',
!intl.locale.toLowerCase().startsWith(navigator.language.slice(0, 2).toLowerCase()) && 'Intl locale ≠ navigator.language',
out.webrtc === 'another IP' && 'WebRTC shows another IP',
].filter(Boolean);
return out;
})()`;
async function launch(engine, body) {
const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`launch ${res.status} ${(await res.text()).slice(0, 120)}`);
return res.json();
}
async function measure({ country, locale }, [label, engine, options, contextFrom], i) {
const proxy = `cdpfleet-resi-country-${country.toLowerCase()}-session-c74${country.toLowerCase()}${RUN}-lifetime-10`;
const row = { setup: label, country };
try {
const camoufox = engine === 'camoufox';
const { wsUrl } = await launch(engine, { proxy, headless: camoufox, record: false, ...(camoufox ? { main_world_eval: true } : {}), ...options });
const browser = await (camoufox ? firefox : chromium).connect(wsUrl, { headers: { 'x-api-key': KEY } });
try {
let contextOptions = {};
if (contextFrom === 'match') { // look the exit up first, then set the context to match it
const probe = await browser.newContext();
const p = await probe.newPage();
await p.goto('http://ip-api.com/json/?fields=timezone', { timeout: 60000 });
contextOptions = { locale, timezoneId: JSON.parse(await p.textContent('body')).timezone };
await probe.close();
}
const context = await browser.newContext(contextOptions);
const page = await context.newPage();
await page.goto('http://ip-api.com/json/?fields=countryCode,timezone,query', { timeout: 60000 });
Object.assign(row, await page.evaluate(camoufox ? `mw:${CHECK}` : CHECK));
} finally {
await browser.close();
}
} catch (err) {
row.error = err.message.split('\n')[0];
}
return row;
}
// Countries in parallel, setups one after another within a country (one exit per country).
const results = await Promise.all(COUNTRIES.map(async (c) => {
const rows = [];
for (const [i, s] of SETUPS.entries()) rows.push(await measure(c, s, i));
return rows;
}));
console.log(JSON.stringify(results.flat(), null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY (residential exits come from cdpfleet-resi tokens)
import json
import os
import time
from concurrent.futures import ThreadPoolExecutor
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
RUN = format(int(time.time() * 1000), "x")[-4:]
COUNTRIES = [
{"country": "US", "locale": "en-US"},
{"country": "DE", "locale": "de-DE"},
{"country": "JP", "locale": "ja-JP"},
]
# (label, engine, launch options, context options from the exit: "match" = exit's locale + timezone)
SETUPS = [
("Camoufox (geoip on, default)", "camoufox", {"geoip": True}, None),
("Camoufox, geoip: false", "camoufox", {"geoip": False}, None),
('Camoufox, locale: "pt-BR"', "camoufox", {"geoip": True, "locale": "pt-BR"}, None),
("Chrome headful, no locale", "chrome", {"headless": False}, None),
("Chrome headful, context locale + timezoneId from the exit", "chrome", {"headless": False}, "match"),
]
# What a site can read and compare, run on the exit-lookup page itself (ip-api.com's JSON, so the
# page knows the exit): exit country and timezone, languages, Accept-Language (echoed by httpbin),
# Intl's timezone and locale, a formatted number, and the WebRTC public address (compared with the
# exit, never returned).
CHECK = """(async () => {
const exit = JSON.parse(document.body.innerText);
const headers = await fetch('https://httpbin.org/headers').then((r) => r.json()).catch(() => ({ headers: {} }));
const srflx = await new Promise((done) => {
const ips = new Set();
try {
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
pc.createDataChannel('x');
pc.onicecandidate = (e) => { const c = e.candidate && e.candidate.candidate; if (c && c.includes(' srflx ')) ips.add(c.split(' ')[4]); };
pc.createOffer().then((o) => pc.setLocalDescription(o));
setTimeout(() => { pc.close(); done([...ips]); }, 4000);
} catch { done([]); }
});
const intl = Intl.DateTimeFormat().resolvedOptions();
const acceptLanguage = (headers.headers['Accept-Language'] || '').split(',')[0];
const out = {
exit_country: exit.countryCode, exit_timezone: exit.timezone,
languages: navigator.languages.join(', '), accept_language: acceptLanguage,
timezone: intl.timeZone, intl_locale: intl.locale, number: (1234567.891).toLocaleString(),
webrtc: srflx.length === 0 ? 'none' : srflx.every((ip) => ip === exit.query) ? 'exit IP' : 'another IP',
};
out.mismatches = [
out.timezone !== exit.timezone && 'timezone ≠ IP timezone',
(navigator.language.split('-')[1] || '').toUpperCase() !== exit.countryCode && 'language region ≠ IP country',
acceptLanguage.toLowerCase() !== navigator.language.toLowerCase() && 'Accept-Language ≠ navigator.language',
!intl.locale.toLowerCase().startsWith(navigator.language.slice(0, 2).toLowerCase()) && 'Intl locale ≠ navigator.language',
out.webrtc === 'another IP' && 'WebRTC shows another IP',
].filter(Boolean);
return out;
})()"""
def measure(c, setup):
label, engine, options, context_from = setup
cc = c["country"].lower()
proxy = f"cdpfleet-resi-country-{cc}-session-c74{cc}{RUN}-lifetime-10"
row = {"setup": label, "country": c["country"]}
camoufox = engine == "camoufox"
body = {"proxy": proxy, "headless": camoufox, "record": False, **({"main_world_eval": True} if camoufox else {}), **options}
res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json=body, timeout=180)
if not res.ok:
return {**row, "error": f"launch {res.status_code} {res.text[:120]}"}
with sync_playwright() as p: # the sync API is per thread
browser = (p.firefox if camoufox else p.chromium).connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
context_options = {}
if context_from == "match": # look the exit up first, then set the context to match it
probe = browser.new_context()
pg = probe.new_page()
pg.goto("http://ip-api.com/json/?fields=timezone", timeout=60000)
context_options = {"locale": c["locale"], "timezone_id": json.loads(pg.text_content("body"))["timezone"]}
probe.close()
page = browser.new_context(**context_options).new_page()
page.goto("http://ip-api.com/json/?fields=countryCode,timezone,query", timeout=60000)
row.update(page.evaluate(f"mw:{CHECK}" if camoufox else CHECK))
except Exception as err: # noqa: BLE001 — report the cell, keep the run going
row["error"] = str(err).split("\n")[0]
finally:
browser.close()
return row
# Countries in parallel, setups one after another within a country (one exit per country).
def country_rows(c):
return [measure(c, s) for s in SETUPS]
with ThreadPoolExecutor(len(COUNTRIES)) as pool:
print(json.dumps([r for rows in pool.map(country_rows, COUNTRIES) for r in rows], indent=2, ensure_ascii=False))
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY (residential exits come from cdpfleet-resi tokens)
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.concurrent.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String RUN = Long.toString(System.currentTimeMillis(), 36).substring(Long.toString(System.currentTimeMillis(), 36).length() - 4);
static final String[][] COUNTRIES = {{"US", "en-US"}, {"DE", "de-DE"}, {"JP", "ja-JP"}};
// {label, engine, launch options (JSON), match = context locale + timezone from a first lookup of the exit}
static final String[][] SETUPS = {
{"Camoufox (geoip on, default)", "camoufox", "{\"geoip\": true}", "no"},
{"Camoufox, geoip: false", "camoufox", "{\"geoip\": false}", "no"},
{"Camoufox, locale: \"pt-BR\"", "camoufox", "{\"geoip\": true, \"locale\": \"pt-BR\"}", "no"},
{"Chrome headful, no locale", "chrome", "{\"headless\": false}", "no"},
{"Chrome headful, context locale + timezoneId from the exit", "chrome", "{\"headless\": false}", "match"},
};
// What a site can read and compare, run on the exit-lookup page itself (ip-api.com's JSON, so the
// page knows the exit): exit country and timezone, languages, Accept-Language (echoed by httpbin),
// Intl's timezone and locale, a formatted number, and the WebRTC public address (compared with the
// exit, never returned).
static final String CHECK = """
(async () => {
const exit = JSON.parse(document.body.innerText);
const headers = await fetch('https://httpbin.org/headers').then((r) => r.json()).catch(() => ({ headers: {} }));
const srflx = await new Promise((done) => {
const ips = new Set();
try {
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
pc.createDataChannel('x');
pc.onicecandidate = (e) => { const c = e.candidate && e.candidate.candidate; if (c && c.includes(' srflx ')) ips.add(c.split(' ')[4]); };
pc.createOffer().then((o) => pc.setLocalDescription(o));
setTimeout(() => { pc.close(); done([...ips]); }, 4000);
} catch { done([]); }
});
const intl = Intl.DateTimeFormat().resolvedOptions();
const acceptLanguage = (headers.headers['Accept-Language'] || '').split(',')[0];
const out = {
exit_country: exit.countryCode, exit_timezone: exit.timezone,
languages: navigator.languages.join(', '), accept_language: acceptLanguage,
timezone: intl.timeZone, intl_locale: intl.locale, number: (1234567.891).toLocaleString(),
webrtc: srflx.length === 0 ? 'none' : srflx.every((ip) => ip === exit.query) ? 'exit IP' : 'another IP',
};
out.mismatches = [
out.timezone !== exit.timezone && 'timezone ≠ IP timezone',
(navigator.language.split('-')[1] || '').toUpperCase() !== exit.countryCode && 'language region ≠ IP country',
acceptLanguage.toLowerCase() !== navigator.language.toLowerCase() && 'Accept-Language ≠ navigator.language',
!intl.locale.toLowerCase().startsWith(navigator.language.slice(0, 2).toLowerCase()) && 'Intl locale ≠ navigator.language',
out.webrtc === 'another IP' && 'WebRTC shows another IP',
].filter(Boolean);
return out;
})()""";
static JsonObject measure(Playwright playwright, String code, String locale, String[] s) {
Gson gson = new Gson();
String cc = code.toLowerCase();
boolean camoufox = s[1].equals("camoufox");
JsonObject row = new JsonObject();
row.addProperty("setup", s[0]);
row.addProperty("country", code);
try {
JsonObject body = JsonParser.parseString(s[2]).getAsJsonObject();
body.addProperty("proxy", "cdpfleet-resi-country-" + cc + "-session-c74" + cc + RUN + "-lifetime-10");
if (!body.has("headless")) body.addProperty("headless", camoufox);
body.addProperty("record", false);
if (camoufox) body.addProperty("main_world_eval", true);
HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + s[1] + "/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) { row.addProperty("error", "launch " + res.statusCode()); return row; }
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
Browser browser = (camoufox ? playwright.firefox() : playwright.chromium()).connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Browser.NewContextOptions options = new Browser.NewContextOptions();
if (s[3].equals("match")) { // look the exit up first, then set the context to match it
BrowserContext probe = browser.newContext();
Page pg = probe.newPage();
pg.navigate("http://ip-api.com/json/?fields=timezone", new Page.NavigateOptions().setTimeout(60000));
options.setLocale(locale).setTimezoneId(JsonParser.parseString(pg.textContent("body")).getAsJsonObject().get("timezone").getAsString());
probe.close();
}
Page page = browser.newContext(options).newPage();
page.navigate("http://ip-api.com/json/?fields=countryCode,timezone,query", new Page.NavigateOptions().setTimeout(60000));
JsonObject seen = gson.toJsonTree(page.evaluate(camoufox ? "mw:" + CHECK : CHECK)).getAsJsonObject();
for (String k : seen.keySet()) row.add(k, seen.get(k));
} finally {
browser.close();
}
} catch (Exception err) {
row.addProperty("error", String.valueOf(err.getMessage()).split("\n")[0]);
}
return row;
}
public static void main(String[] args) throws Exception {
// Countries in parallel, setups one after another within a country (one exit per country).
ExecutorService pool = Executors.newFixedThreadPool(COUNTRIES.length);
List<Future<JsonArray>> futures = new ArrayList<>();
for (String[] c : COUNTRIES) futures.add(pool.submit(() -> {
JsonArray rows = new JsonArray();
try (Playwright playwright = Playwright.create()) { // Playwright objects are per thread
for (String[] s : SETUPS) rows.add(measure(playwright, c[0], c[1], s));
}
return rows;
}));
JsonArray all = new JsonArray();
for (Future<JsonArray> f : futures) all.addAll(f.get());
pool.shutdown();
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(all));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY (residential exits come from cdpfleet-resi tokens)
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var run = Convert.ToString(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), 16)[^4..];
var countries = new (string Code, string Locale)[] { ("US", "en-US"), ("DE", "de-DE"), ("JP", "ja-JP") };
// (label, engine, launch options, match = context locale + timezone from a first lookup of the exit)
var setups = new (string Label, string Engine, Dictionary<string, object> Options, bool Match)[]
{
("Camoufox (geoip on, default)", "camoufox", new() { ["geoip"] = true }, false),
("Camoufox, geoip: false", "camoufox", new() { ["geoip"] = false }, false),
("Camoufox, locale: \"pt-BR\"", "camoufox", new() { ["geoip"] = true, ["locale"] = "pt-BR" }, false),
("Chrome headful, no locale", "chrome", new() { ["headless"] = false }, false),
("Chrome headful, context locale + timezoneId from the exit", "chrome", new() { ["headless"] = false }, true),
};
// What a site can read and compare, run on the exit-lookup page itself (ip-api.com's JSON, so the
// page knows the exit): exit country and timezone, languages, Accept-Language (echoed by httpbin),
// Intl's timezone and locale, a formatted number, and the WebRTC public address (compared with the
// exit, never returned).
const string Check = """
(async () => {
const exit = JSON.parse(document.body.innerText);
const headers = await fetch('https://httpbin.org/headers').then((r) => r.json()).catch(() => ({ headers: {} }));
const srflx = await new Promise((done) => {
const ips = new Set();
try {
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
pc.createDataChannel('x');
pc.onicecandidate = (e) => { const c = e.candidate && e.candidate.candidate; if (c && c.includes(' srflx ')) ips.add(c.split(' ')[4]); };
pc.createOffer().then((o) => pc.setLocalDescription(o));
setTimeout(() => { pc.close(); done([...ips]); }, 4000);
} catch { done([]); }
});
const intl = Intl.DateTimeFormat().resolvedOptions();
const acceptLanguage = (headers.headers['Accept-Language'] || '').split(',')[0];
const out = {
exit_country: exit.countryCode, exit_timezone: exit.timezone,
languages: navigator.languages.join(', '), accept_language: acceptLanguage,
timezone: intl.timeZone, intl_locale: intl.locale, number: (1234567.891).toLocaleString(),
webrtc: srflx.length === 0 ? 'none' : srflx.every((ip) => ip === exit.query) ? 'exit IP' : 'another IP',
};
out.mismatches = [
out.timezone !== exit.timezone && 'timezone ≠ IP timezone',
(navigator.language.split('-')[1] || '').toUpperCase() !== exit.countryCode && 'language region ≠ IP country',
acceptLanguage.toLowerCase() !== navigator.language.toLowerCase() && 'Accept-Language ≠ navigator.language',
!intl.locale.toLowerCase().startsWith(navigator.language.slice(0, 2).toLowerCase()) && 'Intl locale ≠ navigator.language',
out.webrtc === 'another IP' && 'WebRTC shows another IP',
].filter(Boolean);
return out;
})()
""";
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(3) };
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
async Task<JsonObject> Measure((string Code, string Locale) c, (string Label, string Engine, Dictionary<string, object> Options, bool Match) s)
{
var cc = c.Code.ToLowerInvariant();
var row = new JsonObject { ["setup"] = s.Label, ["country"] = c.Code };
var camoufox = s.Engine == "camoufox";
var body = new Dictionary<string, object> { ["proxy"] = $"cdpfleet-resi-country-{cc}-session-c74{cc}{run}-lifetime-10", ["headless"] = camoufox, ["record"] = false };
if (camoufox) body["main_world_eval"] = true;
foreach (var (k, v) in s.Options) body[k] = v;
var res = await http.PostAsJsonAsync($"https://starter.cdpfleet.com/{s.Engine}/session", body);
if (!res.IsSuccessStatusCode) { row["error"] = $"launch {(int)res.StatusCode}"; return row; }
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
var browser = await (camoufox ? playwright.Firefox : playwright.Chromium).ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var options = new BrowserNewContextOptions();
if (s.Match) // look the exit up first, then set the context to match it
{
var probe = await browser.NewContextAsync();
var pg = await probe.NewPageAsync();
await pg.GotoAsync("http://ip-api.com/json/?fields=timezone", new() { Timeout = 60000 });
options.Locale = c.Locale;
options.TimezoneId = JsonNode.Parse((await pg.TextContentAsync("body"))!)!["timezone"]!.GetValue<string>();
await probe.CloseAsync();
}
var page = await (await browser.NewContextAsync(options)).NewPageAsync();
await page.GotoAsync("http://ip-api.com/json/?fields=countryCode,timezone,query", new() { Timeout = 60000 });
var seen = JsonNode.Parse((await page.EvaluateAsync<JsonElement>(camoufox ? "mw:" + Check : Check)).GetRawText())!.AsObject();
foreach (var (k, v) in seen.ToList()) { seen.Remove(k); row[k] = v; }
}
catch (Exception err) { row["error"] = err.Message.Split('\n')[0]; }
finally { await browser.CloseAsync(); }
return row;
}
// Countries in parallel, setups one after another within a country (one exit per country).
var perCountry = await Task.WhenAll(countries.Select(async c =>
{
var rows = new List<JsonObject>();
foreach (var s in setups) rows.Add(await Measure(c, s));
return rows;
}));
Console.WriteLine(JsonSerializer.Serialize(perCountry.SelectMany(r => r), 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 (residential exits come from cdpfleet-resi tokens)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
var run = func() string { s := strconv.FormatInt(time.Now().UnixMilli(), 36); return s[len(s)-4:] }()
type country struct{ Code, Locale string }
type setup struct {
Label, Engine string
Options map[string]any
Match bool // context locale + timezone from a first lookup of the exit
}
var countries = []country{{"US", "en-US"}, {"DE", "de-DE"}, {"JP", "ja-JP"}}
var setups = []setup{
{"Camoufox (geoip on, default)", "camoufox", map[string]any{"geoip": true}, false},
{"Camoufox, geoip: false", "camoufox", map[string]any{"geoip": false}, false},
{`Camoufox, locale: "pt-BR"`, "camoufox", map[string]any{"geoip": true, "locale": "pt-BR"}, false},
{"Chrome headful, no locale", "chrome", map[string]any{"headless": false}, false},
{"Chrome headful, context locale + timezoneId from the exit", "chrome", map[string]any{"headless": false}, true},
}
// What a site can read and compare, run on the exit-lookup page itself (ip-api.com's JSON, so the
// page knows the exit): exit country and timezone, languages, Accept-Language (echoed by httpbin),
// Intl's timezone and locale, a formatted number, and the WebRTC public address (compared with the
// exit, never returned).
const check = `(async () => {
const exit = JSON.parse(document.body.innerText);
const headers = await fetch('https://httpbin.org/headers').then((r) => r.json()).catch(() => ({ headers: {} }));
const srflx = await new Promise((done) => {
const ips = new Set();
try {
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
pc.createDataChannel('x');
pc.onicecandidate = (e) => { const c = e.candidate && e.candidate.candidate; if (c && c.includes(' srflx ')) ips.add(c.split(' ')[4]); };
pc.createOffer().then((o) => pc.setLocalDescription(o));
setTimeout(() => { pc.close(); done([...ips]); }, 4000);
} catch { done([]); }
});
const intl = Intl.DateTimeFormat().resolvedOptions();
const acceptLanguage = (headers.headers['Accept-Language'] || '').split(',')[0];
const out = {
exit_country: exit.countryCode, exit_timezone: exit.timezone,
languages: navigator.languages.join(', '), accept_language: acceptLanguage,
timezone: intl.timeZone, intl_locale: intl.locale, number: (1234567.891).toLocaleString(),
webrtc: srflx.length === 0 ? 'none' : srflx.every((ip) => ip === exit.query) ? 'exit IP' : 'another IP',
};
out.mismatches = [
out.timezone !== exit.timezone && 'timezone ≠ IP timezone',
(navigator.language.split('-')[1] || '').toUpperCase() !== exit.countryCode && 'language region ≠ IP country',
acceptLanguage.toLowerCase() !== navigator.language.toLowerCase() && 'Accept-Language ≠ navigator.language',
!intl.locale.toLowerCase().startsWith(navigator.language.slice(0, 2).toLowerCase()) && 'Intl locale ≠ navigator.language',
out.webrtc === 'another IP' && 'WebRTC shows another IP',
].filter(Boolean);
return out;
})()`
func launch(engine string, body map[string]any) (string, error) {
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(b))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
return "", fmt.Errorf("launch %d %.120s", res.StatusCode, msg)
}
var s struct{ WsUrl string }
return s.WsUrl, json.NewDecoder(res.Body).Decode(&s)
}
func measure(pw *playwright.Playwright, c country, s setup) map[string]any {
cc := strings.ToLower(c.Code)
row := map[string]any{"setup": s.Label, "country": c.Code}
fail := func(err error) map[string]any { row["error"] = strings.Split(err.Error(), "\n")[0]; return row }
camoufox := s.Engine == "camoufox"
body := map[string]any{"proxy": "cdpfleet-resi-country-" + cc + "-session-c74" + cc + run + "-lifetime-10", "headless": camoufox, "record": false}
if camoufox {
body["main_world_eval"] = true
}
for k, v := range s.Options {
body[k] = v
}
wsUrl, err := launch(s.Engine, body)
if err != nil {
return fail(err)
}
bt := pw.Chromium
if camoufox {
bt = pw.Firefox
}
browser, err := bt.Connect(wsUrl, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return fail(err)
}
defer browser.Close()
opts := playwright.BrowserNewContextOptions{}
if s.Match { // look the exit up first, then set the context to match it
probe, err := browser.NewContext()
if err != nil {
return fail(err)
}
pg, _ := probe.NewPage()
if _, err := pg.Goto("http://ip-api.com/json/?fields=timezone", playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
return fail(err)
}
text, _ := pg.TextContent("body")
var tz struct{ Timezone string }
json.Unmarshal([]byte(text), &tz)
opts.Locale, opts.TimezoneId = playwright.String(c.Locale), playwright.String(tz.Timezone)
probe.Close()
}
context, err := browser.NewContext(opts)
if err != nil {
return fail(err)
}
page, _ := context.NewPage()
if _, err := page.Goto("http://ip-api.com/json/?fields=countryCode,timezone,query", playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
return fail(err)
}
expr := check
if camoufox {
expr = "mw:" + check
}
v, err := page.Evaluate(expr)
if err != nil {
return fail(err)
}
for k, val := range v.(map[string]any) {
row[k] = val
}
return row
}
func main() {
pw, err := playwright.Run()
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
// Countries in parallel, setups one after another within a country (one exit per country).
perCountry := make([][]map[string]any, len(countries))
var wg sync.WaitGroup
for i, c := range countries {
wg.Add(1)
go func(i int, c country) {
defer wg.Done()
for _, s := range setups {
perCountry[i] = append(perCountry[i], measure(pw, c, s))
}
}(i, c)
}
wg.Wait()
var rows []map[string]any
for _, r := range perCountry {
rows = append(rows, r...)
}
out, _ := json.MarshalIndent(rows, "", " ")
fmt.Println(string(out))
}
What we got
| Setup | Exit | Browser timezone | IP timezone | navigator.languages | Accept-Language | Number | WebRTC | Mismatches a site can check |
|---|---|---|---|---|---|---|---|---|
| Camoufox (geoip on, default) | US | America/New_York | America/New_York | en-US, en | en-US | 1,234,567.891 | exit IP | none |
| Camoufox, geoip: false | US | UTC | America/New_York | en-US, en | en-US | 1,234,567.891 | none | timezone ≠ IP timezone |
| Camoufox, locale: "pt-BR" | US | America/New_York | America/New_York | pt-BR, pt | pt-BR | 1.234.567,891 | exit IP | language region ≠ IP country |
| Chrome headful, no locale | US | UTC | America/New_York | en-US, en | en-US | 1,234,567.891 | none | timezone ≠ IP timezone |
| Chrome headful, context locale + timezoneId from the exit | US | America/New_York | America/New_York | en-US | en-US | 1,234,567.891 | none | none |
| Camoufox (geoip on, default) | DE | Europe/Berlin | Europe/Berlin | de-DE, de | de-DE | 1.234.567,891 | exit IP | none |
| Camoufox, geoip: false | DE | UTC | Europe/Berlin | en-US, en | en-US | 1,234,567.891 | none | timezone ≠ IP timezone; language region ≠ IP country |
| Camoufox, locale: "pt-BR" | DE | Europe/Berlin | Europe/Berlin | pt-BR, pt | pt-BR | 1.234.567,891 | exit IP | language region ≠ IP country |
| Chrome headful, no locale | DE | UTC | Europe/Berlin | en-US, en | en-US | 1,234,567.891 | none | timezone ≠ IP timezone; language region ≠ IP country |
| Chrome headful, context locale + timezoneId from the exit | DE | Europe/Berlin | Europe/Berlin | de-DE | de-DE | 1.234.567,891 | none | none |
| Camoufox (geoip on, default) | JP | Asia/Tokyo | Asia/Tokyo | ja-JP, ja | ja-JP | 1,234,567.891 | exit IP | none |
| Camoufox, geoip: false | JP | UTC | Asia/Tokyo | en-US, en | en-US | 1,234,567.891 | none | timezone ≠ IP timezone; language region ≠ IP country |
| Camoufox, locale: "pt-BR" | JP | Asia/Tokyo | Asia/Tokyo | pt-BR, pt | pt-BR | 1.234.567,891 | exit IP | language region ≠ IP country |
| Chrome headful, no locale | JP | UTC | Asia/Tokyo | en-US, en | en-US | 1,234,567.891 | none | timezone ≠ IP timezone; language region ≠ IP country |
| Chrome headful, context locale + timezoneId from the exit | JP | Asia/Tokyo | Asia/Tokyo | ja-JP | ja-JP | 1,234,567.891 | none | none |
From the Node.js run on 2026-10-28. 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)
[
{
"setup": "Camoufox (geoip on, default)",
"country": "US",
"exit_country": "US",
"exit_timezone": "America/New_York",
"languages": "en-US, en",
"accept_language": "en-US",
"timezone": "America/New_York",
"intl_locale": "en-US",
"number": "1,234,567.891",
"webrtc": "exit IP",
"mismatches": []
},
{
"setup": "Camoufox, geoip: false",
"country": "US",
"exit_country": "US",
"exit_timezone": "America/New_York",
"languages": "en-US, en",
"accept_language": "en-US",
"timezone": "UTC",
"intl_locale": "en-US",
"number": "1,234,567.891",
"webrtc": "none",
"mismatches": [
"timezone ≠ IP timezone"
]
},
{
"setup": "Camoufox, locale: \"pt-BR\"",
"country": "US",
"exit_country": "US",
"exit_timezone": "America/New_York",
"languages": "pt-BR, pt",
"accept_language": "pt-BR",
"timezone": "America/New_York",
"intl_locale": "pt-BR",
"number": "1.234.567,891",
"webrtc": "exit IP",
"mismatches": [
"language region ≠ IP country"
]
},
{
"setup": "Chrome headful, no locale",
"country": "US",
"exit_country": "US",
"exit_timezone": "America/New_York",
"languages": "en-US, en",
"accept_language": "en-US",
"timezone": "UTC",
"intl_locale": "en-US",
"number": "1,234,567.891",
"webrtc": "none",
"mismatches": [
"timezone ≠ IP timezone"
]
},
{
"setup": "Chrome headful, context locale + timezoneId from the exit",
"country": "US",
"exit_country": "US",
"exit_timezone": "America/New_York",
"languages": "en-US",
"accept_language": "en-US",
"timezone": "America/New_York",
"intl_locale": "en-US",
"number": "1,234,567.891",
"webrtc": "none",
"mismatches": []
},
{
"setup": "Camoufox (geoip on, default)",
"country": "DE",
"exit_country": "DE",
"exit_timezone": "Europe/Berlin",
"languages": "de-DE, de",
"accept_language": "de-DE",
"timezone": "Europe/Berlin",
"intl_locale": "de-DE",
"number": "1.234.567,891",
"webrtc": "exit IP",
"mismatches": []
},
{
"setup": "Camoufox, geoip: false",
"country": "DE",
"exit_country": "DE",
"exit_timezone": "Europe/Berlin",
"languages": "en-US, en",
"accept_language": "en-US",
"timezone": "UTC",
"intl_locale": "en-US",
"number": "1,234,567.891",
"webrtc": "none",
"mismatches": [
"timezone ≠ IP timezone",
"language region ≠ IP country"
]
},
{
"setup": "Camoufox, locale: \"pt-BR\"",
"country": "DE",
"exit_country": "DE",
"exit_timezone": "Europe/Berlin",
"languages": "pt-BR, pt",
"accept_language": "pt-BR",
"timezone": "Europe/Berlin",
"intl_locale": "pt-BR",
"number": "1.234.567,891",
"webrtc": "exit IP",
"mismatches": [
"language region ≠ IP country"
]
},
{
"setup": "Chrome headful, no locale",
"country": "DE",
"exit_country": "DE",
"exit_timezone": "Europe/Berlin",
"languages": "en-US, en",
"accept_language": "en-US",
"timezone": "UTC",
"intl_locale": "en-US",
"number": "1,234,567.891",
"webrtc": "none",
"mismatches": [
"timezone ≠ IP timezone",
"language region ≠ IP country"
]
},
{
"setup": "Chrome headful, context locale + timezoneId from the exit",
"country": "DE",
"exit_country": "DE",
"exit_timezone": "Europe/Berlin",
"languages": "de-DE",
"accept_language": "de-DE",
"timezone": "Europe/Berlin",
"intl_locale": "de-DE",
"number": "1.234.567,891",
"webrtc": "none",
"mismatches": []
},
{
"setup": "Camoufox (geoip on, default)",
"country": "JP",
"exit_country": "JP",
"exit_timezone": "Asia/Tokyo",
"languages": "ja-JP, ja",
"accept_language": "ja-JP",
"timezone": "Asia/Tokyo",
"intl_locale": "ja-JP",
"number": "1,234,567.891",
"webrtc": "exit IP",
"mismatches": []
},
{
"setup": "Camoufox, geoip: false",
"country": "JP",
"exit_country": "JP",
"exit_timezone": "Asia/Tokyo",
"languages": "en-US, en",
"accept_language": "en-US",
"timezone": "UTC",
"intl_locale": "en-US",
"number": "1,234,567.891",
"webrtc": "none",
"mismatches": [
"timezone ≠ IP timezone",
"language region ≠ IP country"
]
},
{
"setup": "Camoufox, locale: \"pt-BR\"",
"country": "JP",
"exit_country": "JP",
"exit_timezone": "Asia/Tokyo",
"languages": "pt-BR, pt",
"accept_language": "pt-BR",
"timezone": "Asia/Tokyo",
"intl_locale": "pt-BR",
"number": "1.234.567,891",
"webrtc": "exit IP",
"mismatches": [
"language region ≠ IP country"
]
},
{
"setup": "Chrome headful, no locale",
"country": "JP",
"exit_country": "JP",
"exit_timezone": "Asia/Tokyo",
"languages": "en-US, en",
"accept_language": "en-US",
"timezone": "UTC",
"intl_locale": "en-US",
"number": "1,234,567.891",
"webrtc": "none",
"mismatches": [
"timezone ≠ IP timezone",
"language region ≠ IP country"
]
},
{
"setup": "Chrome headful, context locale + timezoneId from the exit",
"country": "JP",
"exit_country": "JP",
"exit_timezone": "Asia/Tokyo",
"languages": "ja-JP",
"accept_language": "ja-JP",
"timezone": "Asia/Tokyo",
"intl_locale": "ja-JP",
"number": "1,234,567.891",
"webrtc": "none",
"mismatches": []
}
]Takeaways
- Camoufox with
geoip(its default) passed every check in all 15 cells across five runs. On 2026-10-28 its timezone was the exit IP's (New York, Berlin, Tokyo), its language carried the IP's country as region, Accept-Language and Intl agreed withnavigator.language, and WebRTC showed only the exit IP. - But
geoipdraws the language per launch, weighted by who lives there: through the German exit only 2 of 5 launches picked German; the others goten-DE,es-DEandel-DE. The US exit gotes-USonce in five, and Japan got Japanese every time. Each is a plausible resident, but if the site expects the country's main language (a German shop, say), setlocaleyourself. Earlier, on 2026-10-22, a South African exit drew Northern Sotho. - Without a locale and timezone, Chrome tells on itself: headful Chrome reported
UTCanden-USthrough every exit in every run, so the timezone disagreed with the IP everywhere and the language region did in Germany and Japan. Setting both on the context from a first lookup of the exit (newContext({ locale, timezoneId })) made every check agree. geoip: falseleaves Camoufox onUTCanden-US, the same mismatches as plain Chrome; turn it off only when you set timezone and locale yourself. An explicitlocalewins overgeoipfor language only: withlocale: "pt-BR"Camoufox kept the exit's timezone but spoke Portuguese (region BR) in all three countries.- Small differences remain between setups that pass: Chrome's context locale gives a one-entry
navigator.languages(de-DE) where Camoufox lists two (de-DE, de), and WebRTC showed no public address on Chrome but the exit IP on Camoufox; neither contradicted the IP. Formats per locale: case 15; location from the exit: case 35; Camoufox personas: case 04 and case 49.