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 and security models differ subtly. This guide covers declaring the matrix, allocating workers and shards across CI, 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.
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: {
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. Where these projects, devices, and shared use options are designed and centralized is the subject of Playwright Config & Fixtures.
Worker allocation and flaky-test isolation
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. Native retries absorb genuinely transient failures — a momentarily slow network — 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.
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,
});
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. Distinguishing true flakiness from a deterministic engine bug is exactly the workflow detailed across Cross-Browser Execution and the wider Debugging & Test Observability guide.
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 and locale 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',
});
const page = await context.newPage();
await page.goto('https://example.com/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();
});
Because the isolation contract is identical on every engine, one auth.json works across all three — the full isolation model is covered in Browser Contexts & Isolation.
Engine-specific behavior and explicit waits
The engines are close but not identical. 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. Chromium optimizes shadow DOM traversal aggressively. 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.
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. 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.
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'] } },
],
});
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.
Reading and reporting cross-browser failures
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 custom or built-in 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. Always record the exact engine revision and environment so a failure is reproducible — a bug that only repros on a specific WebKit build is invisible without that metadata. The full failure-analysis loop lives in the Debugging & Test Observability guide.
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 index from the CI environment.
const matrix = process.env.BROWSER_MATRIX?.split(',') ?? ['chromium', 'firefox', 'webkit'];
const shard = process.env.CI ? '--shard=1/3' : '';
const projects = matrix.map((b) => `--project=${b}`).join(' ');
const command = `npx playwright test ${projects} ${shard} --reporter=html`;
execSync(command, { stdio: 'inherit' }); // fails the job on non-zero exit
Wiring this into a pipeline — caching the browser binaries, fanning shards across runners, and merging the reports — is the focus of CI/CD Integration, which extends the matrix above into a full GitHub Actions workflow.
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 --project=