Evomi

Blog / Scraping Techniques

Timezone, Locale and Your Exit IP: The Cheapest Contradiction to Ship

The ScraperThe Scraper7 min read
Spider timezone and IP

You spent two weeks on the transport layer: curl_cffi with a real Chrome impersonation profile, HTTP/2 SETTINGS frames in the right order. Then you moved the hard pages to Playwright, pinned the User-Agent to Chrome 152, and routed the fleet through residential exits in Frankfurt.

The hard pages still behave like they know. So you open a console on one of your own sessions:

JavaScript
Intl.DateTimeFormat().resolvedOptions().timeZone
// "America/Los_Angeles"


Your packets leave Germany. Your browser says California, and so does Date.prototype.getTimezoneOffset()navigator.language says en-US, and every request carries Accept-Language: en-US,en;q=0.9.

None of that is a TLS problem, so no amount of TLS work fixes it: it is bookkeeping, the cheapest contradiction in the stack to ship by accident.

Six Declarations, Six Different Sources

A browser session declares where it is and what language it speaks in several places, each fed by a different subsystem, and nothing cross-checks them.

  • Intl.DateTimeFormat().resolvedOptions().timeZone returns an IANA zone identifier such as Europe/Berlin. It is a name, and the strongest signal in the set because nothing else can derive it. V8 reads it from ICU, which reads the OS zone; ECMA-402 requires resolvedOptions() to report that zone when the constructor was given none, so it is always there.
  • Date.prototype.getTimezoneOffset() returns a number of minutes, derived from that zone plus the tzdata rules for the instant the Date represents. It is not a constant.
  • Intl.DateTimeFormat().resolvedOptions().locale returns a BCP 47 tag, and it comes from the browser's UI language rather than the zone.
  • Intl.NumberFormat and Intl.Collator hang off that same resolved locale and leak it. (1234567.89).toLocaleString() gives 1,234,567.89 under en-US and 1.234.567,89 under de-DE. Collation differs too: ["z", "ä", "a"] sorts to a,ä,z under German rules and a,z,ä under Swedish.
  • navigator.language and navigator.languages are the browser's language preference list, a string and a read-only array, set by the user rather than by the OS zone. They are not independent: navigator.language is the first element of navigator.languages, so a pair that disagrees is a contradiction with no configuration behind it.
  • The Accept-Language header is generated from that same list, on every request including subresources, whether or not any JavaScript runs. Chrome sends the whole list with language-only fallbacks and q weights, en-US,en;q=0.9,de-DE;q=0.8,de;q=0.7, capped at ten entries. Safari, and Chrome in Incognito, send one tag, so a single tag from a normal Chrome session is the odd shape out.

Configured independently, they drift.

table of cont


That last row matters more than it looks, and the folklore is mostly wrong: the Debian-based python:*-slim images do ship tzdata. Two real traps survive. Alpine does not — tzdata is a separate apk add, and glibc's documented rule is that an uninterpretable TZ means UTC, silently. And Debian 13 moved the deprecated aliases into tzdata-legacy, so TZ=US/Pacific resolves on Debian 12 but not on 13.

When ICU cannot identify a host zone it does not guess: it returns the literal Etc/Unknown, visible from the page. V8 caches the ICU zone per isolate, so changing TZ in a running process does not move the browser's clock.

The Offset Is Not the Zone

The most common version of this bug is hardcoding an offset instead of a zone. getTimezoneOffset() uses an inverted sign, minutes west of UTC, so UTC-08:00 comes back as +480 and UTC+01:00 as -60 — but the real problem is that the number is a function of the date, not of the session:

C#
TZ=America/Los_Angeles
  new Date("2026-01-15T12:00:00Z").getTimezoneOffset()  ->  480   (PST, UTC-8)
  new Date("2026-07-15T12:00:00Z").getTimezoneOffset()  ->  420   (PDT, UTC-7)

TZ=Europe/Berlin
  new Date("2026-01-15T12:00:00Z").getTimezoneOffset()  ->  -60   (CET,  UTC+1)
  new Date("2026-07-15T12:00:00Z").getTimezoneOffset()  ->  -120  (CEST, UTC+2)


A constant offset is therefore wrong for part of the year, on a different schedule than the target's clock: the EU shifts on the last Sundays of March and October, both at 01:00 UTC, so every European zone moves at once, while the US shifts on the second Sunday in March and the first Sunday in November at 02:00 local time, zone by zone.

The calendars do not line up: in 2026, 8–29 March and 25 October to 1 November, three weeks and one week, leave the gap between Berlin and Los Angeles at eight hours rather than the usual nine.

Declare a zone name and the runtime computes all of this, twice a year, forever. Declare an offset and you have signed up to maintain tzdata yourself.

What the Geo Join Actually Looks Like

None of these signals is suspicious alone. What a target can do cheaply is join them.

City-level geolocation databases return a time zone alongside country and city: MaxMind's GeoIP2 City response carries location.time_zone, documented as the zone "specified by the IANA Time Zone Database". So the server has an IANA zone for your egress before your JavaScript runs, and comparing it to the zone the page reports is a string comparison.

The locale half is just as mechanical: arkenfox's TZP pulls the resolved locale out of Intl.CollatorIntl.NumberFormatIntl.DateTimeFormatIntl.PluralRulesIntl.Segmenter and friends and diffs them.

That gives a cheap decision table:

  1. Zone matches the IP's zone, language plausible for the region. Ordinary.
  2. Zone matches, language does not. Common and boring — expats, English-preferring users, corporate laptops.
  3. Zone and IP in different countries, but the reported offset matches the zone the page named. A traveller, a VPN, a corporate tunnel, interesting in aggregate, fine individually.
  4. Zone and IP disagree, and the offset does not even match the zone the page named. Nobody's laptop does this; it is a configuration artefact, and the one you control.

Case 4 is the free win: internal consistency is yours to guarantee whatever your exit region is. And if you already choose exit regions deliberately, case 3 collapses too.

None of which turns a no into a yes: if a target has told you not to crawl it, the answer is an official API, a licence, or identifying yourself. Coherence only stops you failing for a reason that was never about the data.

timezones

One Page, Every Layer

This probe reads all of it from one page load and prints the container's TZ beside it:

Python
import json
import os

from playwright.sync_api import sync_playwright

PROBE = """() => {
  const dtf = Intl.DateTimeFormat().resolvedOptions();
  const jan = new Date(Date.UTC(2026, 0, 15, 12));
  const jul = new Date(Date.UTC(2026, 6, 15, 12));
  return {
    intl_time_zone: dtf.timeZone,
    intl_locale: dtf.locale,
    intl_calendar: dtf.calendar,
    intl_numbering_system: dtf.numberingSystem,
    collator_locale: new Intl.Collator().resolvedOptions().locale,
    number_format_sample: (1234567.89).toLocaleString(),
    collation_sample: ["z", "ä", "a"].sort(new Intl.Collator().compare).join(","),
    offset_minutes_january: jan.getTimezoneOffset(),
    offset_minutes_july: jul.getTimezoneOffset(),
    navigator_language: navigator.language,
    navigator_languages: [...navigator.languages],
  };
}"""

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(locale="de-DE", timezone_id="Europe/Berlin")
    page = context.new_page()

    response = page.goto("https://example.com", wait_until="domcontentloaded")
    report = page.evaluate(PROBE)

    headers = response.request.all_headers()
    report["accept_language_header"] = headers.get("accept-language")
    report["process_tz_env"] = os.environ.get("TZ")

    print(json.dumps(report, indent=2, ensure_ascii=False))
    browser.close()


Two things to look for. Whether intl_localenavigator_language and accept_language_header agree, set only timezone_id and they will not. And whether process_tz_env matches intl_time_zone: Playwright overrides the zone inside the page, so a passing browser check says nothing about the rest of your process.

Pick a Region, Then Make Every Layer Agree

The constructive version is not "spoof a timezone": treat the exit region as the primary key and derive every other declaration from it.

Shell
REGIONS = {
    "de": {
        "timezone_id": "Europe/Berlin",
        "locale": "de-DE",
        "accept_language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
        "proxy": {
            "server": "http://proxy.example.net:1000",
            "username": "USER-country-DE",
            "password": "PASSWORD",
        },
    },
    "us_west": {
        "timezone_id": "America/Los_Angeles",
        "locale": "en-US",
        "accept_language": "en-US,en;q=0.9",
        "proxy": {
            "server": "http://proxy.example.net:1000",
            "username": "USER-country-US",
            "password": "PASSWORD",
        },
    },
}

UA = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
)


def open_context(browser, region_key):
    region = REGIONS[region_key]
    return browser.new_context(
        locale=region["locale"],
        timezone_id=region["timezone_id"],
        extra_http_headers={"Accept-Language": region["accept_language"]},
        proxy=region["proxy"],
        user_agent=UA,
    )


locale and timezone_id are separate options mapping to different browser subsystems, and setting one without the other is exactly how you produce the contradiction; set them from the same dictionary entry and the failure mode disappears. timezone_id takes the ICU identifiers Chromium ships; Playwright's docs point at metaZones.txt in Chromium's bundled ICU for the list.

You do not strictly need the header override: Playwright documents locale as affecting "navigator.language value, Accept-Language request header value as well as number and date formatting rules", so all three move together. Add extra_http_headers only when you want a specific weighted preference list, and then keep the syntax right: RFC 9110 §12.5.4 defines the header, §12.4.2 its q values, with at most three digits after the decimal point.

Then the container. If anything outside the browser touches a timestamp — logging, scheduling, an httpx fallback, the process needs the same zone, set before it starts:

YAML
FROM python:3.13-slim
# tzdata is already installed in the Debian-based images.
# On Alpine you need it: RUN apk add --no-cache tzdata
# On Debian 13, deprecated aliases such as US/Pacific also need tzdata-legacy.
ENV TZ=Europe/Berlin


Mistakes That Waste Time

  • Setting timezone_id and leaving locale alone. You have swapped one honest signal for two conflicting ones: a German zone, American English, same request.
  • Overriding getTimezoneOffset() in an init script. The offset is derived; patch it and the named zone and the numeric offset disagree, case 4 above, the combination with no real-world counterpart.
  • Hardcoding an offset in a config file. Correct until the next DST transition, then wrong for months.
  • Assuming the zone must match the IP. It does not; the thing to eliminate is internal incoherence.
  • Exporting TZ after the browser has started. V8 has already cached the zone from ICU, set it before launch.

Wrapping Up

Two changes cover most of it. Set locale and timezone_id from the same region record that picks the proxy, so all three move together or none do. And declare zones by IANA name, not by offset, so the runtime handles DST instead of your config file.

The rest is diagnosis. Run the probe against your own fleet: if intl_time_zonenavigator_languageaccept_language_header and your exit IP's country do not tell one story, that is a bug you can close this afternoon — cheaper than another week on the transport layer.