[Cases](https://cdpfleet.com/docs/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.

[Camoufox](https://cdpfleet.com/docs/browsers/camoufox) [Google Chrome](https://cdpfleet.com/docs/browsers/chrome)

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](https://cdpfleet.com/docs/fingerprinting) 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

1.  For each country, open one sticky residential exit and run the five setups one after another on it.
2.  Open the exit-lookup page (ip-api.com's JSON, which carries the exit's country, timezone and IP).
3.  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.
4.  List each check that disagrees with the exit.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/74-timezone-locale-ip-consistency), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)

 

```node
// npm install playwright@1.60.0
// 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));
```

```python
# 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))
```

```java
// 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));
  }
}
```

```csharp
// 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
// go get github.com/playwright-community/playwright-go@v0.6000.0
// 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 with `navigator.language`, and WebRTC showed only the exit IP.
-   **But `geoip` draws the language per launch, weighted by who lives there:** through the German exit only 2 of 5 launches picked German; the others got `en-DE`, `es-DE` and `el-DE`. The US exit got `es-US` once 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), set `locale` yourself. Earlier, on 2026-10-22, a South African exit drew Northern Sotho.
-   **Without a locale and timezone, Chrome tells on itself:** headful Chrome reported `UTC` and `en-US` through 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: false` leaves Camoufox on `UTC` and `en-US`,** the same mismatches as plain Chrome; turn it off only when you set timezone and locale yourself. **An explicit `locale` wins over `geoip` for language only:** with `locale: "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](https://cdpfleet.com/docs/cases/three-countries); location from the exit: [case 35](https://cdpfleet.com/docs/cases/geolocation-and-permissions); Camoufox personas: [case 04](https://cdpfleet.com/docs/cases/camoufox-persona) and [case 49](https://cdpfleet.com/docs/cases/camoufox-same-device).

---
Source: https://cdpfleet.com/docs/cases/timezone-locale-ip-consistency · Markdown: https://cdpfleet.com/docs/cases/timezone-locale-ip-consistency.md · All docs for AI tools: https://cdpfleet.com/llms.txt
