Playwright architecture, selector reliability, and advanced interaction patterns.

Playwright Setup & Core Architecture

Every reliable Playwright suite rests on the same three-object model: a Browser launches an engine process, a BrowserContext carves out an isolated profile inside it, and a Page drives a single tab. Understanding how those objects nest — and how the test runner, config file, and fixtures wire them together — is what separates a suite that passes once from one that passes ten thousand times in parallel on CI. This guide maps the whole stack: installing browsers, declaring projects in playwright.config.ts, isolating state with contexts, minting and reusing authenticated sessions, running the same specs across Chromium, Firefox, and WebKit, structuring code with page objects, tuning parallelism, and shipping it all through a pipeline. Each technique area links to a focused guide where you can go deeper.

Playwright object and tooling architecture A layered diagram showing the test runner reading config and fixtures, which launch a Browser containing isolated BrowserContexts, each owning Page objects, all feeding a CI pipeline. Test runner playwright.config.ts fixtures + projects Browser (engine process) BrowserContext A Page Page BrowserContext B Page CI/CD pipeline (sharded runners)
The runner reads config and fixtures to launch a Browser; isolated contexts each own pages, and sharded CI runners replay the whole tree in parallel.

Installing Playwright and provisioning browsers

A new project starts with the official initializer, which scaffolds a TypeScript config, an example spec, and — if you opt in — a GitHub Actions workflow. It then downloads the three bundled engines so your local matrix matches CI. The wizard is interactive, so run it once per repository and commit the generated files.

import { execSync } from 'node:child_process';

// Scaffold config, example tests, and an optional CI workflow.
execSync('npm init playwright@latest', { stdio: 'inherit' });
// Download Chromium, Firefox, and WebKit plus their OS-level dependencies.
execSync('npx playwright install --with-deps', { stdio: 'inherit' });

After installation, run the example suite once to confirm every engine launches without permission errors. Pin the @playwright/test version in package.json so a future npm update cannot silently change browser builds underneath your assertions — version drift is one of the most common sources of "it passed yesterday" failures. Treat the browser binaries as a build dependency: the same npx playwright install command runs in your Dockerfile and your CI job so local and remote runs use identical engine revisions.

The directory layout the initializer produces is worth understanding because the rest of the architecture references it. playwright.config.ts sits at the root and is loaded automatically. tests/ (or whatever testDir you set) holds the specs. A playwright-report/ directory receives the HTML report after a run, and test-results/ collects traces, screenshots, and video. Add the last two to .gitignore — they are run artifacts, not source. Keeping config at the root and tests in a dedicated directory is what lets the runner discover and shard specs without per-file registration.

Where the binaries actually live, and why it matters

The engines do not sit in node_modules. Playwright downloads them into a shared per-user cache — ~/.cache/ms-playwright on Linux, ~/Library/Caches/ms-playwright on macOS, %USERPROFILE%\AppData\Local\ms-playwright on Windows — keyed by an internal revision number that is bound to the installed @playwright/test version. Three consequences follow. First, upgrading the npm package without re-running npx playwright install leaves the runner pointing at a revision that is not on disk, and every launch fails with an "executable doesn't exist" error rather than anything test-shaped. Second, because the cache is outside the project, a naive CI cache keyed on package-lock.json alone will restore stale binaries after an upgrade; key it on the resolved Playwright version and set PLAYWRIGHT_BROWSERS_PATH when you need the download inside the workspace, as covered in Caching Playwright Browsers in CI. Third, on a locked-down corporate network the download itself is the failure point: the installer honours HTTPS_PROXY, and PLAYWRIGHT_DOWNLOAD_HOST lets you point it at an internal mirror. When only headless Chromium is needed — a scraping job, a smoke check — npx playwright install --only-shell chromium fetches a fraction of the payload.

The Browser, BrowserContext, and Page model

Playwright exposes three nested objects, and most architecture decisions reduce to choosing the right one. A Browser is an expensive, long-lived engine process. A BrowserContext is a cheap, fully isolated profile inside that process — its own cookies, localStorage, IndexedDB, permissions, and cache. A Page is a single tab inside a context. Because contexts are cheap and pages are cheaper, you almost never relaunch the browser between tests; instead the runner hands each test a fresh context, guaranteeing a clean slate without the cost of a new process.

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

const browser = await chromium.launch();               // one engine process
const contextA = await browser.newContext();           // isolated profile A
const contextB = await browser.newContext();           // isolated profile B — no shared state
const pageA = await contextA.newPage();                // a tab inside A
await pageA.goto('https://example.com');
await contextA.close();                                // releasing A leaves B untouched
await contextB.close();
await browser.close();

This model is the reason Playwright parallelizes cleanly. Two tests in two contexts cannot see each other's storage even though they share one engine. Get the boundaries right and the rest of the architecture — fixtures, projects, sharding — composes on top. The full treatment of these boundaries, including authenticated storageState injection and concurrency limits, lives in Browser Contexts & Isolation.

Why isolation is the foundation

A test that mutates global state and a test that reads it will pass or fail depending on execution order, and parallel runners make that order nondeterministic. Context isolation removes the shared surface entirely. Provision one context per test, inject any pre-authenticated session through storageState, and let the runner tear it down after the last assertion. When a long-running scraping or end-to-end job needs many simultaneous sessions, you cap concurrency so memory stays bounded — the practical recipe is in How to Configure Multiple Browser Contexts in Playwright. Because cookies and storage never cross the context boundary, isolation also underpins safe data extraction behind login walls, a pattern that recurs throughout the Reliable Selector Strategies for Playwright guide where stable selectors matter most against authenticated, dynamic UIs.

Context options are also where environment emulation lives. Viewport size, locale, timezoneId, geolocation, colorScheme, granted permissions, and device descriptors are all per-context settings, which means one worker can drive a mobile Safari profile in Tokyo while the next drives a desktop Chrome profile in Berlin, in the same process, with no interference. That is a far cheaper way to cover a device matrix than launching separate browsers, and the option-by-option treatment is in Emulating Devices, Locales and Timezones.

Persistent contexts and connecting to a running engine

Two escape hatches sit outside the standard tree, and both trade isolation for something else. A persistent context owns a real profile directory on disk, so cookies, cache, service workers, and browser extensions survive between runs. It is what you reach for when the target site is protected by a device-trust cookie that takes a manual step to obtain, or when you must load an extension the automation depends on. The cost is that there is no Browser object above it and no isolation below it: every test that uses that profile shares its state, so parallel workers will collide.

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

// A persistent context owns its profile directory on disk. There is no
// separate Browser object — the context IS the browser session.
const context = await chromium.launchPersistentContext('./.profile', {
  viewport: { width: 1280, height: 720 },
  // Extensions and a real profile require a headed run in Chromium.
  headless: false,
});
// A persistent context opens with one page already attached.
const page = context.pages()[0] ?? (await context.newPage());
await page.goto('https://example.com');
await context.close();      // closing the context ends the browser session

The second escape hatch is chromium.connectOverCDP(), which attaches to a Chrome instance you started yourself rather than launching a bundled engine. It is useful for driving a browser inside a remote container or a debugging session already in flight, but it forfeits the version pinning that makes runs reproducible, so keep it out of the CI path and treat it as a local diagnostic tool.

Configuration and fixtures: the wiring layer

playwright.config.ts is the single source of truth for how tests run: where they live (testDir), how long they may take (timeout), how many times the runner retries a failure (retries), how many workers run concurrently, and which artifacts — traces, video, screenshots — to capture. Declaring this centrally means the same suite behaves identically on a laptop and on a CI runner, with environment-specific values injected through process.env rather than hardcoded.

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

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,                                  // per-test ceiling
  fullyParallel: true,                              // run files concurrently
  retries: process.env.CI ? 2 : 0,                  // absorb transient flakiness on CI only
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',                        // forensic data only when needed
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

Fixtures are the second half of the wiring. Instead of beforeEach and afterAll hooks, Playwright uses dependency injection: you extend the base test object with named factories, and the runner builds exactly the fixtures a test asks for, in dependency order, then tears them down in reverse. A fixture that opens an authenticated page, for example, owns the entire lifecycle of that page's context.

import { test as base, type Page } from '@playwright/test';

// A test-scoped fixture: fresh, isolated context per test.
export const test = base.extend<{ authedPage: Page }>({
  authedPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'auth.json' });
    const page = await context.newPage();
    await use(page);            // hand the page to the test body
    await context.close();      // guaranteed teardown, even on failure
  },
});

Worker-scoped fixtures persist across the files a single worker runs, which is where you put expensive one-time setup like seeding a database or minting a token. Test-scoped fixtures rebuild per spec for clean isolation. Choosing the right scope — and chaining fixtures so a dashboardPage depends on an apiClient — is the core skill covered in Playwright Config & Fixtures, with parallel-safe global setup detailed in Setting Up Global Fixtures for Parallel Tests.

The fixture build-and-teardown contract

What makes fixtures reliable is the guarantee around the use() call. Everything before use() is setup; use() hands the value to the test and blocks until the test finishes; everything after use() is teardown that runs unconditionally, including when the test throws. That contract is why a context opened in a fixture is always closed, why a seeded record is always cleaned up, and why you never write try/finally by hand. Fixtures also compose: declare one fixture as a dependency of another by naming it in the arguments, and the runner orders construction and reverses teardown for you. This dependency graph — not a flat list of hooks — is the mental model that scales from a single authenticated page to a multi-resource setup involving a seeded database, an API client, and several page objects.

Scope is the decision that most often gets made by accident. A test-scoped fixture runs once per test, so anything expensive inside it is multiplied by your test count; a worker-scoped fixture runs once per worker process and is shared by every test that worker executes, so anything mutable inside it becomes a shared resource across those tests. The rule that holds up under parallelism: worker scope for read-only or per-worker-partitioned resources — a token, a seeded tenant with a worker-index suffix, a compiled artifact — and test scope for anything a test writes to. Getting that split right is usually the single largest win available in suite runtime, and the mechanics are worked through in Worker-Scoped Fixtures for Expensive Setup.

Authentication as an architectural layer, not a test step

Almost every non-trivial application puts a login screen in front of the behaviour you actually want to assert. Driving that screen inside each test is the single most expensive mistake in suite design: a three-second login multiplied by four hundred specs is twenty minutes of wall clock spent re-proving something you already tested once, and each of those logins is another chance for an identity provider hiccup to fail an unrelated assertion. The architectural answer is to treat the authenticated session as a build artifact. A dedicated setup project signs in once through the real UI, serialises the resulting cookies and origin storage to disk with storageState(), and every other project starts its contexts pre-seeded from that file.

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

const ADMIN_STATE = 'playwright/.auth/admin.json';

// This file is matched by the `setup` project and runs before the suite.
setup('authenticate as admin', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL ?? '');
  await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD ?? '');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait on a real post-login signal so the cookie is definitely set.
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Serialise cookies + localStorage/IndexedDB origins for every worker to reuse.
  await page.context().storageState({ path: ADMIN_STATE });
});

The config side is a project dependency. Declaring dependencies: ['setup'] makes the runner execute the setup project to completion before any test project starts, and pointing use.storageState at the file it wrote means every context in that project opens already signed in.

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

export default defineConfig({
  projects: [
    // Runs first, once, and writes the state file.
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/admin.json',   // every context starts logged in
      },
      dependencies: ['setup'],                          // ordering guarantee
    },
  ],
});
Sequence of a setup project seeding worker contexts A sequence diagram in which a setup project logs in once and writes a storage state file, which then seeds a fresh context in each parallel worker. setup project runs once storageState JSON on disk worker 1 test context worker 2 test context writes cookies + storage seeds a fresh context seeds a fresh context in parallel every worker starts signed in — no UI login in the test body
One real login produces a JSON artifact that seeds every parallel worker's context, so no test spends time re-authenticating.

Three edge cases decide whether this pattern holds up. Sessions expire, so the state file needs a freshness check — the setup project should validate the saved cookie against a cheap authenticated endpoint and re-mint it when the check fails, rather than letting a stale file produce a suite-wide wave of redirects to /login. Multiple roles need multiple files: an admin.json, an editor.json, and a viewer.json, each written by its own setup test and consumed by its own project or fixture, so a permissions test can run two roles side by side. And the files hold live credentials, so they belong in a git-ignored directory and never in the repository. Federated identity adds one more wrinkle, because the login flow crosses to a different origin and back, and storageState only captures the origins the browser actually visited. The full treatment of all four problems — expiry, multi-role, secret handling, and OAuth round trips — is in Authentication & Session State, which is the layer to read next if your application has a login screen at all.

Cross-browser execution as configuration, not code

A project in the config is a named execution profile. By declaring one project per engine, you run the identical spec files three times — once each on Chromium, Firefox, and WebKit — without touching a line of test code. The runner multiplies your specs by your projects, and --project=firefox filters to a single engine when you are debugging an engine-specific failure.

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

export default defineConfig({
  fullyParallel: true,
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
  ],
});

The engines are not byte-for-byte identical: WebKit applies stricter Content Security Policy defaults, Firefox times synthetic input differently during rapid DOM mutation, and Chromium handles shadow DOM traversal aggressively. The fix is almost never branching test logic; it is using auto-retrying locators and explicit waitFor() calls so timing differences resolve themselves. Where genuine engine quirks remain, the comparison and debugging workflow lives in Cross-Browser Execution and the engine-by-engine breakdown in Running Chromium vs Firefox vs WebKit in Playwright.

Projects are not only for engines. Because a project is just a name plus a use block plus optional dependencies and testMatch, the same mechanism expresses a smoke tier that runs on every commit and a full tier that runs nightly, a mobile-viewport profile, a logged-out profile alongside the authenticated one, and the setup projects described above. Keep the count deliberate: every project multiplies the spec set, so three engines times two auth roles times a mobile profile is a six-fold run, and that cost lands squarely on your pipeline budget.

Structuring code with the Page Object Model

As a suite grows, inlining selectors into every spec creates a maintenance trap: a single UI rename forces edits across dozens of files. The Page Object Model fixes this by encapsulating each screen's locators and actions inside a class that receives the Page through its constructor. Tests then read as intent — loginPage.submitCredentials(...) — while selector details live in one place.

import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly username: Locator;
  readonly submit: Locator;

  constructor(page: Page) {
    this.page = page;
    // Prefer role- and label-based locators so the model survives UI refactors.
    this.username = page.getByRole('textbox', { name: /username/i });
    this.submit = page.getByRole('button', { name: 'Sign In' });
  }

  async submitCredentials(user: string, pass: string): Promise<void> {
    await this.username.fill(user);
    await this.page.getByLabel('Password').fill(pass);
    await this.submit.click();
    await this.page.waitForURL('**/dashboard');   // assert navigation, never sleep
  }
}

Organize page objects by feature domain rather than raw URL so components compose across workflows, and lean on Playwright's strict locator mode to surface ambiguous matches during development. Directory topology, component reuse, and scaling patterns for large repositories are documented in Page Object Model Design and its deep dive, Structuring Large Projects with the Page Object Model.

One boundary is worth stating explicitly: page objects hold locators and actions, not assertions. A method that both clicks and asserts hides the expectation from the spec, which makes failures read as "something broke inside the page object" instead of naming the behaviour that regressed. Return locators or plain data from the model and let the test express the expectation. The exception is a navigation guarantee like the waitForURL() above, which is part of the action completing rather than a claim about business behaviour.

Driving the backend with the API request context

Not every step of a test needs a browser. Playwright ships an HTTP client — APIRequestContext — exposed through the built-in request fixture, and using it for setup and teardown is one of the largest speed and stability wins available. Creating an invoice through eleven UI interactions takes seconds and can fail for eleven unrelated reasons; creating it with one POST takes milliseconds and fails only if the API is genuinely broken. Reserve the browser for the behaviour under test and let HTTP handle everything else.

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

test('seeds data over HTTP, then verifies it in the UI', async ({ page, request }) => {
  // `request` speaks HTTP directly: no rendering, no locators, no waiting.
  // It inherits baseURL and storageState from the project's `use` block.
  const created = await request.post('/api/invoices', {
    data: { customer: 'Acme', amount: 4200 },
  });
  expect(created.ok()).toBeTruthy();
  const { id } = (await created.json()) as { id: string };

  // Only the assertion that needs a real browser uses one.
  await page.goto(`/invoices/${id}`);
  await expect(page.getByRole('heading', { name: 'Acme' })).toBeVisible();

  // Tear down through the same API so nothing leaks into the next test.
  const deleted = await request.delete(`/api/invoices/${id}`);
  expect(deleted.status()).toBe(204);
});

There is a subtlety about cookies. The request fixture is its own context: it picks up baseURL and storageState from your use block at construction time, but it does not share a live cookie jar with the page. If a test logs in through the UI and then expects request to be authenticated, it will not be — use page.request, which is bound to the page's own context and does share its cookies. The same client is also the cleanest way to assert on the contract behind a screen: fetch the endpoint the UI calls, check the payload shape, and you have caught a backend regression that a purely visual assertion would have missed.

Parallelism: workers, files, and the shard arithmetic

Playwright's concurrency has two independent knobs, and confusing them is a common source of both slow suites and mysterious cross-test interference. Workers are operating-system processes on one machine; the runner spawns several and hands each a slice of the spec set. Shards split the spec set across separate machines, each of which then runs its own workers. Multiply them together to get true concurrency: four shards of four workers is sixteen tests in flight.

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

export default defineConfig({
  // Parallelise tests WITHIN a file, not just across files.
  fullyParallel: true,
  // CI containers over-report CPU count; half is a safer starting point.
  workers: process.env.CI ? '50%' : undefined,
  // Fail the whole run early once a threshold of failures is hit, so a broken
  // deploy does not burn the full pipeline budget.
  maxFailures: process.env.CI ? 20 : 0,
  // Forbid accidental focus leaks reaching the main branch.
  forbidOnly: !!process.env.CI,
});
Wall-clock effect of sharding one spec set A timeline comparing a single runner taking thirty-six minutes with four shards each taking nine minutes for the same set of specs. wall-clock time for the same 400 specs 1 runner 36 min end to end shard 1/4 9 min shard 2/4 9 min shard 3/4 9 min shard 4/4 9 min all four shards finish here; merge the blob reports
Sharding trades runner cost for wall clock roughly linearly — provided the split is balanced and every shard reports into one merged result.

Three practical constraints shape the numbers. First, each worker owns a browser process, and a Chromium instance under load wants roughly 1 GB, so a two-core CI container with 7 GB of memory tops out well before the CPU count suggests; workers: '50%' is a better default than the machine's reported core count. Second, the split is by file by default, so one file holding two hundred tests pins an entire shard while its siblings idle — fullyParallel: true breaks that up by distributing individual tests. Third, sharded runs produce one report per shard, which is useless on its own; configure the blob reporter on CI and run npx playwright merge-reports afterwards to get a single HTML report covering the whole matrix.

Some tests genuinely cannot run concurrently — a suite that toggles a global feature flag, or one that asserts against a singleton queue. Rather than dropping global parallelism to protect them, mark just those files with test.describe.configure({ mode: 'serial' }), or give them their own project with workers: 1. Partitioning shared fixtures by worker index — a tenant named acct-${test.info().workerIndex} — removes most of the remaining collisions without giving up concurrency at all. The pipeline mechanics of splitting, running, and merging are detailed in Running Playwright Tests in GitHub Actions with Sharding.

Running it all in CI/CD

The final layer is the pipeline. CI is where parallelism pays off and where flakiness hurts most, so the config you wrote earlier — retries on CI only, traces on first retry, video retained on failure — exists precisely for this stage. Sharding splits the spec set across multiple runners with --shard=1/4, cutting wall-clock time roughly linearly, while each runner uses the same npx playwright install browsers as your laptop so results are reproducible.

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

export default defineConfig({
  // Capture forensic artifacts only when a test actually fails on CI.
  use: {
    trace: 'on-first-retry',
    video: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  // Default the reporter to HTML locally and a CI-friendly format in the pipeline.
  reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'html',
});

Containerizing the runner pins the OS, fonts, and engine revisions so a green local run stays green remotely. The full pipeline workflow — caching browsers, fanning specs across runners with sharding, and Dockerizing for headless execution — is covered in CI/CD Integration. When a pipeline run does fail, the trace and video artifacts feed directly into the Debugging & Test Observability workflow, where the Trace Viewer reconstructs the exact failing timeline.

Reporters and artifacts: the observability surface

The reporter is the runner's output layer, and choosing it deliberately matters as much as choosing workers. Locally, the html reporter gives an interactive report with embedded traces; in the pipeline, a machine-readable format such as github (which annotates the PR directly), junit (which CI dashboards ingest), or json (which custom tooling parses) turns raw pass/fail data into something a team acts on. You can run several reporters at once. The artifacts the config captures — trace zips, failure screenshots, retained video — are what make a failed CI run reproducible without re-running it, and they are uploaded as build artifacts so any engineer can download and replay them. This observability surface is the bridge between the architecture you build and the day-to-day work of keeping it green.

Auto-waiting: the property that makes all of this reliable

Underneath every layer is one behavior that makes Playwright deterministic where older tools were flaky: auto-waiting. When you call an action like click() or fill() on a locator, Playwright does not fire immediately. It first checks a set of actionability conditions — the element is attached to the DOM, visible, stable (not animating), receives events (not obscured by an overlay), and enabled — and retries until they all hold or the timeout elapses. The same retry logic backs web-first assertions: expect(locator).toBeVisible() polls until the element appears rather than asserting once against a snapshot.

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

test('auto-waiting removes manual sleeps', async ({ page }) => {
  await page.goto('/dashboard');
  // No waitForTimeout: click() retries until the button is actionable.
  await page.getByRole('button', { name: 'Load report' }).click();
  // The assertion polls until the row appears, absorbing async fetch latency.
  await expect(page.getByRole('row', { name: /Q3 revenue/ })).toBeVisible();
});
The actionability retry loop behind every action A state machine showing a click call entering a set of five actionability checks that retry on a loop until they all pass or the timeout raises an error. locator.click() invoked actionability checks attached to the DOM visible stable, not animating receives pointer events enabled all checks pass action dispatched any check fails: re-evaluate, no sleep needed TimeoutError raised
Every action loops through the same five checks until they all hold, so a fixed sleep is never the right synchronisation tool.

This is why the recurring rule across every guide here is "never call waitForTimeout." A fixed sleep is either too short — and flaky — or too long — and slow. Auto-waiting replaces both with a condition that resolves the instant the app is ready. The engines differ in exactly how fast they reach that ready state, which is precisely why auto-retrying locators, rather than per-engine branching, are what keep the cross-browser matrix green.

Auto-waiting has limits worth knowing, because the failures it cannot absorb are the ones that reach your inbox. It waits for the element, not for your application's notion of "done": a table that renders empty rows before its data arrives satisfies every actionability check while showing nothing useful, so assert on the content you expect rather than the container. It also cannot see through an element that is technically actionable but semantically wrong — a stale row that a re-render is about to replace will happily accept a click that then applies to the wrong record. And the checks are skipped entirely by force: true, which is why that flag belongs in a comment-justified exception rather than in your default vocabulary. When a wait does time out, the trace records which condition never held, which turns "flaky click" into a named cause; the workflow is in Detecting and Fixing Flaky Playwright Tests.

Where each layer's failures show up

The architecture also predicts where a problem will surface, which shortens debugging. An installation or version-pin problem fails at launch — browsers will not start, or behave differently than CI. A fixture-scope mistake shows up as a slow suite or as state bleeding between tests. A missing context boundary shows up as order-dependent flakiness that only appears under parallel execution. An expired or shared session artifact shows up as a whole project failing at once, every spec redirected to a login page. A brittle selector inside a page object shows up as a sudden wave of failures after an unrelated UI change. A parallelism problem shows up as tests that pass alone and fail together. A pipeline problem shows up as "green locally, red on CI." Mapping a symptom back to its layer is half the work, and the artifacts captured by the config layer — traces, video, screenshots — give you the evidence to confirm the diagnosis in the Debugging & Test Observability workflow.

Maintaining the suite over time

A suite is a living system, and the architecture includes the habits that keep it healthy. Pin the Playwright version and bump it deliberately, reading the release notes for engine behavior changes. Track historical pass rates so a slowly rising flake rate is visible before it becomes a crisis, and route genuinely unstable specs to a quarantine project rather than letting them erode trust in the whole suite. Audit locators periodically for the brittle patterns — hashed class names, positional indexes — that page objects are meant to keep out. Watch per-shard duration so an imbalanced split can be rebalanced before it dominates pipeline time. None of this is glamorous, but it is the difference between a suite that scales for years and one that is rewritten every six months because no one trusts it.

Budget the same attention to the suite's own performance. Record total wall clock and the slowest twenty specs on every main-branch run; a suite that grows ten percent slower each month is on a path to being skipped. The usual culprits are predictable: UI setup that should be an API call, a test-scoped fixture doing work that belongs at worker scope, a login flow that escaped the setup project, and files that grew large enough to unbalance the shard split. Each of those is a layer of the architecture leaking into a place it does not belong, which is why the layering is worth defending.

How the pieces fit together

Read top to bottom, the architecture is a single dependency chain. Installation provisions the engines. Config and fixtures decide how the runner builds and tears down the object tree. Contexts enforce isolation so parallelism is safe. Setup projects mint the session artifacts those contexts consume. Projects fan the tree across engines and profiles. Page objects keep the test code maintainable as it grows, and the API request context keeps the browser out of work that does not need it. Auto-waiting makes every interaction resolve on a real condition rather than a guess. Workers and shards multiply the whole thing across processes and machines, and CI replays it while capturing artifacts when something breaks. Master each layer in its own guide, and the suite scales from one spec to thousands without the flakiness that sinks brittle test code.

Frequently Asked Questions

What is the difference between a Browser, a BrowserContext, and a Page?

A Browser is a single, expensive engine process. A BrowserContext is a cheap, fully isolated profile inside it with its own cookies and storage. A Page is one tab inside a context. The runner gives each test a fresh context so tests stay isolated without relaunching the browser.

Do I need separate test files for each browser engine?

No. Declare one project per engine in playwright.config.ts and the runner replays the same spec files across Chromium, Firefox, and WebKit. Use --project=<name> to filter to a single engine when debugging.

Should I use Page Object Model from the start of a project?

For anything beyond a handful of specs, yes. Encapsulating selectors and actions in page object classes means a UI change is a one-file edit instead of a sweep across every spec, which is the difference between a maintainable suite and a brittle one.

Where should the storageState file live, and should I commit it?

Write it to a git-ignored directory such as playwright/.auth/, one file per role. It contains live session cookies and tokens, so committing it leaks credentials and guarantees a stale artifact the moment the session expires. Regenerate it from a setup project on every run, and provide the credentials it needs through environment variables or your CI secret store.

How many workers should I configure?

Start with a percentage rather than a fixed number — workers: '50%' — because CI containers routinely report the host's core count while being limited to far less CPU and memory. Each worker drives its own browser process and wants roughly a gigabyte under load, so memory is usually the real ceiling. Measure wall clock at a few settings and keep the lowest value that does not introduce resource-contention timeouts.

Can several tests share one BrowserContext to save time?

They can, but the saving is small and the cost is large: a shared context means shared cookies, shared storage, and order-dependent results the moment one test writes state another reads. Context creation is measured in milliseconds, so the tax you are avoiding is negligible. If setup is genuinely expensive, move the expensive part to a worker-scoped fixture or an API call and still give every test its own context.

Does the request fixture share cookies with the page?

Not at runtime. The request fixture is constructed from the project's use block, so it inherits baseURL and any storageState you configured, but it keeps its own cookie jar afterwards. If a test authenticates through the UI and then needs an authenticated HTTP call, use page.request, which is bound to that page's context and sees the cookies the browser just received.

Why does my suite pass locally but fail in CI?

Four causes account for most of it: a different browser revision because the CI cache restored stale binaries, fewer CPUs so timing-sensitive tests exceed their timeout, headless-versus-headed rendering differences in fonts and viewport, and missing environment variables so the auth setup silently produces an unauthenticated state file. Enable trace: 'on-first-retry', download the trace from the failed run, and the timeline will name which of the four you are looking at.

Back to overview