Playwright architecture, selector reliability, and advanced interaction patterns.

Cross-Browser Execution

Playwright ships three engines — Chromium, Firefox, and WebKit — behind one API, so a single suite can validate every browser your users run without duplicated code. The mechanism is configuration, not branching: you declare a project per engine, and the runner multiplies your spec files by your projects. The hard part is not running three engines; it is keeping a suite green across all of them when their timing, font stacks, and security models differ subtly. This guide covers declaring the matrix, allocating workers and shards across CI, staging engines behind project dependencies, isolating state per engine, and handling the genuine behavioral differences that survive auto-waiting. It builds on Playwright Setup & Core Architecture, which introduces projects as named execution profiles.

One spec set fanned across three engine projects A shared set of spec files feeds three engine projects — Chromium, Firefox, and WebKit — which run in parallel across sharded CI runners. Spec files one suite Chromium project Firefox project WebKit project Runner + shards
The runner fans one spec set across engine projects and shards; no test code is duplicated to add an engine.

What "three engines" actually means

Playwright does not automate the browsers installed on your machine. It downloads pinned builds into a local cache and drives each one over a different automation protocol: Chromium over the Chrome DevTools Protocol, Firefox over Juggler, and WebKit over a patched remote-inspector surface. Those patches add automation hooks that upstream does not expose; they do not rewrite layout or JavaScript semantics. The practical consequence is that the webkit project renders with the same WebCore and JavaScriptCore that ship inside Safari, but it is not Safari — there is no Safari UI, no extension surface, and no guarantee that the bundled revision matches whatever Apple shipped last week. The same qualifier applies to chromium, which is upstream Chromium rather than branded Chrome or Edge.

Because the binaries are pinned to the npm package, upgrading @playwright/test upgrades all three engines simultaneously. That single fact explains a large share of "the suite went red and nobody touched the tests" incidents: a dependency bump moved WebKit forward two upstream revisions, and a layout change surfaced an assertion that was always fragile. Record the resolved Playwright version and the engine revisions in your CI logs so the diff is visible when triage starts.

How each Playwright engine maps to a shipping browser A comparison matrix showing, for Chromium, Firefox, and WebKit, the automation protocol used, the bundled build, and the real browser each one stands in for. Project Driver protocol Bundled build Stands in for chromium DevTools Protocol Bundled Chromium Chrome and Edge firefox Juggler Patched Firefox Firefox desktop webkit WebKit inspector Patched WebKit Safari and iOS All three revisions are pinned to the installed Playwright version. Upgrading the package upgrades every engine at once.
Each project is a pinned, automation-patched build that approximates a shipping browser rather than being one.

Sorting failures into four buckets keeps triage honest. Most single-engine failures are test-authoring bugs that only one engine happens to expose — a race that Chromium's faster paint hides. The next largest group is environment drift: a missing font on the Linux CI image, no GPU, a different scrollbar width. Genuine product bugs that affect only one engine are real but comparatively rare, and actual Playwright or engine defects are rarer still. Reaching for the last explanation first wastes days.

Prerequisites

You need Node 18 or newer, @playwright/test installed as a dev dependency, and the engine binaries fetched with npx playwright install --with-deps — the --with-deps flag pulls the system libraries WebKit and Firefox need on a bare Linux image, and skipping it is the single most common cause of "browser closed unexpectedly" in a fresh container. You also need a suite that is already green on one engine. Turning on a three-engine matrix over an unstable single-engine suite triples the noise without producing a signal; stabilize first, using the patterns in Flaky Test Management, then widen the matrix. Finally, all shared options — baseURL, timeouts, trace settings — should already live in one config, as described in Playwright Config & Fixtures.

Declaring the engine matrix

A project is a named execution profile in playwright.config.ts. Declaring one per engine tells the runner to replay every spec under each, and the devices presets supply sensible viewport, user-agent, and capability defaults. fullyParallel: true lets files inside each project run concurrently, and CI-only retries absorb transient failures without masking real bugs in local runs.

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

export default defineConfig({
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,        // absorb transient flakiness on CI only
  timeout: 30_000,
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
  ],
});

Filter to a single engine with --project=firefox while reproducing an engine-specific failure, then drop the flag to confirm the fix holds everywhere. The flag repeats, so --project=firefox --project=webkit runs exactly the two engines you care about. Where these projects, devices, and shared use options are designed and centralized is the subject of the config guide linked above.

The multiplication is the cost. A 400-test suite becomes 1,200 executions the moment you add two engines, and if your pipeline was already at its time budget it will now blow through it. Most teams do not need full parity on every engine: the risk of an engine-specific regression concentrates in rendering-heavy flows, date and number formatting, media playback, and anything touching the clipboard or file system. A tag-filtered matrix encodes that judgement directly in the config, so Chromium keeps full coverage while the other two engines run a curated subset.

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

export default defineConfig({
  projects: [
    // Chromium is the reference engine and runs every spec.
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    // The other engines run only tests tagged @cross in their title or annotations.
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
      grep: /@cross/,
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
      grep: /@cross/,
      // Give WebKit more headroom: it is the slowest engine on Linux runners.
      timeout: 45_000,
    },
  ],
});

Per-project timeout, retries, and expect.timeout overrides matter more than they look. WebKit on a Linux CI runner is consistently the slowest of the three, and a global timeout tuned to Chromium produces a stream of WebKit timeouts that look like product failures. Raising the budget for one project is a targeted fix; raising it globally hides real Chromium slowdowns.

Worker allocation and parallelism budgets

Running three engines multiplies your test count, so worker allocation decides whether the matrix finishes quickly or starves the CPU. A common rule is workers: '50%' locally to leave room for the IDE and workers: '100%' in a dedicated CI container. The limiting resource is usually memory, not cores: each worker owns a browser process tree, and a WebKit or Chromium worker with a heavy application loaded can hold several hundred megabytes. On a 2-core, 7 GB hosted runner, four workers is often faster than eight because eight starts swapping.

Two flags make the budget explicit rather than accidental. maxFailures aborts the run once the failure count crosses a threshold, which stops a broken deploy from burning forty minutes of runner time proving the same point four hundred times. --repeat-each=10 runs a suspect spec repeatedly under one engine, which is the fastest way to separate a genuine race from a one-off.

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

export default defineConfig({
  // Leave headroom locally; saturate dedicated CI runners.
  workers: process.env.CI ? '100%' : '50%',
  retries: process.env.CI ? 2 : 0,
  // Stop a catastrophic run early instead of burning the whole runner budget.
  maxFailures: process.env.CI ? 25 : 0,
  // Refuse to merge a branch that still has a focused test in it.
  forbidOnly: !!process.env.CI,
  reporter: process.env.CI ? [['blob'], ['github']] : [['list']],
});

Workers and shards solve different halves of the same problem: shards split the test list across machines, workers split it across processes on one machine, and total concurrency is the product of the two. Four shards of six workers is twenty-four browsers running at once, which is fine if each runner has the memory for six and useless if it does not.

When tests genuinely share mutable state, mark the group test.describe.serial so they run in a deterministic order, but treat that as a last resort — the better fix is injecting independent data through fixtures so every test is order-independent, the approach detailed in Setting Up Global Fixtures for Parallel Tests. Native retries absorb genuinely transient failures, but a test that fails the same way every time is a real bug, not flakiness, and should not be retried into a false green.

State isolation per engine

Different engines persist cookies, storage, and tokens with their own internals, so any test that reuses a single shared session across engines invites leakage. The fix is the same everywhere: create an isolated context with browser.newContext(), optionally inject a pre-authenticated session via storageState, and normalize viewport, locale, and timezone so rendering is comparable engine to engine.

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

test('renders identically across engines', async ({ browser }) => {
  const context = await browser.newContext({
    viewport: { width: 1280, height: 720 },
    locale: 'en-US',
    timezoneId: 'UTC',          // pin time formatting so assertions are engine-neutral
  });
  const page = await context.newPage();

  await page.goto('/dashboard');
  await expect(page).toHaveTitle(/Dashboard/);

  // Auto-retrying locator assertions absorb per-engine timing differences.
  await page.locator('[data-testid="loader"]').waitFor({ state: 'hidden' });
  await expect(page.locator('[data-testid="content"]')).toBeVisible();
  await context.close();
});

Pinning timezoneId and locale is not cosmetic. Intl.DateTimeFormat resolves differently per engine when the host locale leaks through, so an assertion on a rendered date can pass on a developer's machine and fail on a UTC runner for reasons that have nothing to do with the browser. Pin both in the shared use block and the whole class of failure disappears.

The isolation contract itself is identical on every engine, so one auth.json produced under Chromium normally replays under Firefox and WebKit — it is only cookies plus per-origin local storage serialized as JSON. Two caveats bite in practice. First, a session bound to the user agent or to a device fingerprint will be rejected when replayed under a different engine, and you then need one storage state file per project. Second, storageState does not capture IndexedDB unless you opt in, so applications that keep their session token in IndexedDB appear logged out no matter which engine replays the file. The full isolation model is covered in Browser Contexts & Isolation, and the login-reuse patterns live in Authentication & Session State.

Staging the matrix with project dependencies

A project can declare dependencies, and the runner will not start it until every named project has finished successfully. That turns the flat matrix into a staged pipeline with two immediate wins. A setup project performs the expensive login once and writes a storage state file that every engine project consumes, instead of each engine logging in independently. And a fast Chromium smoke project acts as a gate: if the application does not even boot, the three full engine projects never start, and a broken build fails in ninety seconds instead of twenty minutes.

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

const STORAGE = 'playwright/.auth/user.json';

export default defineConfig({
  projects: [
    {
      // Runs first: logs in once and writes STORAGE for everyone else.
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
      use: { ...devices['Desktop Chrome'] },
    },
    {
      // Cheap gate: a handful of @smoke specs on the fastest engine.
      name: 'smoke',
      grep: /@smoke/,
      dependencies: ['setup'],
      use: { ...devices['Desktop Chrome'], storageState: STORAGE },
    },
    // The three full engine projects only start once the gate is green.
    {
      name: 'chromium',
      dependencies: ['smoke'],
      use: { ...devices['Desktop Chrome'], storageState: STORAGE },
    },
    {
      name: 'firefox',
      dependencies: ['smoke'],
      use: { ...devices['Desktop Firefox'], storageState: STORAGE },
    },
    {
      name: 'webkit',
      dependencies: ['smoke'],
      use: { ...devices['Desktop Safari'], storageState: STORAGE },
    },
  ],
});

Two behaviors are worth internalizing before you rely on this. Dependencies are transitive but not parallel-blocking beyond their own edges — chromium, firefox, and webkit all depend on smoke and therefore all start together the moment it passes. And running --project=webkit alone still executes setup and smoke first, because the runner resolves the dependency graph rather than the flag literally. That is usually what you want; when it is not, --no-deps skips the upstream projects and assumes the artifacts already exist.

Wall-clock timeline of a staged, sharded engine matrix A timeline showing a setup project finishing before a dependency gate, after which two Chromium shards, Firefox, and WebKit run concurrently to different finish times. dependencies gate setup (auth) chromium 1/2 chromium 2/2 firefox webkit 0m 4m 8m 12m critical path
Wall-clock time is set by the slowest lane, so the engine to shard first is whichever bar reaches furthest right.

The timeline makes the optimization obvious: total duration is the critical path, not the total work. Sharding Chromium twice while WebKit runs unsharded shortens nothing. Measure per-project duration from the report, then allocate shards to whichever project owns the rightmost bar.

Engine-specific behavior and explicit waits

The engines are close but not identical, and the differences that survive auto-waiting fall into predictable categories. WebKit applies stricter Content Security Policy defaults and can block inline scripts that Chromium tolerates. Firefox times synthetic input differently during rapid DOM mutation, so a click fired mid-reflow may land before the handler attaches. Scrollbar widths differ by platform, which shifts every viewport-relative coordinate by a handful of pixels. Font fallback differs the most of all: a Linux runner without the Apple system fonts renders WebKit text in a substitute face, so text metrics, line wrapping, and any screenshot comparison change with the base image rather than with the browser.

The mistake is branching test logic per engine; the fix is auto-retrying locators and explicit waits that resolve whenever the condition becomes true, regardless of which engine got there first. Every Playwright action runs the same actionability loop before it dispatches, and understanding that loop is what lets you stop writing sleeps.

The actionability loop that runs before every action A state machine in which a locator is resolved, checked for attachment, visibility, stability, and hit-target eligibility, then dispatched — with any failed check looping back to re-resolve until the timeout. any check fails — poll again Locator resolved Attached to DOM Visible and stable Enabled, hit target Action dispatched Assertion settles
Because the same loop runs on all three engines, a locator-based test absorbs engine timing differences without any per-engine branching.

Replace any deprecated page.waitFor() with locator.waitFor() for structural readiness and page.waitForURL() for navigation completion, and confirm visibility with expect(locator).toBeVisible() rather than reading the DOM at a fixed instant. Avoid waitForLoadState('networkidle') entirely on a cross-engine matrix: analytics beacons and long-polling connections settle at different moments per engine, so the wait either resolves too early or hangs. Wait on the element you are about to use — the reasoning is developed further in Handling Dynamic Content.

A handful of differences cannot be papered over by waiting, and for those the browserName fixture plus an annotated skip is the honest tool. Never branch inside the test body; branch at the declaration level so the report shows a skipped test with a reason instead of a silently different code path.

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

test('copies the share link to the clipboard', async ({ page, browserName }) => {
  // Clipboard read permissions are Chromium-only; record why, don't branch silently.
  test.skip(browserName !== 'chromium', 'clipboard-read permission is Chromium-only');

  await page.goto('/documents/42');
  await page.getByRole('button', { name: 'Copy link' }).click();

  const clipboard = await page.evaluate(() => navigator.clipboard.readText());
  expect(clipboard).toContain('/documents/42');
});

test('selects all text with the platform modifier', async ({ page }) => {
  await page.goto('/documents/42');
  const editor = page.getByRole('textbox', { name: 'Body' });
  await editor.click();

  // ControlOrMeta resolves to Meta on macOS/WebKit and Control elsewhere.
  await page.keyboard.press('ControlOrMeta+A');
  await expect(editor).toHaveJSProperty('selectionStart', 0);
});

Where a genuine engine quirk remains after that — a real rendering or API difference — the engine-by-engine breakdown and targeted fixes live in Running Chromium vs Firefox vs WebKit in Playwright.

Branded channels alongside bundled engines

Bundled Chromium is hermetic and reproducible, which is exactly what you want for the default matrix. It is also missing the proprietary media codecs and the enterprise policy surface that branded Chrome and Edge carry, so a small set of tests genuinely needs the shipping product rather than the open-source build. The channel option switches a project onto an installed branded browser without changing a line of test code.

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

export default defineConfig({
  projects: [
    // Hermetic default: pinned open-source Chromium.
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    {
      // Branded Chrome for tests that need proprietary codecs (H.264, AAC).
      name: 'chrome-branded',
      grep: /@media/,
      use: { ...devices['Desktop Chrome'], channel: 'chrome' },
    },
    {
      // Edge coverage for enterprise policy and IE-mode-adjacent regressions.
      name: 'edge',
      grep: /@enterprise/,
      use: { ...devices['Desktop Edge'], channel: 'msedge' },
    },
  ],
});

The tradeoff is hermeticity. A channel project drives whatever version is installed on the machine, so the runner image now carries a moving dependency and two developers can see different results on the same commit. Install the channel explicitly in CI with npx playwright install msedge, keep channel projects tag-scoped to the few tests that need them, and never make a branded channel your only coverage of an engine family.

Device emulation and mobile projects

Cross-browser coverage is not only desktop engines. The devices registry includes mobile profiles — iPhone 14, Pixel 7, and dozens more — that bundle a viewport, a mobile user agent, a device-scale factor, and touch support. Adding a mobile project to the matrix exercises the responsive layout and touch interactions that desktop projects never touch, and it costs only another entry in the projects array. WebKit-backed iPhone profiles are particularly valuable because they surface the same rendering engine real iOS Safari users run.

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

export default defineConfig({
  projects: [
    { name: 'desktop-chrome', use: { ...devices['Desktop Chrome'] } },
    // Mobile profiles add touch, a mobile UA, and a device-scaled viewport.
    { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
  ],
});

One constraint surprises people: Firefox does not support the isMobile and touch-emulation flags, so mobile profiles are Chromium- and WebKit-backed only. Attempting to spread a mobile preset over a Firefox project fails at launch rather than silently degrading. That is rarely a coverage gap in practice, because the mobile browsers with meaningful share are Chromium- or WebKit-based anyway. When a test only makes sense on one form factor, scope it with testMatch or a tag so the matrix does not run a desktop-only flow on a phone profile. The interactions that differ most between form factors — taps versus clicks, hover states, on-screen keyboards — connect directly to the patterns in Advanced Interactions & Test Assertions, and the finer points of viewport, locale, and timezone emulation are covered in Emulating Devices, Locales and Timezones.

Failure modes and reading a three-engine report

A three-engine matrix multiplies the failure surface, so the reporting layer has to make per-engine results legible. Capture a trace per engine so you can replay the exact failing timeline on the engine that failed, retain video on failure for rendering artifacts, and attach screenshots for visual diffs. A reporter that groups results by project turns a wall of failures into "WebKit fails this assertion, the others pass," which is the single most useful signal when triaging.

Four failure shapes recur, and each has a different first move. A test failing on exactly one engine every time is a behavioral difference — reproduce with --project=<engine> and read the trace. A test failing on all three engines is a product bug or a bad assertion, and the engine matrix is a distraction. A test failing on one engine intermittently is usually a timing assumption that the slower engine exposes; look for a missing web-first assertion rather than reaching for a retry. And a test failing only in CI is environment drift: compare the fonts, the container image, and the engine revision before touching the test.

Name your artifacts by project so a downloaded bundle is self-describing — testInfo.project.name is available inside fixtures and hooks, and attaching the engine version to the report turns a bug report into a reproducible one. Trace analysis itself is covered in Analyzing Test Failures with the Playwright Trace Viewer, and artifact configuration in Reporters & Test Artifacts. Where the difference is purely visual, a pixel baseline per project is the right instrument — see Visual Regression Testing, and expect to keep one baseline set per project-and-platform pair rather than one globally.

Sharding the matrix across CI runners

A three-engine matrix is the workload that benefits most from sharding. Splitting the spec set with --shard=1/4 spreads tests across four runners, and combining shards with project filters gives a grid of independent jobs that finish in a fraction of the serial time. Driving that from a script keeps the command consistent between local debugging and the pipeline.

import { execSync } from 'node:child_process';

// Read the engine list and shard coordinates from the CI environment.
const matrix = process.env.BROWSER_MATRIX?.split(',') ?? ['chromium', 'firefox', 'webkit'];
const index = process.env.SHARD_INDEX ?? '1';
const total = process.env.SHARD_TOTAL ?? '3';
const shard = process.env.CI ? `--shard=${index}/${total}` : '';
const projects = matrix.map((b) => `--project=${b}`).join(' ');

// The blob reporter emits a mergeable report fragment per shard.
const reporter = process.env.CI ? '--reporter=blob' : '--reporter=html';
execSync(`npx playwright test ${projects} ${shard} ${reporter}`, { stdio: 'inherit' });

Sharding is only useful if the report survives it. Each shard writes a blob report fragment; a final job downloads all fragments and runs npx playwright merge-reports --reporter=html ./all-blob-reports to produce one HTML report covering every engine and shard, with retries and flaky markers intact. Without that merge step you get N disconnected reports and no way to answer "did this test pass anywhere". Cache the browser binaries between jobs as well — a cold playwright install costs a minute or more per runner, multiplied by every shard in the grid. Wiring all of this into a pipeline is the focus of CI/CD Integration, with the workflow file itself in Running Playwright Tests in GitHub Actions with Sharding and the caching detail in Caching Playwright Browsers in CI.

Going deeper

One deep dive sits beneath this guide: Running Chromium vs Firefox vs WebKit in Playwright walks each engine's concrete divergences — CSP defaults, input timing, media support, and download handling — with the targeted fix for each. Pair it with Page Object Model Design if your matrix has grown large enough that engine-specific selectors are creeping into specs, and with Debugging & Test Observability for the failure-analysis loop that a wide matrix demands.

Frequently Asked Questions

Do I write separate tests for Chromium, Firefox, and WebKit?

No. Declare one project per engine in playwright.config.ts and the runner replays the same spec files across all three. You only write engine-specific code in the rare case of a genuine behavioral difference, and even then auto-retrying locators usually remove the need.

How many workers should I configure for a cross-browser matrix?

A practical default is workers set to 50% of cores locally to leave headroom for your editor, and 100% on a dedicated CI runner. The right number depends on memory per worker, so watch for out-of-memory crashes and scale back if the matrix saturates the machine.

Should I retry a test that fails on only one engine?

Only if the failure is genuinely transient. A test that fails the same way every run on one engine is a real behavioral difference, not flakiness, and retrying it just hides the bug. Reproduce it with the --project flag for that engine, fix the timing or the assertion, then confirm the fix across all engines.

Is Playwright's WebKit the same thing as Safari?

Not quite. Playwright bundles an automation-patched WebKit that uses the same WebCore rendering and JavaScriptCore engines Safari is built on, so layout and JavaScript semantics match closely, but there is no Safari user interface, no extension support, and the bundled revision tracks the Playwright release rather than Apple's shipping schedule. It catches engine-level regressions well; it will not catch a Safari-only UI behavior.

Can I run the full matrix on every pull request?

You can, but most teams should not. Run the reference engine on every push, and gate the full three-engine matrix behind a merge queue, a nightly schedule, or a label. Tag-filtering the secondary engines with grep keeps pull-request feedback fast while still covering the flows where engine differences actually concentrate.

Why does my suite pass locally on macOS but fail on a Linux runner?

Almost always fonts or system libraries rather than the browser. Linux images lack the Apple system fonts, so text metrics and wrapping change, and a missing shared library makes Firefox or WebKit exit at launch. Install with the --with-deps flag, pin a container image, and keep screenshot baselines per platform rather than sharing one set across operating systems.

Back to overview