Running Chromium vs Firefox vs WebKit in Playwright
Playwright ships its own builds of three rendering engines — Chromium (Blink), Firefox (Gecko), and WebKit (the engine behind Safari) — and drives all of them through one unified API. The promise is that a test written once runs on every engine, but the reality is that Blink, Gecko, and WebKit differ in font loading, cookie partitioning, content-security-policy enforcement, and layout timing. This page shows how to declare all three engines in a single config, run one engine or the full matrix on demand, and write tests that branch on browserName only where an engine genuinely behaves differently. It is the hands-on companion to Cross-Browser Execution, which sits under Playwright Setup & Core Architecture.
Root cause: cross-engine failures are environment divergence, not engine defects
When a test passes on Chromium but fails on WebKit, the instinct is to blame the engine. Almost always the real cause is that the test relies on behavior that only one engine happens to provide for free. WebKit does not always fire font-load events at the same point Blink does, so a screenshot taken too early renders with a fallback face. Firefox's Total Cookie Protection partitions cookies per top-level site, so a session restored from a cross-site cookie silently fails to authenticate. WebKit enforces content-security-policy more strictly, so an injected script that Chromium tolerates is blocked. The fix is never to special-case the engine everywhere; it is to declare every engine in the config, let the runner fan the spec out, and add a narrow browserName branch only at the exact point where the engines genuinely differ.
That framing matters because the alternative — three parallel copies of the same spec, one per engine — decays fast. Each copy drifts as the product changes, and the copies that fail least often stop being maintained at all. A single spec with two or three guarded lines keeps the intent in one place and makes the divergence itself reviewable in a pull request.
Where the three engines actually diverge
The list of genuine behavioural differences is short, and knowing it saves hours of guessing. Font loading is the most common: Blink and Gecko resolve web fonts before paint in most cases, while WebKit can paint a fallback face first and swap it in later, which is enough to break a pixel comparison or a text-metric assertion. Cookie policy is the second: Firefox partitions storage by top-level site, and WebKit's Intelligent Tracking Prevention drops many third-party cookies outright, so any flow that leans on a cookie set from another origin behaves differently on each engine. Content-security-policy enforcement is the third: WebKit rejects injected inline scripts that Chromium permits, which surfaces whenever a test or a stub injects code into the page.
Two smaller differences matter in continuous integration. Chromium runs under a purpose-built headless shell, while Firefox and WebKit use their normal binaries in a headless mode, so memory profiles differ and WebKit is the engine most likely to be killed by a low container memory limit. And each engine speaks a different debugging protocol underneath — CDP for Chromium, Juggler for Firefox, and WebKit's own remote inspector — which is why a raw CDP session is available only on Chromium.
Everything not in that matrix is shared. The locator engine, auto-waiting, web-first assertions, page.route() interception, tracing, video capture, and Browser Contexts & Isolation all behave the same on every engine, because Playwright implements them above the protocol layer rather than delegating to the browser. When a failure is not on the short list, treat it as an application bug or a synchronization gap rather than an engine quirk.
Minimal reproducible example
First, declare the three engines as projects so one command can cover all of them. The devices presets supply the right browserName, viewport, and user agent for each engine.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // run files across the matrix concurrently
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
The spec below runs unchanged on all three engines, with a single guarded branch for WebKit's font timing. The browserName fixture tells the test which engine it is currently running under.
import { test, expect } from '@playwright/test';
test('header renders consistently across engines', async ({ page, browserName }) => {
await page.goto('https://example.com/dashboard');
// Auto-waiting assertion works identically on every engine.
const heading = page.getByRole('heading', { level: 1 });
await expect(heading).toBeVisible();
// WebKit may not have finished loading web fonts when the heading appears;
// wait for fonts explicitly ONLY there so the other engines are not slowed.
if (browserName === 'webkit') {
await page.evaluate(() => document.fonts.ready);
}
await expect(heading).toContainText('Dashboard');
});
Step-by-step fix
- Declare every engine as a project. Add
chromium,firefox, andwebkitentries to theprojectsarray inplaywright.config.ts, spreading the matchingdevicespreset into eachuseblock so engine, viewport, and user agent stay consistent. Keeping the presets rather than hand-writing a viewport means an engine upgrade cannot silently change the geometry your snapshots were recorded at. - Install the engine binaries. Run
npx playwright installso Playwright's version-pinned builds of all three engines are present; the bundled Chromium is separate from a system-installed Chrome. On Linux add--with-depsso the shared libraries WebKit and Firefox need are installed alongside the browsers. - Run one engine or the whole matrix. Use
npx playwright test --project=webkitto isolate a single engine while debugging, and omit--projectto run the full matrix in CI. The flag is repeatable, so--project=firefox --project=webkitcovers the two non-Blink engines when you are chasing a divergence. - Branch only where engines truly differ. Read the
browserNamefixture and add a narrow conditional — such as awaitingdocument.fonts.readyon WebKit — instead of writing three separate copies of the test. Put a one-line comment on every branch naming the behaviour it compensates for, so the guard can be deleted when the engine catches up. - Inject session state explicitly for Firefox. Because Firefox partitions cookies, restore authentication with
context.addCookies()or astorageStatefile rather than relying on cross-site cookie persistence. Generating that state once per worker, as described in Setting Up Global Fixtures for Parallel Tests, also removes a login round trip from every engine. - Cache binaries in CI. Cache the Playwright browser download path between runs to avoid re-downloading three engines on every job, and align worker count with available CPU to prevent out-of-memory kills on WebKit. Sharding the matrix across runners, covered in Running Playwright Tests in GitHub Actions with Sharding, keeps wall-clock time flat as the suite grows.
Deciding when a browserName branch is justified
Most single-engine failures are not engine differences at all. Before adding a conditional, run the failing spec on the suspect engine with --repeat-each=10; a failure that appears intermittently is a synchronization gap, and a failure that appears every single time is a candidate for a real divergence. Only the second kind deserves a browserName guard. The decision below keeps that discipline explicit and stops the config from accumulating engine-specific code that nobody can later justify removing.
Keep a comment beside every guard naming the engine behaviour and the date you added it. Engine differences close over time — WebKit's font timing and Firefox's cookie handling both shift between releases — and an undated guard tends to survive long after the reason for it has gone. Reviewing those comments when you bump the Playwright version is the cheapest way to keep the matrix honest.
Troubleshooting variants
WebKit blocks an injected script or fails on a self-signed certificate
WebKit enforces content-security-policy more aggressively than Blink, so scripts that Chromium tolerates get rejected. Strip or rewrite the Content-Security-Policy header with page.route() for the page under test, and set ignoreHTTPSErrors: true in the context when a staging host serves a self-signed certificate. Both of these build directly on Network Interception Basics.
Firefox loses the logged-in session between navigations
Firefox's Total Cookie Protection partitions cookies by top-level site, which breaks any flow that depends on a cross-site cookie. Stop relying on the browser to carry the cookie across origins and instead seed the session deterministically with context.addCookies() or a saved storageState, ideally created once per worker rather than per test.
One engine is flaky in CI but green locally
Flakiness that appears only on one engine in CI is usually a synchronization gap, not an engine fault. Replace any fixed delay with an auto-waiting assertion, capture the failing run with --trace on, and inspect it engine by engine in the Playwright Trace Viewer. Reserve retries for genuinely transient network errors rather than papering over a missing wait, and follow the triage sequence in Detecting and Fixing Flaky Playwright Tests.
The WebKit project is killed part-way through a CI run
An exit without a test failure, often reported as a browser crash or a closed target, usually means the container ran out of memory. WebKit and Firefox launch full binaries rather than a slim headless shell, so three engines running at full worker count can exceed a default two-gigabyte runner. Lower workers for the matrix job, raise the container's shared-memory size, and pin the image as described in Dockerizing Playwright for Headless CI.
Verification
Prove cross-engine stability four ways. First, run the matrix repeatedly (npx playwright test --repeat-each=5) and confirm every project stays green; a guard that only works on the first pass shows up immediately. Second, run each project in isolation with --project=firefox and --project=webkit so an engine-specific failure is not hidden by a passing Chromium run. Third, open a trace from a WebKit run and confirm the font-ready branch fired and the heading rendered with the intended typeface, not a fallback. Fourth, temporarily delete each browserName guard and re-run only the engine it protects — if the spec still passes, the guard is stale and should be removed rather than carried forward. Wiring this matrix into continuous integration is covered by CI/CD Integration, and the per-project artifacts that make a failed matrix run readable are covered in Capturing Screenshots and Video on Test Failure.
Frequently Asked Questions
Does Playwright use my installed Chrome, Firefox, and Safari?
By default no. Playwright downloads its own version-pinned builds of Chromium, Firefox, and WebKit so results are reproducible across machines. You can target a system browser with the channel option, such as channel: 'chrome', but that requires the browser to be installed separately.
How do I run a single engine while debugging?
Pass the project name to the CLI, for example npx playwright test --project=webkit. Omitting the --project flag runs every project declared in the config, which is what you want in CI for full matrix coverage.
Why does the same test pass on Chromium but fail on WebKit?
It usually relies on behavior only one engine provides for free, such as font load timing, cookie partitioning, or relaxed content-security-policy. Add a narrow browserName branch at the exact point of divergence rather than rewriting the whole test.
Is Playwright's WebKit the same as the Safari my users run?
It is the same open-source engine, built from a recent WebKit revision, but it is not Safari. Apple's shipping browser adds proprietary layers on top of the engine, so Playwright's WebKit is an accurate proxy for layout, CSS, and JavaScript behavior while remaining unsuitable for testing Safari-only UI features such as its extension model.
Should every spec run on all three engines?
No. Run the full matrix on the flows that carry real revenue or compliance risk, and keep the rest on Chromium alone. Tag the cross-engine subset and select it with a grep filter so the matrix job stays fast enough that nobody is tempted to disable it.