Playwright's default worker count is half the logical CPUs on the machine, so a 2-core CI runner gets 1 worker and runs tests sequentially by default. fullyParallel: true runs every test in parallel regardless of file, but requires that each test own its data entirely: two tests modifying the same database row with a hardcoded ID will race and fail intermittently. This article covers worker configuration, the isolation requirements for fullyParallel, test.describe.serial() for intentionally sequential groups, and sharding with --shard to split a large suite across GitHub Actions matrix machines.

How Playwright runs tests by default

Before you change any configuration, it helps to understand what Playwright actually does out of the box.

By default, Playwright runs test files in parallel and tests within a single file sequentially. Each worker process picks up a test file, runs every test in that file from top to bottom, and then picks up the next available file. Multiple workers run simultaneously, each handling a different file.

The default worker count is half the number of logical CPUs on the machine. On a typical developer laptop with 8 cores, you get 4 workers. On a CI runner with 2 cores, you get 1 worker, meaning tests run sequentially unless you override this.

// playwright.config.ts — default behavior (no changes needed to get this)
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  // workers defaults to half of logical CPUs
  // tests within a file run sequentially by default
});

This default behavior is a reasonable starting point. Tests in the same file often share setup state implicitly (same page object, same login flow, same data fixtures), and sequential execution within a file keeps those safe. Separate files run simultaneously, which gives you speedup without requiring perfect isolation between every single test.

Configuring workers in playwright.config.ts

The workers option controls how many parallel processes run your tests. You can set it as an absolute number or as a percentage of available CPUs.

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

export default defineConfig({
  testDir: './tests',
  
  // Absolute number — always use exactly 4 workers
  workers: 4,
  
  // Or as a percentage of available CPUs
  // workers: '75%',
  
  // Or different values per environment
  // workers: process.env.CI ? 2 : '50%',
});

The percentage form is useful when you want the same config to work on different machines. '50%' on an 8-core machine gives 4 workers; on a 2-core CI runner it gives 1. You're telling Playwright "use half the machine" rather than hardcoding a number.

You can also override workers from the command line without touching the config:

# Run with a specific number of workers
npx playwright test --workers=4

# Force sequential execution (1 worker)
npx playwright test --workers=1

--workers=1 is useful for debugging test isolation problems. If tests pass with 1 worker but fail with 4, you have a shared state issue somewhere.
When debugging a flaky test, always run with --workers=1 first. If the test passes consistently, you're dealing with a race condition or shared state between tests rather than a bug in the test itself.

fullyParallel mode: run everything at once

The standard mode runs files in parallel but tests within a file sequentially. fullyParallel: true removes that restriction. Every individual test runs in parallel regardless of which file it's in.

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,  // All tests run in parallel
  workers: 4,
});

This can dramatically cut runtime for large suites. A suite with 100 tests spread across 10 files (if each test takes 2 seconds) drops from 20 seconds to about 5 seconds with 4 workers in fullyParallel mode.

The catch: fullyParallel requires that every test is fully isolated. No shared browser context, no shared login state that gets mutated, no tests that assume they run in a specific order. If your tests write to a shared database record and both tests try to modify the same row simultaneously, you'll get intermittent failures that are hard to reproduce.

Before enabling fullyParallel, audit your test suite for:

  • Tests that create data with hardcoded IDs (user ID 123 gets created by test A and deleted by test B)
  • Tests that rely on a previous test having run first
  • Page-level state that isn't reset between tests

If your tests use test.beforeEach to log in fresh and work with unique data, fullyParallel is safe to enable. If they share a pre-authenticated browser context stored in a module-level variable, they're not ready for it.

test.describe.serial() for intentionally sequential tests

Sometimes a group of tests genuinely needs to run in order. A checkout flow where test 1 adds an item to cart, test 2 applies a coupon, and test 3 completes the purchase: these tests are inherently sequential. test.describe.serial() is the right tool for this.

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

test.describe.serial('checkout flow', () => {
  test('add item to cart', async ({ page }) => {
    await page.goto('/products/widget-pro');
    await page.getByRole('button', { name: 'Add to cart' }).click();
    await expect(page.getByTestId('cart-count')).toHaveText('1');
  });

  test('apply coupon code', async ({ page }) => {
    await page.goto('/cart');
    await page.getByPlaceholder('Coupon code').fill('SAVE10');
    await page.getByRole('button', { name: 'Apply' }).click();
    await expect(page.getByTestId('discount-amount')).toBeVisible();
  });

  test('complete purchase', async ({ page }) => {
    await page.goto('/checkout');
    await page.getByLabel('Card number').fill('4242424242424242');
    await page.getByLabel('Expiry').fill('12/26');
    await page.getByLabel('CVC').fill('123');
    await page.getByRole('button', { name: 'Pay now' }).click();
    await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
  });
});

With test.describe.serial(), Playwright runs these three tests in order and stops if any of them fails. There's no point running "complete purchase" if "add item to cart" failed.

Use serial sparingly. Every serial block is a section of your suite that can't be parallelized. If you find yourself adding serial to most describe blocks, the real fix is making your tests independent of each other: generating unique test data, using isolated browser contexts, cleaning up after each test.

Test isolation: the prerequisite for parallel execution

Parallel execution amplifies isolation problems. A test that works fine alone will fail unpredictably when it runs at the same time as another test that touches the same data or state.

The core principle: each test must own its data and not depend on anything left over from another test.

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

// BAD: shared state between tests
let userId: number;

test('creates a user', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { name: 'Alice', email: 'alice@example.com' }
  });
  userId = (await response.json()).id;  // shared variable — race condition waiting to happen
});

test('updates the user', async ({ request }) => {
  // If the previous test hasn't run yet (or ran in a different worker), userId is undefined
  await request.put(`/api/users/${userId}`, {
    data: { name: 'Alice Updated' }
  });
});

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

// GOOD: each test creates and owns its own data
test('updates a user', async ({ request }) => {
  // Create the user within this test
  const createResponse = await request.post('/api/users', {
    data: { 
      name: 'Alice', 
      email: `alice-${Date.now()}@example.com`  // unique email prevents conflicts
    }
  });
  const { id } = await createResponse.json();

  // Now update it — we own this user
  const updateResponse = await request.put(`/api/users/${id}`, {
    data: { name: 'Alice Updated' }
  });
  expect(updateResponse.status()).toBe(200);
});

For UI tests, Playwright's page fixture gives each test its own browser context by default. That part is handled for you. The isolation issues usually come from test data in a shared database, not from browser state.

Using test.beforeAll to create shared data and test.afterAll to clean it up seems efficient, but it creates hidden dependencies between tests. If one test modifies the shared data, subsequent tests break. Prefer test.beforeEach with per-test data, even if it's slower.

Sharding: splitting your suite across CI machines

Workers parallelize tests within one machine. Sharding splits the test suite across multiple machines. These two mechanisms are independent and complementary. You can use both together.

The --shard flag takes a current/total argument:

# Run shard 1 of 3 (first third of tests)
npx playwright test --shard=1/3

# Run shard 2 of 3
npx playwright test --shard=2/3

# Run shard 3 of 3
npx playwright test --shard=3/3

Playwright distributes test files evenly across shards. With 30 test files and 3 shards, each shard gets 10 files. The distribution is deterministic. You'll get the same files in the same shard on every run.

You can combine sharding with workers. Each shard runs with multiple workers, so you get parallelism both within the shard and across shards:

# Each shard uses 4 workers internally
npx playwright test --shard=1/3 --workers=4

Sharding is mainly valuable in CI, where you can provision multiple machines for a single pipeline run.

GitHub Actions matrix for parallel sharding

GitHub Actions supports matrix builds, running a job multiple times with different inputs. Combined with Playwright sharding, this is how you split a slow test suite across parallel machines.

# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run tests (shard ${{ matrix.shard }}/4)
        run: npx playwright test --shard=${{ matrix.shard }}/4
        env:
          BASE_URL: ${{ vars.BASE_URL }}
          TEST_USER: ${{ secrets.TEST_USER }}
          TEST_PASS: ${{ secrets.TEST_PASS }}

      - name: Upload shard report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report-shard-${{ matrix.shard }}
          path: playwright-report/
          retention-days: 7

fail-fast: false is important here. By default, if one matrix job fails, GitHub cancels the remaining jobs. With fail-fast: false, all shards run to completion even if one fails. You get the full picture of what passed and what failed across the entire suite.

The install chromium only argument on the browser install step saves time. If you're running cross-browser tests, change this to --with-deps without specifying a browser to install all three.

If you want to merge the shard reports into a single report, add a merge job after the matrix completes:

  merge-reports:
    needs: test
    runs-on: ubuntu-latest
    if: always()
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      
      - run: npm ci
      
      - name: Download all shard reports
        uses: actions/download-artifact@v4
        with:
          pattern: playwright-report-shard-*
          path: all-reports/
          merge-multiple: false
      
      - name: Merge reports
        run: npx playwright merge-reports --reporter html ./all-reports/*/
      
      - name: Upload merged report
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-merged
          path: playwright-report/
          retention-days: 14

This gives you a single downloadable HTML report covering all shards, with all test results in one place.

Measuring speedup and finding the right worker count

More workers doesn't always mean faster tests. Adding workers increases resource contention: more CPU, more memory, more browser processes competing for the same machine. At some point, adding another worker slows things down because the machine is overloaded.

The rough rule: workers = number of logical CPUs works well for CPU-bound workloads. For browser tests, which are mostly waiting on network and rendering, you can often push higher. Workers = 2x CPUs is a reasonable experiment.

Here's how to measure:

# Baseline: 1 worker (sequential)
npx playwright test --workers=1 2>&1 | grep "passed\|failed\|Duration"

# Try 2 workers
npx playwright test --workers=2 2>&1 | grep "passed\|failed\|Duration"

# Try 4 workers
npx playwright test --workers=4 2>&1 | grep "passed\|failed\|Duration"

# Try 8 workers
npx playwright test --workers=8 2>&1 | grep "passed\|failed\|Duration"

Plot the results. You're looking for the inflection point where adding workers stops reducing time. That's your optimal count for that machine.

For CI specifically, check what resources your runner provides. GitHub Actions ubuntu-latest runners have 4 vCPUs and 16 GB RAM. With Playwright browser tests, 4 workers is a solid starting point. You might get 5-10% faster with more, but you'll start seeing memory pressure at 8+ workers on that runner.

A practical formula for calculating sharding benefit:

Time with N shards ≈ (total test time on 1 machine) / N  +  fixed overhead per shard

Fixed overhead = checkout + npm ci + browser install ≈ 60-90 seconds

If your suite takes 10 minutes with 1 machine, 4 shards brings it to roughly 2.5 minutes + 90 seconds overhead = ~4 minutes. That's a real win. If your suite takes 3 minutes, 4 shards brings it to 45 seconds + 90 seconds = 2.5 minutes. Not worth the added complexity.

The sharding threshold: start considering sharding when your suite consistently takes more than 5 minutes on a single CI machine.

// playwright.config.ts — production-ready parallel config
import { defineConfig } from '@playwright/test';

const isCI = !!process.env.CI;

export default defineConfig({
  testDir: './tests',
  
  // Use full parallelism — requires isolated tests
  fullyParallel: true,
  
  // Tune workers for the environment
  workers: isCI ? 4 : '50%',
  
  // Retries only in CI — don't mask failures locally
  retries: isCI ? 1 : 0,
  
  // Timeout per test
  timeout: 30_000,

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
  },
});

This config is self-documenting about the tradeoffs: full parallelism everywhere, CI gets fixed workers while local gets a percentage, retries only in CI so you're not hiding problems during development.

FAQ

My tests pass locally but fail when run in parallel. Where do I start?

Run with --workers=1 and confirm tests pass. Then try --workers=2. If that fails, you have a shared state problem between exactly two tests that are now running simultaneously. Check for module-level variables, shared database rows with hardcoded IDs, or any state that persists across tests. The fix is almost always moving setup into beforeEach and using unique identifiers for test data.

How does Playwright decide which tests go in which shard?

Playwright sorts test files alphabetically and distributes them round-robin across shards. You don't control the assignment directly. If one shard consistently takes much longer than others (one shard has all the slow tests), consider splitting large test files into smaller ones so the distribution is more even.

Can I run specific tags or grep patterns per shard instead of using --shard?

Yes, and some teams prefer this for predictability: --grep @checkout on one machine and --grep @catalog on another. The downside is manual maintenance: you have to update the grep patterns as you add tests. --shard is automatic and maintenance-free.

Does fullyParallel: true affect the order test results appear in the report?

Yes. With fullyParallel, results appear as tests complete, not in file order. The HTML report still groups by file and test, so readability isn't affected. The terminal output just looks more interleaved.

What's the difference between workers in the config and --shard on the command line? workers controls parallelism within one process on one machine. --shard splits the suite across multiple invocations, typically on different machines. They operate at different levels and work together. Each shard can have multiple workers. → See also: Debugging Flaky Tests: A Practical Guide | CI/CD for QA: GitHub Actions, Jenkins, and GitLab Compared | GitHub Actions for Playwright Tests: The Complete Setup (2026) | Test Isolation: Why Each Playwright Test Should Be Stateless | Playwright Config File Explained: Every Option You Need to Know