Playwright architecture, selector reliability, and advanced interaction patterns.

Emulating Devices, Locales and Timezones

A checkout page that renders 1.234,50 € in Berlin and $1,234.50 in Chicago is running the same JavaScript against two different Intl configurations, and a responsive header that collapses at 390px is responding to a viewport your CI machine never has. Playwright reproduces all of it through browser.newContext() options — viewport, userAgent, isMobile, hasTouch, deviceScaleFactor, locale, timezoneId, geolocation — plus a registry of ready-made hardware profiles exported as devices. The trap is that every one of these settings is frozen at the moment the context is created, so the natural instinct to "switch to German halfway through the test" has no API behind it. This page shows how to compose device, locale and timezone emulation correctly, wire it into config projects, and diagnose the four failures that account for most emulation bug reports.

Root cause: emulation is a property of the context, not the page

A BrowserContext is the boundary at which Playwright configures the renderer — it hands the engine a locale override, a timezone override, a user-agent string and a viewport once, at construction time, and the page inherits them. That is why Browser Contexts & Isolation is the right mental model here: a context is a device, and emulating a second device means opening a second context rather than mutating the first. There is page.setViewportSize() and context.setGeolocation(), but there is deliberately no setLocale() or setTimezone(), because changing either after scripts have already read navigator.language or constructed an Intl.DateTimeFormat would leave the page in a state no real browser can reach.

The second half of the root cause is where the assertion runs. Your test file executes in Node, on the CI machine, in whatever zone TZ happens to be — usually UTC in a container and local time on a laptop. The page executes in the emulated zone. Any expected value you compute with new Date() in the test body is therefore computed against the wrong clock, and the test passes locally and fails in CI. Expectations about formatted dates and numbers have to be derived inside the page, or hard-coded as literal strings.

Which context option drives which browser surface Four newContext options map to the browser surface they emulate and to the API a page script can observe them through. Context option Emulated surface Read by the page viewport CSS media queries matchMedia width isMobile + hasTouch meta viewport, touch ontouchstart locale Accept-Language navigator.language timezoneId ICU zone database Intl resolvedOptions every row is fixed when newContext() returns
Each emulation option targets a different browser subsystem, and the right-hand column names the API you assert against to prove it took effect.

Minimal reproducible example

The test below opens one context that emulates an iPhone 13 in Germany, then proves each of the three dimensions independently: viewport through a media query, locale through the formatted total, and timezone through the ICU zone the page resolves.

import { test, expect, devices } from '@playwright/test';

test('renders a German total and a Berlin clock on a phone viewport', async ({ browser }) => {
  const context = await browser.newContext({
    // Spread first: the preset supplies viewport, userAgent, deviceScaleFactor,
    // isMobile, hasTouch and defaultBrowserType in one object.
    ...devices['iPhone 13'],
    locale: 'de-DE',                 // sets navigator.language AND the Accept-Language header
    timezoneId: 'Europe/Berlin',     // the ICU zone Date and Intl use inside the page
    geolocation: { latitude: 52.52, longitude: 13.405 },
    permissions: ['geolocation'],    // without this the page gets GeolocationPositionError code 1
  });
  const page = await context.newPage();
  await page.goto('/checkout');

  // Viewport: 390px wide, so the mobile breakpoint must be active.
  const isNarrow = await page.evaluate(() => matchMedia('(max-width: 480px)').matches);
  expect(isNarrow).toBe(true);

  // Timezone: resolve it INSIDE the page. Node still runs in the CI machine's zone.
  const zone = await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone);
  expect(zone).toBe('Europe/Berlin');

  // Locale: de-DE uses a dot for thousands, a comma for decimals, symbol last.
  await expect(page.getByTestId('order-total')).toHaveText('1.234,50 €');

  await context.close();          // emulation dies with the context; nothing leaks to the next test
});

The devices registry is worth reading once rather than trusting blindly. Each entry is a small literal holding userAgent, viewport, deviceScaleFactor, isMobile, hasTouch and defaultBrowserType — nothing more. It does not model network throttling, CPU limits, platform-specific fonts, or the software keyboard, so a preset tells you how your CSS responds to a phone-sized screen with touch input, not how the site feels on the hardware. Treat it as a layout and capability fixture, and keep genuine device-lab coverage for questions it cannot answer.

Two things in that snippet fail silently if you get them wrong. Spreading a preset name that does not exist — ...devices['iPhone 13 Pro Maxx'] — spreads undefined, which is legal JavaScript and a complete no-op, so the test runs at the default 1280×720 desktop viewport and reports no error at all. And an invalid zone is the opposite: timezoneId: 'Europe/Berlim' throws immediately with Invalid timezone ID: Europe/Berlim, which is the friendlier of the two outcomes.

Step-by-step fix

  1. Create emulation at the context, never mid-test. Pass every option to browser.newContext() or declare it in use. If a scenario needs a second device, open a second context with browser.newContext() and run the two side by side, exactly as described in How to Configure Multiple Browser Contexts in Playwright. Reaching for a mutation API mid-test is the signal that the test is really two tests.
  2. Spread the device preset first, then override. devices['Pixel 7'] is a plain object; spread it at the top of the options literal so your explicit locale, timezoneId or viewport keys win. Reversing the order silently reverts your overrides to the preset's values, which is a bug that reads as correct code.
  3. Match the engine to defaultBrowserType. Every preset carries a defaultBrowserType'webkit' for iPhone and iPad, 'chromium' for Pixel and Galaxy. Running the iPhone user agent on Chromium gives you a Blink layout wearing a Safari label, which is worse than no emulation because it produces confident, wrong results. Pin the project's browserName to the preset's engine and read Running Chromium vs Firefox vs WebKit in Playwright for the wider engine differences.
  4. Set locale and timezoneId together. A German locale on a UTC clock renders 14:30 where the user would see 16:30, and half of your date assertions will be right for the wrong reason. Treat the pair as one setting representing one user.
  5. Grant permissions explicitly. geolocation in the context options only supplies coordinates; the page still needs permissions: ['geolocation'] or a later context.grantPermissions(['geolocation']), otherwise navigator.geolocation.getCurrentPosition() invokes its error callback with code 1. The same applies to notifications, clipboard-read and camera.
  6. Derive expectations inside the page. Use page.evaluate() to read Intl.DateTimeFormat().resolvedOptions().timeZone or to format a known instant, then compare. Never build the expected string with a Node-side new Date(); the runner's clock is not the emulated clock.
  7. Pin the instant with page.clock. Install a fixed time before navigation so a timezone-formatted timestamp is deterministic forever, rather than only until the next daylight-saving transition. page.clock.install() must run before page.goto() to intercept the page's first Date construction.
  8. Promote the combination to a config project. Move the working options into a named project in playwright.config.ts so the whole suite can run under --project=mobile-de, and so the settings compose with fixtures as described in Playwright Config & Fixtures.

The config form of steps 1 through 8 collapses to a few lines, and this is where emulation belongs once it stops being an experiment:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'mobile-de',
      use: {
        ...devices['iPhone 13'],        // brings browserName: 'webkit' via defaultBrowserType
        locale: 'de-DE',
        timezoneId: 'Europe/Berlin',
      },
    },
    {
      name: 'desktop-jp',
      use: {
        ...devices['Desktop Chrome'],
        locale: 'ja-JP',
        timezoneId: 'Asia/Tokyo',
        colorScheme: 'dark',            // emulates prefers-color-scheme for the whole project
      },
    },
  ],
});
When emulation options can still be changed A context lifecycle timeline showing options fixed at newContext, with only viewport and geolocation mutable afterwards. options evaluated once newContext(opts) newPage() goto('/checkout') expect(...) clock.install() belongs here locale, timezoneId, userAgent and isMobile are fixed here only viewport and geolocation can still be changed
The mutable window closes as soon as the context exists, which is why a second device means a second context rather than a setter call.

Troubleshooting variants

options.isMobile is not supported in Firefox

Firefox rejects isMobile, and because every phone preset sets it, spreading devices['Pixel 7'] into a Firefox project fails at context creation rather than at the assertion. There are two honest fixes. Either drop the mobile presets from the Firefox project and emulate only what Firefox supports — viewport, userAgent, locale, timezoneId, deviceScaleFactor — or exclude Firefox from mobile coverage entirely and let it carry desktop breakpoints. Destructuring the offending key out of the preset (const { isMobile, ...rest } = devices['Pixel 7'];) works, but understand what you have bought: a narrow viewport without touch-event emulation or meta-viewport handling, which is a phone-shaped desktop rather than a phone. Decide deliberately, and document the choice next to your cross-browser project matrix.

The locale is set but the page still renders US formatting

Three causes, in order of frequency. First, the application ignores navigator.language and reads a user preference from an API response or a cookie — emulation cannot override an explicit server-side choice, so you also need to seed that cookie on the context or stub the preferences endpoint. Second, formatting happens on the server during SSR, where your context's Accept-Language header is the only signal that crosses the wire; confirm the request actually carried de-DE before blaming the browser. Third, an extraHTTPHeaders: { 'Accept-Language': 'en-US' } left over from an earlier debugging session overrides the header Playwright derives from locale, and the mismatch between navigator.language and the header produces exactly this split behaviour. Grep the config for Accept-Language before anything else.

Timezone-dependent assertions break twice a year

A test that hard-codes 16:30 for a UTC instant in Europe/Berlin is asserting a UTC+2 offset, which holds from late March to late October and fails on the Monday after the clocks change. The same class of failure hits America/Sao_Paulo and any zone whose government revises its rules, since the ICU database ships new offsets with browser updates. Remove the ambient dependency: install a fixed clock, so the instant under test never moves.

import { test, expect } from '@playwright/test';

test.use({ timezoneId: 'Europe/Berlin', locale: 'de-DE' });

test('order timestamp is stable across daylight-saving transitions', async ({ page }) => {
  // Must run before goto(): it patches Date, setTimeout and friends on the fresh document.
  await page.clock.install({ time: new Date('2026-01-15T14:30:00Z') });
  await page.goto('/orders/1042');

  // 14:30 UTC in Berlin in January is 15:30 — a CET (UTC+1) winter offset, pinned forever.
  await expect(page.getByTestId('placed-at')).toHaveText('15:30');
});

Note the test.use() call at file scope. It applies to every test in the file and causes Playwright to build a new context with those options; calling it inside a test() body raises test.use() can only be called in a test file or a test.describe() group, which is the runner enforcing the same lifecycle rule the diagram above describes.

Choosing an emulation strategy A decision tree branching from what you are emulating into device presets, locale settings, or clock pinning. What are you emulating? Mobile layout + touch Regional formatting Time-sensitive UI use a devices[] preset match browserName set locale per project assert on Intl output set timezoneId pin page.clock time
Start from the behaviour under test rather than the option list, and the three branches rarely overlap in the same assertion.

Verification

Prove each dimension separately, because a single end-to-end assertion cannot tell you which of the three settings failed. For the viewport, evaluate matchMedia() against the breakpoint your CSS actually uses rather than eyeballing a screenshot — a numeric width comparison is immune to font substitution. For the locale, read navigator.language in the page and inspect the outbound request with page.on('request', r => r.headers()['accept-language']); agreement between the two rules out a stray extraHTTPHeaders entry. For the timezone, compare Intl.DateTimeFormat().resolvedOptions().timeZone to the exact string you configured.

Then verify the composition. Run the same spec file under two projects (npx playwright test --project=mobile-de --project=desktop-jp) and confirm the assertions differ in the way you expect; two projects that pass identical hard-coded strings are a strong hint that neither locale is being applied. When something still disagrees, open the run in the Playwright Trace Viewer: the network tab shows the real Accept-Language header per request, and the DOM snapshot renders at the emulated viewport, so you can see the breakpoint that was live at failure time. Pair that with failure screenshots and video, remembering that deviceScaleFactor: 3 produces images three times the CSS pixel dimensions.

One further check catches leakage. Emulation is scoped to a context, so a test that opens its own context and forgets to close it can leave an emulated page alive while the next test believes it is on a desktop. Assert the viewport width at the start of any spec that mixes emulated and default contexts, and close every manually created context in a finally block or a fixture teardown so the guarantee holds under failure as well as success.

Finally, guard against regression. Emulated projects are a classic source of environment-dependent flake, so run the new project with --repeat-each=5 before merging and apply the diagnosis loop from Detecting and Fixing Flaky Playwright Tests. A locale or timezone test that fails one run in five is almost always missing a pinned clock or asserting on a Node-computed date.

Frequently Asked Questions

Can I change the locale or timezone in the middle of a test?

No — both are applied when the context is constructed and there is no setter for either. To compare two locales in one spec, create two contexts from the same browser fixture and drive them in parallel, or split the coverage into two files and declare test.use({ locale }) at the top of each. The one exception in this family of options is viewport, which page.setViewportSize() can change at any time, and geolocation, which context.setGeolocation() updates live.

Does emulating a mobile device actually test mobile rendering?

Only if the engine matches. A device preset supplies a viewport, a user agent, a pixel ratio and touch flags, but it cannot turn Chromium into WebKit, so devices['iPhone 13'] running under Chromium exercises Blink's layout with Safari's user-agent string. Honour the preset's defaultBrowserType and your mobile Safari project will run on WebKit, which shares an engine lineage with real iOS Safari. Nothing in Playwright emulates iOS-specific chrome such as the dynamic address bar or momentum scrolling.

Why does my screenshot baseline break when I add device emulation?

deviceScaleFactor multiplies the pixel dimensions of every capture, so a 390×844 CSS viewport at a factor of 3 writes a 1170×2532 PNG. Baselines recorded before you added the preset were captured at a factor of 1 and will never match. Delete and regenerate the affected snapshots with --update-snapshots after the emulation lands, and keep one baseline directory per project name so desktop and mobile captures never collide.

Does locale affect the Node side of my test?

It does not. The locale and timezoneId options configure the browser process only; your test file keeps using the machine's Intl defaults and the TZ environment variable. That asymmetry is why any expected date or currency string should either be a literal you wrote by hand or a value computed with page.evaluate(). If you genuinely need matching behaviour in Node, set TZ on the process before the runner starts.

Back to overview