Cases / #74 · 2026-10-28 · Medium
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
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.