Playwright: Parallelism, Sharding & Retries

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A suite of end-to-end tests is only useful if it finishes quickly and fails for real reasons. Playwright Test addresses the first with parallel worker processes and sharding across machines, and the second with retries that tell a flaky test from a broken one. This page targets Playwright 1.64.1 for Node.js (the Python, Java and .NET examples elsewhere in this section ran at 1.63) and was checked on 25 October 2026. Every command and test on this page was run against the Bookshelf demo application described in Example Application & Test Layout on a 10-core macOS machine with Chromium, except the GitHub Actions workflow, which was not run (see its section). Isolation in general, and per-test browser contexts, are covered in Authentication & Isolation; reporters and traces are covered in Debugging, Tracing & Codegen; the CI view of flakiness is in Running in CI/CD.

How Playwright runs tests in parallel

The test runner is an orchestrator. It collects the tests, then starts worker processes: independent operating-system processes, each with its own Node.js runtime and its own browser instance. Workers cannot talk to each other and share no memory, so module-level variables are not shared between tests that land on different workers. Each worker runs the tests it is handed one after another, and each test gets a fresh browser context, so the browser state is clean even though the browser process is reused.

Three behaviours follow from that design and explain most surprises:

  • Files run in parallel, tests inside a file run in order. This is the default. All tests of a file go to one worker unless you change the mode.

  • A worker is reused as long as tests pass. Several files usually run in one worker, one after another, which keeps the browser start-up cost low.

  • A worker is discarded after a failure. Playwright shuts down the worker (running afterAll hooks) and starts a new one for the next test, so a failing test cannot leave dirty state behind for healthy tests. The replacement has the same parallelIndex but a new workerIndex.

How many workers

By default the runner uses half of the logical CPU cores. On the 10-core machine used here, a suite with enough test groups starts five workers:

$ npx playwright test -c ex-parallelism/playwright.config.ts tests/read-only tests/isolated tests/modes tests/flaky tests/broken
Running 17 tests using 5 workers

The number of workers is also capped by the number of independent test groups: four tests in one fullyParallel file use at most four workers no matter what you ask for. Use --workers or the workers option to change it. The value is a number or a percentage of the logical cores:

$ npx playwright test tests/read-only --workers=1
Running 4 tests using 1 worker
$ npx playwright test tests/read-only --workers=25%
Running 4 tests using 2 workers
$ npx playwright test tests/read-only --workers=50%
Running 4 tests using 4 workers

On a 10-core host 25% gives 2 workers (the result is rounded down, never below 1) and 50% would give 5, but only four test groups exist in that file. A value of zero or a negative number is rejected (since 1.60). --workers=1 is the way to turn parallelism off entirely, for debugging or for a suite that is not yet isolated.

Choosing a worker count

More workers are not always faster. Every worker starts its own browser, so memory and CPU saturate, and network-bound tests wait on the application under test, which also has a limit. The usual pattern is the default on a developer machine and an explicit number on CI, where cores are often shared and counted differently from the host:

// playwright.config.ts (excerpt)
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Limit the number of workers on CI, use the default locally.
  workers: process.env.CI ? 2 : undefined,
});

A project can have its own limit with testProject.workers (since 1.52); the global workers value is still the upper bound. Headless and headed runs both honour the setting, but headed workers each open a visible window, so use --workers=1 when you watch a run.

Parallel modes: default, parallel and serial

A file or a describe group is in one of three modes. The mode decides which tests may run at the same time.

Mode Behaviour Use it for

default

Files run in parallel; tests inside a file run in order in one worker. When one fails, the worker restarts and the rest of the file continues in the next worker.

The out-of-the-box mode. Safe when tests in a file share expensive set-up in beforeAll.

parallel

Every test runs independently, possibly in a different worker. Each test runs its own beforeAll and afterAll. Enabled for everything by fullyParallel: true or --fully-parallel.

Independent tests, which is what end-to-end tests should be.

serial

Tests run in order in one worker. If one fails, the rest are skipped; with retries the whole group is retried together from the first test.

Truly dependent steps. The documentation discourages it.

fullyParallel

fullyParallel: true puts every test of every file in parallel mode. It also changes sharding, because shards can then be balanced per test instead of per file (see the sharding section). It can be set globally, per project, or on the command line:

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

export default defineConfig({
  testDir: './tests',
  outputDir: './test-results',
  globalSetup: './global-setup.ts',
  // Run the tests of every file in parallel, not only the files.
  fullyParallel: true,
  // Default is 50% of the logical CPU cores; override with --workers.
  workers: process.env.CI ? 2 : undefined,
  retries: process.env.CI ? 2 : 0,
  // One blob report per shard on CI, a readable list locally.
  reporter: process.env.CI ? 'blob' : 'list',
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    testIdAttribute: 'data-testid',
    trace: 'on-first-retry',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

Every other spec on this page runs with this configuration. To make only some projects fully parallel, put fullyParallel: true inside the project entry instead.

test.describe.configure

test.describe.configure() sets the mode, retries, timeout and (since 1.64) lock of the enclosing file or describe group. Called at the top level of a file it applies to the whole file; inside a describe callback it applies to that group. This spec shows an independent group with its own retry count, a group that opts out of fullyParallel, and a serial group (modes.spec.ts):

import { test, expect } from './fixtures';

// Serial mode: dependent steps share one worker, run in order and are retried together.
test.describe.serial('checkout-like flow', () => {
  let bookTitle = '';

  test('step 1: open the first book', async ({ page }) => {
    await page.goto('/books');
    await page.getByTestId('book-card').first().click();
    bookTitle = (await page.getByTestId('book-title').textContent()) ?? '';
    expect(bookTitle).not.toBe('');
  });

  test('step 2: the title is remembered', async () => {
    expect(bookTitle).not.toBe('');
  });
});

// Per-group retries and an explicit mode.
test.describe('group with its own retries', () => {
  test.describe.configure({ mode: 'parallel', retries: 2 });

  test('first independent test', async ({ page }) => {
    await page.goto('/');
    await expect(page.getByTestId('brand')).toBeVisible();
  });

  test('second independent test', async ({ page }) => {
    await page.goto('/books');
    await expect(page.getByTestId('result-count')).toBeVisible();
  });
});

// Opt out of fullyParallel for one group: tests run in order in one worker.
test.describe('ordered group', () => {
  test.describe.configure({ mode: 'default' });

  test('ordered 1', async ({ page }) => {
    await page.goto('/');
  });

  test('ordered 2', async ({ page }) => {
    await page.goto('/books');
  });
});

The six tests passed in about 0.7 seconds on four workers. test.describe.serial(title, callback) is shorthand for a describe with mode: 'serial'.

Serial mode in practice

The state shared through the bookTitle variable above only works because the two tests are guaranteed to run in the same worker, in order. If a serial test fails, the rest of the group does not run:

$ npx playwright test serial-failure
Running 3 tests using 1 worker
  ✓  1 [chromium] › serial-failure.spec.ts:5:5 › first good (73ms)
  ✘  2 [chromium] › serial-failure.spec.ts:10:5 › second flaky (66ms)
  -  3 [chromium] › serial-failure.spec.ts:15:5 › third good
  1 failed
    [chromium] › serial-failure.spec.ts:10:5 › second flaky
  1 did not run
  1 passed (524ms)

With --retries=1 the whole group is retried from its first test, not only the failed one:

$ npx playwright test serial-failure --retries=1
Running 3 tests using 1 worker
  ✓  1 [chromium] › serial-failure.spec.ts:5:5 › first good (75ms)
  ✘  2 [chromium] › serial-failure.spec.ts:10:5 › second flaky (79ms)
  -  3 [chromium] › serial-failure.spec.ts:15:5 › third good
  ✓  4 [chromium] › serial-failure.spec.ts:5:5 › first good (retry #1) (106ms)
  ✓  5 [chromium] › serial-failure.spec.ts:10:5 › second flaky (retry #1) (82ms)
  ✓  6 [chromium] › serial-failure.spec.ts:15:5 › third good (retry #1) (80ms)
  1 flaky
    [chromium] › serial-failure.spec.ts:10:5 › second flaky
  2 passed (1.3s)

The documentation recommends against serial mode because a failure hides every later test and a retry repeats work. Prefer making each test arrange its own state. A common legitimate use is sharing one page between steps with beforeAll (browser.newPage()) and afterAll (page.close()), which only works in serial mode because the page must live in a single worker.

Worker index and parallel index

Every worker has two ids. The worker index is unique and starts at 0; a worker restarted after a failure gets a new one. The parallel index is between 0 and workers - 1 and is reused by the replacement worker, so it names a "slot". Read them from testInfo.workerIndex and testInfo.parallelIndex, or from the environment variables TEST_WORKER_INDEX and TEST_PARALLEL_INDEX. The first spec (read-only.spec.ts) prints them:

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

const queries = [
  { q: 'a', min: 1 },
  { q: 'e', min: 1 },
  { q: 'o', min: 1 },
  { q: 'zzzz-no-match', min: 0 },
];

// Read-only tests are safe to run in any order on any worker.
for (const { q, min } of queries) {
  test(`search for "${q}"`, async ({ page }, testInfo) => {
    console.log(
      `search "${q}" -> worker ${testInfo.workerIndex}, parallel ${testInfo.parallelIndex}`,
    );
    await page.goto('/books');
    await page.getByTestId('search-input').fill(q);
    await page.getByTestId('search-input').press('Enter');
    if (min > 0) {
      await expect(page.getByTestId('book-card').first()).toBeVisible();
    } else {
      await expect(page.getByTestId('empty-state')).toBeVisible();
    }
  });
}

With the default workers each test lands on its own worker, and with one worker they all share worker 0:

$ npx playwright test read-only
Running 4 tests using 4 workers

search "e" -> worker 1, parallel 1
search "zzzz-no-match" -> worker 3, parallel 3
search "a" -> worker 0, parallel 0
search "o" -> worker 2, parallel 2
  ✓  2 [chromium] › read-only.spec.ts:12:7 › search for "e" (202ms)
  ✓  1 [chromium] › read-only.spec.ts:12:7 › search for "zzzz-no-match" (209ms)
  ✓  3 [chromium] › read-only.spec.ts:12:7 › search for "o" (217ms)
  ✓  4 [chromium] › read-only.spec.ts:12:7 › search for "a" (240ms)

  4 passed (739ms)

$ npx playwright test read-only --workers=1
Running 4 tests using 1 worker

search "a" -> worker 0, parallel 0
  ✓  1 [chromium] › read-only.spec.ts:12:7 › search for "a" (95ms)
search "e" -> worker 0, parallel 0
...

The first run printed the lines in an order that differs from the test numbers, which is normal: ordering across workers is not guaranteed. Use the worker index for resources that a worker creates once and keeps (a database user, a tenant, a temporary directory), and the parallel index for resources you pre-provision as a fixed pool (workers ports or accounts).

Isolating data between parallel tests

Parallel tests collide when they touch state that lives outside their own browser context. The browser context already isolates cookies, storage and the DOM; the shared pieces are the backend (database rows, files, server-side sessions) and anything global on the machine. The rules are: tests either only read shared data, or they create data that is unique to them and assert only on that data.

The reset trap

Bookshelf’s /api/test/reset restores the seed data and clears every server-side session. It is a good way to start a run and a bad thing to call in a test, because it affects tests that are running on other workers at the same moment. The next spec has one test that signs in and posts a review, and another that calls reset while the first is "doing other work" (reset-trap.spec.ts; the short waitForTimeout only stands in for the work a real test would do between steps):

import { test, expect } from './fixtures';

// BAD: a parallel test that calls the global reset endpoint also wipes every session and
// every review written by tests that run at the same time on other workers.
test.describe('shared-state trap', () => {
  test('reader posts a review', async ({ page, signedIn }) => {
    await page.waitForTimeout(500); // pretend to do other work first
    const res = await page.request.post('/api/reviews', {
      data: { bookId: 1, rating: 5, text: 'Written while another test resets' },
    });
    expect(res.status()).toBe(201);
  });

  test('another test resets the data', async ({ request }) => {
    await new Promise((r) => setTimeout(r, 200));
    await request.post('/api/test/reset');
  });
});

Run on two workers, the reader’s own test fails although nothing is wrong with it. Its session disappeared in the middle of the test:

$ npx playwright test reset-trap --workers=2
Running 2 tests using 2 workers

  ✓  2 [chromium] › reset-trap.spec.ts:14:7 › shared-state trap › another test resets the data (214ms)
  ✘  1 [chromium] › reset-trap.spec.ts:6:7 › shared-state trap › reader posts a review (583ms)

  1) [chromium] › reset-trap.spec.ts:6:7 › shared-state trap › reader posts a review

    Error: expect(received).toBe(expected) // Object.is equality

    Expected: 201
    Received: 401

  1 failed
    [chromium] › reset-trap.spec.ts:6:7 › shared-state trap › reader posts a review
  1 passed (985ms)

This is the textbook shape of a "flaky" test: in a real suite the reset would land at a random moment, so the victim would pass most days and fail on some. /api/test/seed has the same property for books and reviews because it replaces the shared arrays; seeding different data from two parallel tests makes each test see the other’s books. A seed is safe only when it runs in a place that no test overlaps, or when every parallel test seeds exactly the same data before any test reads it.

Three ways to avoid the problem, from most to least preferable:

  • Arrange once, before the workers start. Reset (or seed) in globalSetup, a setup project or the CI job’s first step, and make every test read-only or additive.

  • Write unique data and assert only on it. Derive a name from testInfo.testId, testInfo.workerIndex or a random value, and look the data up by that name.

  • Use a locked or dedicated resource. Give the test a backend of its own (a container, a schema, a tenant per worker) or a test lock (next sections).

The global set-up used by this page’s configuration (global-setup.ts) resets once, before any worker starts:

import { request, type FullConfig } from '@playwright/test';

// Reset once, before any worker starts. Never reset inside a parallel test.
export default async function globalSetup(config: FullConfig) {
  const baseURL = config.projects[0].use.baseURL ?? process.env.BASE_URL;
  const api = await request.newContext({ baseURL });
  const res = await api.post('/api/test/reset');
  if (!res.ok()) throw new Error(`reset failed: ${res.status()}`);
  await api.dispose();
}

A worker-scoped fixture and unique data per test

test.extend can create a worker-scoped fixture that is built once per worker and shared by that worker’s tests. The fixture below gives each worker a tag from its worker index and signs in per test inside the test’s own context (fixtures.ts). The password comes from the BOOKSHELF_READER_PASSWORD environment variable that the Bookshelf server was started with:

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

const readerPassword = process.env.BOOKSHELF_READER_PASSWORD!;

type Worker = { tag: string };
type Test = { signedIn: void };

export const test = base.extend<Test, Worker>({
  // Worker-scoped: one tag per worker process, unique across parallel workers.
  tag: [async ({}, use, workerInfo) => {
    await use(`w${workerInfo.workerIndex}`);
  }, { scope: 'worker' }],

  // Test-scoped: each test signs in inside its own browser context.
  signedIn: [async ({ page }, use) => {
    const res = await page.request.post('/api/session', {
      data: { username: 'reader', password: readerPassword },
    });
    expect(res.ok()).toBeTruthy();
    await use();
  }, { auto: false }],
});

export { expect };

The spec writes one review per test, with a marker that no other test can produce, and checks only that marker (isolated-reviews.spec.ts). It never counts all reviews, never resets, and passes in any order and on any number of workers:

import { test, expect } from './fixtures';

// Each test writes only data that carries its own unique marker and asserts only on that
// marker, so tests never depend on how many reviews exist globally.
for (const bookId of [1, 2, 3, 4]) {
  test(`reader reviews book ${bookId}`, async ({ page, signedIn, tag }, testInfo) => {
    const marker = `review-${tag}-${testInfo.testId}`;
    const res = await page.request.post('/api/reviews', {
      data: { bookId, rating: 4, text: `Written by ${marker}` },
    });
    expect(res.status()).toBe(201);

    await page.goto(`/books/${bookId}`);
    await expect(page.getByTestId('review').filter({ hasText: marker })).toHaveCount(1);
  });
}
$ npx playwright test isolated-reviews
Running 4 tests using 4 workers

  ✓  1 [chromium] › isolated-reviews.spec.ts:6:7 › reader reviews book 2 (567ms)
  ✓  4 [chromium] › isolated-reviews.spec.ts:6:7 › reader reviews book 3 (606ms)
  ✓  2 [chromium] › isolated-reviews.spec.ts:6:7 › reader reviews book 4 (610ms)
  ✓  3 [chromium] › isolated-reviews.spec.ts:6:7 › reader reviews book 1 (615ms)

  4 passed (1.3s)

Two related recipes from the documentation: write files with testInfo.outputPath('name'), which returns a path unique to the current test, so tests never overwrite each other’s files; and, when a dataset can be shared by many tests, create it in a worker-scoped fixture keyed by workerIndex (the documentation’s example creates a database user per worker and deletes it after the worker’s last test).

Test locks

A few tests need exclusive access to something that cannot be duplicated, such as an external service or a global account setting. A test lock is a named mutex: tests that share a lock name never run at the same time, while every other test keeps running in parallel. Locks work across files, workers and projects (since 1.63):

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

test('update user settings', { lock: 'user-settings' }, async ({ page }) => {
  // never runs at the same time as another test that holds 'user-settings'
});

test('reset the database', { lock: ['database', 'external-api'] }, async () => {
  // holds both locks; starts only when both are free
});

test.describe('billing', { lock: 'billing-account' }, () => {
  test('charge', async () => {});
});

// File-wide or group-wide (1.64); repeated calls add locks without removing any.
test.describe.configure({ lock: 'user-settings' });

This snippet was not run: the Bookshelf application has no resource that needs a lock. Playwright acquires every lock of a test before the test starts and releases them when it finishes. In default and serial mode all tests of a file run together, so a lock declared on any one of them is held for the whole file.

Order, shuffling and repetition

Within a file in default or serial mode the order is the declaration order. Across files there is no order guarantee when parallelism is on. If you need a fixed order, run with --workers=1 and either name files alphabetically (001-signin.spec.ts) or use a test list file that wraps each spec’s tests in a function and calls it in the order you want (workers: 1, testMatch: 'test.list.ts'). The documentation marks test lists as discouraged and best-effort only: the VS Code extension and tracing may not work properly with them.

Two flags help to find order dependencies and instability:

  • --shuffle [seed] (since 1.64) schedules test files, and tests in parallel mode, in a random order, and prints the seed so the order can be reproduced.

  • --repeat-each=N runs every selected test N times.

$ npx playwright test modes --shuffle
Running 6 tests using 4 workers, shuffle seed 808148001

$ npx playwright test modes --shuffle 271828 --workers=1
Running 6 tests using 1 worker, shuffle seed 271828
  ✓  1 [chromium] › modes.spec.ts:28:7 › group with its own retries › second independent test (78ms)
  ✓  2 [chromium] › modes.spec.ts:23:7 › group with its own retries › first independent test (63ms)
  ✓  3 [chromium] › modes.spec.ts:38:7 › ordered group › ordered 1 (52ms)
  ...

$ npx playwright test modes --repeat-each=3 --grep 'ordered 1'
Running 3 tests using 3 workers
  ✓  3 [chromium] › modes.spec.ts:38:7 › ordered group › ordered 1 (103ms)
  ✓  1 [chromium] › modes.spec.ts:38:7 › ordered group › ordered 1 (104ms)
  ✓  2 [chromium] › modes.spec.ts:38:7 › ordered group › ordered 1 (107ms)
  3 passed (564ms)

--repeat-each=50 --workers=4 against one suspect test is a quick way to reproduce a flaky test locally, and combining it with --retries=0 shows the real failure rate instead of a flaky classification.

Failing fast: maxFailures

maxFailures (CLI --max-failures=N) stops the whole run after N failed tests and reports the remaining tests as "did not run". It saves CI minutes when the application is plainly broken, for example when the server did not start:

$ npx playwright test broken modes --max-failures=1 --workers=1
Running 7 tests using 1 worker
  ✘  1 [chromium] › broken.spec.ts:3:5 › always broken (560ms)
  1 failed
  6 did not run
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Limit the number of failures on CI to save resources
  maxFailures: process.env.CI ? 10 : undefined,
});

--debug implies it: that flag runs with --max-failures=1 --workers=1 --headed.

Retries

Retries re-run a failed test automatically. They are off by default, and they are configured per run, per project, or per group.

# Give failing tests 3 retry attempts
npx playwright test --retries=3
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Give failing tests 3 retry attempts
  retries: 3,
});

A common compromise is no retries locally, so failures are visible immediately, and one or two on CI: retries: process.env.CI ? 2 : 0.

What happens to the worker

When a test fails, the worker process and its browser are discarded and a new worker starts. With retries enabled, the new worker starts with the retry of the failed test and then continues with the remaining tests. beforeAll and afterAll hooks therefore run again in the new worker. This guarantees that a failing test cannot affect the tests after it, and it means retries start from a clean browser, not from the state the failed attempt left behind.

Passed, flaky and failed

Playwright classifies each test by its attempts:

Outcome Meaning

passed

Passed on the first attempt.

flaky

Failed on the first attempt, then passed on a retry.

failed

Failed on the first attempt and on every retry.

skipped

Never ran: skipped on purpose with test.skip(), or not executed because of a serial failure or maxFailures (the summary says "did not run").

The default exit code is 0 when every failure recovered on a retry, so flaky tests are visible in the summary but do not fail the build unless you ask for it.

A genuinely flaky test

testInfo.retry is the number of the current attempt (0 for the first run) and is available to tests, hooks and fixtures. This spec fails on its first attempt and passes on the second, which is exactly the shape of a flaky test (flaky.spec.ts):

import { test, expect } from './fixtures';

test('stable test', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByTestId('home-heading')).toBeVisible();
});

// Fails on the first attempt and passes on the retry: a genuinely flaky test.
test('flaky on purpose', async ({ page }, testInfo) => {
  await page.goto('/books');
  // Simulate an intermittent failure that only the first attempt sees.
  expect(testInfo.retry, 'first attempt fails on purpose').toBeGreaterThan(0);
  await expect(page.getByTestId('book-card').first()).toBeVisible();
});

Without retries the flaky test is a plain failure and the run exits non-zero. With --retries=2 the same run reports one flaky test:

$ npx playwright test flaky --retries=2
Running 2 tests using 2 workers

  ✓  1 [chromium] › flaky.spec.ts:3:5 › stable test (96ms)
  ✘  2 [chromium] › flaky.spec.ts:9:5 › flaky on purpose (99ms)
  ✓  3 [chromium] › flaky.spec.ts:9:5 › flaky on purpose (retry #1) (121ms)


  1) [chromium] › flaky.spec.ts:9:5 › flaky on purpose ────────────────────────

    Error: first attempt fails on purpose

    expect(received).toBeGreaterThan(expected)

    Expected: > 0
    Received:   0

      10 |   await page.goto('/books');
      11 |   // Simulate an intermittent failure that only the first attempt sees.
    > 12 |   expect(testInfo.retry, 'first attempt fails on purpose').toBeGreaterThan(0);
         |                                                            ^
      13 |   await expect(page.getByTestId('book-card').first()).toBeVisible();
      14 | });

  1 flaky
    [chromium] › flaky.spec.ts:9:5 › flaky on purpose ─────────────────────────
  1 passed (1.0s)

The retry appears as its own line, marked (retry #1); the summary lists the flaky test separately from the passed ones. The process exit code of that run was 0. Compare with the same suite without retries:

$ npx playwright test flaky
  ✓  2 [chromium] › flaky.spec.ts:3:5 › stable test (129ms)
  ✘  1 [chromium] › flaky.spec.ts:9:5 › flaky on purpose (134ms)
  1 failed
    [chromium] › flaky.spec.ts:9:5 › flaky on purpose ─────────────────────────
  1 passed (677ms)

A test that fails on every attempt is classified as failed. With --retries=1 the always-failing test below printed both attempts, the second with a trace:

$ npx playwright test broken --retries=1
  ...
    Retry #1 ───────────────────────────────────────────────────────────────────────────────────────
    Error: expect(locator).toHaveCount(expected) failed

    Locator:  getByTestId('book-card')
    Expected: 99
    Received: 6
    Timeout:  500ms
  ...
    ex-parallelism/test-results/broken-always-broken-chromium-retry1/trace.zip
    Usage:
        npx playwright show-trace ex-parallelism/test-results/broken-always-broken-chromium-retry1/trace.zip
  1 failed
    [chromium] › broken.spec.ts:3:5 › always broken ───────────────────────────

Per-group retries and the retry strategy

A group or file can ask for its own retry count with test.describe.configure({ retries: 2 }), as in the modes.spec.ts example above, and a project can set its own value with testProject.retries.

By default a failed test is retried immediately, in the next free worker, while other tests are still running. Since 1.62 retryStrategy: 'isolated' instead runs all retries at the end, one by one in a single worker, to minimise interference with the rest of the suite (useful when a flaky test is flaky precisely because of the load from other tests). This config wraps the base one (isolated.config.ts):

import { defineConfig } from '@playwright/test';
import base from './playwright.config';

export default defineConfig(base, { retries: 2, retryStrategy: 'isolated' });
$ npx playwright test -c ex-parallelism/isolated.config.ts flaky modes
Running 8 tests using 5 workers
  ...
  ✘  2 [chromium] › flaky.spec.ts:9:5 › flaky on purpose (294ms)
  ...
  ✓  9 [chromium] › flaky.spec.ts:9:5 › flaky on purpose (retry #1) (118ms)
  1 flaky
    [chromium] › flaky.spec.ts:9:5 › flaky on purpose ─────────────────────────
  7 passed (1.2s)

The retry ran last (numbered 9 after eight first attempts), as the strategy promises.

Trace on first retry

Retries are a good moment to record a trace, because a trace costs time and disk space and a first attempt rarely needs one. trace: 'on-first-retry' records a trace only for the first retry of each test. The configuration above sets it, and the flaky run wrote one for the passing retry, so you can compare the passing run with the failure that preceded it:

$ find ex-parallelism/test-results -name '*.zip'
ex-parallelism/test-results/flaky-flaky-on-purpose-chromium-retry1/trace.zip
$ npx playwright show-trace ex-parallelism/test-results/flaky-flaky-on-purpose-chromium-retry1/trace.zip

The show-trace command opens the Trace Viewer window and was not run here. The other values are off, on, retain-on-failure, on-all-retries and retain-on-first-failure; the trace viewer is covered in Debugging, Tracing & Codegen and Screenshots, Videos & Traces.

Doing something different on a retry

Because testInfo.retry is available in hooks and fixtures, a suite can clean server-side state before a retry or collect extra diagnostics only on retries:

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

test('my test', async ({ page }, testInfo) => {
  if (testInfo.retry)
    await cleanSomeCachesOnTheServer();
  // ...
});

This is the documentation’s example and was not run (cleanSomeCachesOnTheServer stands for any helper of your own). Use it sparingly: a test that behaves differently on a retry can hide the cause of a failure, and the retry no longer reproduces the original conditions.

Making flaky tests fail the build

A retry converts a red build into a green one, which is convenient and also a way to accumulate instability. Two controls bring flakiness back into view:

  • --fail-on-flaky-tests (since 1.45) or failOnFlakyTests: true in the configuration (since 1.52) makes the run exit with code 1 whenever a test is flaky. Reporters can read the setting from fullConfig.failOnFlakyTests (since 1.61).

  • A tag such as { tag: '@flaky' } on a known-flaky test lets the team list and track it while it still runs.

$ npx playwright test flaky --retries=2 --fail-on-flaky-tests > /dev/null; echo "exit $?"
exit 1

The same run without the flag exits with 0. A typical policy is to allow retries so one unlucky run does not block a merge, and to fail a nightly run on flakiness so the team fixes the offenders.

Re-running only what failed

--last-failed (since 1.44) runs only the tests that failed in the previous run. Playwright records them in <outputDir>/.last-run.json; --last-failed-file or the environment variable PLAYWRIGHT_LAST_RUN_OUTPUT_FILE changes that path. After a run that had two failures:

$ cat ex-parallelism/test-results/.last-run.json
{
  "status": "failed",
  "failedTests": [
    "8c7f8f1902ef713fcbc2-412b8129e06355bc1e62",
    "b862a406253815a75d4a-e09e3ed2593c3c953560"
  ]
}
$ npx playwright test --last-failed
Running 2 tests using 2 workers
  ✘  1 [chromium] › flaky.spec.ts:9:5 › flaky on purpose (113ms)
  ✘  2 [chromium] › broken.spec.ts:3:5 › always broken (620ms)
  2 failed

Only the tests that failed on their final attempt are recorded: a test that is flaky (passed on a retry) leaves the file empty, as an earlier run of this suite showed ("status": "passed", "failedTests": []). Other filters worth knowing are --only-changed[=ref] (since 1.46, files changed in git), --grep and --grep-invert, --test-list and --test-list-invert (since 1.56), and --list to print which tests would run without running them.

Sharding: splitting a suite across machines

Workers use the cores of one machine. Sharding splits the suite into N parts that run independently, typically one part per CI job, so the wall-clock time falls roughly in proportion. Each shard still uses all the workers of its machine.

npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4

The same selection is available as shard: { current: 2, total: 4 } in the configuration. Numbers are 1-based. The runner prints the shard in its first line:

$ CI=1 npx playwright test --shard=1/3
Running 6 tests using 2 workers, shard 1 of 3

How tests are balanced

  • With fullyParallel: true the unit of sharding is the individual test, so shards get an even number of tests. The documentation recommends it for CI.

  • Without it the unit is the test file, so uneven files give uneven shards, and the same file may end up in different shards for different projects. Keep files small and similar in size.

  • Tests that are statically skipped (test.skip(), test.fixme()) do not count when balancing, because they do not run.

Playwright 1.64 offers no other sharding strategy: shards are deterministic splits of the list of tests or files. A suite of 16 tests split three ways above ran 6, 6 and 4 tests. Because the split depends on the set of tests and the configuration, every shard must run with the same configuration, the same project filter and the same checkout.

Blob reports and merge-reports

Each shard writes its own report, so none of them shows the whole run. The blob reporter writes a zip with all results and attachments (traces, screenshots) so the shard reports can be merged afterwards into any report format. A blob report is named after the shard (for example report-a38d0d6-2.zip), so reports from different shards never clash when you put them in one directory:

// playwright.config.ts (excerpt)
reporter: process.env.CI ? 'blob' : 'html',

On CI each shard runs on its own machine, so each has its own blob-report directory. To run all the shards on one machine for this page, each shard’s output was copied into a shared all-blob-reports directory before the next shard started (the blob reporter empties its output directory at the start of a run):

mkdir all-blob-reports
for i in 1 2 3; do
  CI=1 npx playwright test --shard=$i/3
  cp blob-report/*.zip all-blob-reports/
done
ls all-blob-reports
# report-a38d0d6-1.zip  report-a38d0d6-2.zip  report-a38d0d6-3.zip

Then merge-reports (since 1.37) reads the directory and produces a report in any format. Merged into the list reporter, the three shards show up as one run, with the flaky test from shard 1 and 15 passes:

$ npx playwright merge-reports --reporter=list ./all-blob-reports
  ✓   1 [chromium] › flaky.spec.ts:3:5 › stable test (76ms)
  ✘   2 [chromium] › flaky.spec.ts:9:5 › flaky on purpose (80ms)
  ✓   4 [chromium] › flaky.spec.ts:9:5 › flaky on purpose (retry #1) (81ms)
  ...
  1 flaky
    [chromium] › flaky.spec.ts:9:5 › flaky on purpose ─────────────────────────
  15 passed (4.0s)

The options of merge-reports are --reporter (one or several comma-separated reporters, for example --reporter=html,github) and --config, a configuration file that supplies reporter options. The merge configuration can differ from the one used to create the blobs. This one writes an HTML report without opening it, plus a dot summary (merge.config.ts):

export default {
  testDir: './tests',
  reporter: [['html', { open: 'never', outputFolder: 'merged-report' }], ['dot']],
};
$ npx playwright merge-reports --config=ex-parallelism/merge.config.ts ./ex-parallelism/all-blob-reports
$ ls ex-parallelism/merged-report
data	index.html	trace

The HTML report was produced in the merged-report folder next to the merge configuration. When the blobs were created on a different operating system than the machine that merges them, supply an explicit merge configuration with testDir, otherwise the runner cannot decide which directory is the tests root. To run the same tests in several environments (rather than shard them), set tag in the configuration (tag: process.env.CI_ENVIRONMENT_NAME, since 1.57) and the tag is carried into the blob and the merged report.

GitHub Actions: a shard matrix

GitHub Actions turns shards into a job matrix and merges the blobs in a final job. This workflow is the one in the official documentation, with a few comments added. It was not run: act is not installed on the machine used for this page, and a real run needs a repository on GitHub and the application under test reachable from the runner (for example through the webServer option or a service container).

name: Playwright Tests
on:
  push:
    branches: [ main, master ]
  pull_request:
    branches: [ main, master ]
jobs:
  playwright-tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false          # let every shard finish, so the merged report is complete
      matrix:
        shardIndex: [1, 2, 3, 4]
        shardTotal: [4]
    steps:
    - uses: actions/checkout@v7
    - uses: actions/setup-node@v7
      with:
        node-version: lts/*
    - name: Install dependencies
      run: npm ci
    - name: Install Playwright browsers
      run: npx playwright install --with-deps
    - name: Run Playwright tests
      run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
    - name: Upload blob report to GitHub Actions Artifacts
      if: ${{ !cancelled() }}
      uses: actions/upload-artifact@v7
      with:
        name: blob-report-${{ matrix.shardIndex }}
        path: blob-report
        retention-days: 1

  merge-reports:
    # Merge reports after playwright-tests, even if some shards have failed
    if: ${{ !cancelled() }}
    needs: [playwright-tests]
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v7
    - uses: actions/setup-node@v7
      with:
        node-version: lts/*
    - name: Install dependencies
      run: npm ci
    - name: Download blob reports from GitHub Actions Artifacts
      uses: actions/download-artifact@v8
      with:
        path: all-blob-reports
        pattern: blob-report-*
        merge-multiple: true
    - name: Merge into HTML Report
      run: npx playwright merge-reports --reporter html ./all-blob-reports
    - name: Upload HTML report
      uses: actions/upload-artifact@v7
      with:
        name: html-report--attempt-${{ github.run_attempt }}
        path: playwright-report
        retention-days: 14

The workflow depends on the config using the blob reporter on CI (reporter: process.env.CI ? 'blob' : 'html'). The fail-fast: false line matters: with the default true, one failing shard cancels the others and the merged report is incomplete. Other CI systems follow the same three steps: run --shard=i/n in parallel jobs, collect the blob-report directories, and run merge-reports once. A complete discussion of pipeline design is in Running in CI/CD.

Common pitfalls

  • Sharing state through variables or the database. Module variables are per worker and per file load; a database row is shared by everyone. Tests that pass with --workers=1 and fail in parallel are almost always sharing something.

  • Calling a global reset or seed from inside a test. See the reset trap above: it affects the tests running on other workers, including their sessions.

  • Assuming test order across files. Use --shuffle regularly to detect it.

  • Counting global data. Asserting "there are 24 books" or "3 reviews" breaks when another test adds one. Filter by a unique marker.

  • Using serial mode to avoid isolation work. It makes failures contagious and retries expensive.

  • Retries that hide problems. Review the flaky list in every report and consider --fail-on-flaky-tests on a schedule.

  • Different shards using different configuration. Keep the command line identical except for --shard.

  • Uneven shards without fullyParallel. A single huge spec file makes one shard slow.

  • Overcommitting a small CI machine. Too many workers cause timeouts that look like flakiness; lower workers.

  • Forgetting to merge reports. With the blob reporter, nothing readable is produced on a shard until merge-reports runs.

  • Local versus CI. Locally you usually want the default workers, no retries and an html or list reporter; on CI limited workers, one or two retries, a trace on first retry, and a blob or github reporter. Headed runs do not change the scheduling, but each worker opens its own window.

What changed recently

  • 1.20: fullyParallel and --fully-parallel.

  • 1.27: workers accepts a percentage such as 20%.

  • 1.37: the blob reporter and npx playwright merge-reports.

  • 1.44: --last-failed.

  • 1.45: --fail-on-flaky-tests.

  • 1.46: --only-changed.

  • 1.52: per-project workers; the failOnFlakyTests configuration option.

  • 1.56: --test-list and --test-list-invert.

  • 1.57: tag in the configuration, used when merging reports from several environments.

  • 1.60: workers: 0 and negative values are rejected.

  • 1.61: fullConfig.failOnFlakyTests for reporters.

  • 1.62: retryStrategy (immediate or isolated).

  • 1.63: test locks ({ lock: 'name' }) on tests and groups.

  • 1.64: --shuffle, and the lock option of test.describe.configure().