DEV Community

TestDino
TestDino

Posted on

Playwright parallel execution: the 3-layer model your CI config is probably ignoring

You've set workers: 4 in your Playwright config. You've pushed to CI. Tests are... slower? Or flakier? Or both?

Here's what's actually happening. Playwright doesn't have one parallelism dial. It has three. Most CI setups only touch the first one and leave the other two running at defaults that don't match the actual runner. Let's fix that.

Playwright has 3 parallelism layers, not 1

Most teams only know about one.

Layer 1 is file-level parallelism. The moment you have workers > 1, Playwright automatically distributes test files across workers. This one is already on. You didn't configure it and you don't need to.

Layer 2 is test-level parallelism. This is what fullyParallel controls. By default, all tests inside a single file run on the same worker, in order. fullyParallel: true changes that. More on this below.

Layer 3 is machine-level parallelism, which is Playwright sharding. This splits your entire suite across separate CI machines. You should only reach for this after tuning layers 1 and 2 first.

Getting workers right in CI

Every worker is its own OS process with its own browser. More workers means more browsers, which means more RAM and CPU, not just more speed.

The mistake most teams make is hardcoding a worker count:

// fragile, assumes the runner always has 4 cores
workers: 4
Enter fullscreen mode Exit fullscreen mode

A better approach uses the machine's actual core count:

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

export default defineConfig({
  workers: process.env.CI
    ? Math.min(4, os.cpus().length)
    : undefined,
  maxFailures: process.env.CI ? 10 : undefined,
  reportSlowTests: {
    max: 5,
    threshold: 15000,
  },
});
Enter fullscreen mode Exit fullscreen mode

maxFailures is worth setting. When CI breaks, it usually breaks across many tests for the same root cause. Running all 400 of them once 10 have already failed just wastes pipeline time without teaching you anything new.

On worker count, a simple starting point:

  • Under 50 tests on any runner: leave workers as undefined and use the default
  • 50 to 200 tests on a 2 vCPU runner: use 2 workers
  • 50 to 200 tests on a 4 vCPU runner: use 3 to 4 workers
  • Over 200 tests: tune workers first, then think about sharding
  • Flakiness appearing after you bumped workers: reduce by 1 or 2 and recheck

The rule that actually matters: a 2 vCPU machine running 4 workers is launching 4 Chrome instances on 2 cores. That's where the timeouts come from.

What fullyParallel actually does

This is the most misunderstood option in Playwright's parallel execution setup.

fullyParallel: true changes the unit of work from a file to an individual test. A file with 10 tests normally creates 1 task for the scheduler. With fullyParallel: true, that same file creates 10 tasks. Any available worker can pick up any of them.

// playwright.config.ts
export default defineConfig({
  fullyParallel: true,
  workers: process.env.CI ? 2 : undefined,
});
Enter fullscreen mode Exit fullscreen mode

You can also enable it per project instead of globally:

projects: [
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'] },
    fullyParallel: true,
  },
]
Enter fullscreen mode Exit fullscreen mode

One thing that surprises most teams: fullyParallel: true does not make Chrome and Firefox projects run at the same time. The scheduler assigns tasks to workers without caring which project they belong to.

Also worth knowing: if you're using Playwright sharding, fullyParallel: true is recommended. Without it, shards split by file count. With it, they split by individual test count, which balances work more evenly across machines.

If flaky test failures appear after turning on fullyParallel, the cause is almost always shared state between tests in the same file. Fix isolation first, then re-enable.

Per-file execution modes

The global fullyParallel setting isn't all-or-nothing. test.describe.configure() lets you override it on a file by file basis.

Parallel mode forces test-level parallelism on a specific file, even if fullyParallel is off globally:

// tests/product-search.spec.ts
import { test } from '@playwright/test';

test.describe.configure({ mode: 'parallel' });

test('search by name', async ({ page }) => {
  await page.goto('https://storedemo.testdino.com/products');
  await page.getByTestId('header-menu-all-products').click();
});

test('filter by category', async ({ page }) => {
  await page.goto('https://storedemo.testdino.com/products');
});
Enter fullscreen mode Exit fullscreen mode

Watch the beforeAll behavior here. In parallel mode, beforeAllruns separately for each test, not once for the whole file. If it opens a database connection, you'll open one per test. Use beforeEachinstead, or move setup into a fixture.

Serial mode runs tests in order. If one fails, the rest skip:

// tests/checkout-flow.spec.ts
test.describe.configure({ mode: 'serial' });

test('register account', async ({ page }) => {
  await page.goto('https://storedemo.testdino.com/');
  await page.getByTestId('login-signup-link').click();
  await page.getByTestId('signup-email-input').fill(`user.${Date.now()}@test.com`);
});

test('login with new account', async ({ page }) => {
  // skips if registration fails
});
Enter fullscreen mode Exit fullscreen mode

Serial is a last resort. If test 2 can't run without test 1, they're probably one test.

Default mode lets individual files opt out of fullyParallel: true when it's set globally:

test.describe('checkout flow', () => {
  test.describe.configure({ mode: 'default' });
  test('add to cart', async ({ page }) => { /* ... */ });
  test('complete checkout', async ({ page }) => { /* ... */ });
});
Enter fullscreen mode Exit fullscreen mode

Isolation patterns that hold up in CI

Parallel end-to-end tests break when they share state. Two workers hitting the same user account, same database row, same cookie. Three patterns that handle this without overcomplicating your setup:

Timestamp data is the cheapest option and covers most cases:

const email = `testuser.${Date.now()}@mailtest.com`;
Enter fullscreen mode Exit fullscreen mode

parallelIndex for slot-based resources gives each worker a stable ID from 0 to workers - 1. It survives worker restarts, which workerIndex doesn't:

test('slot-based user', async ({ page }, testInfo) => {
  const user = `qa-slot-${testInfo.parallelIndex}@company.com`;
});
Enter fullscreen mode Exit fullscreen mode

The page fixture handles most isolation automatically. Playwright creates a separate browser context per test when you use page. Most tests don't need anything extra because of this.

TestDino's reporting dashboard tracks which tests fail most often across parallel runs. When you're tuning worker counts, it shows you quickly whether a config change helped or just added more noise.

Running in Docker?

Add --disable-dev-shm-usage to your browser launch args before anything else. Docker's default shared memory is 64MB. Chrome needs more. Without this flag, crashes show up as network timeouts and you'll spend time looking in the wrong place.


The full breakdown with CI YAML examples, shard balancing, slow test detection, andworkerIndexvsparallelIndex explained properly:

Playwright Parallel Execution: Workers & fullyParallel Guide

Top comments (0)