← Back to ArticlesQA Automation

Scaling Your Playwright Test Suite: Parallel Execution, CI Integration, and Flaky Tests

May 23, 2026

Last year, we migrated a suite of more than 800 end-to-end tests from Cypress to Playwright. The motivation was simple: cross-browser support, better network tracing, and getting rid of the notorious headaches we had with iframes. Everything looked great when we started trying out Playwright 1.43. But once the test count climbed past 200, we learned the hard way just how critical parallel execution configuration really is.

This article isn't going to be a theoretical Playwright guide, you can already read the docs for that. Instead, I'll share the concrete problems I ran into on a real project and how I got past them.

The Fine Print of Parallel Execution: Worker Count Isn't Magic

Playwright's default parallel execution behavior looks pretty clever at first glance. You set workers in playwright.config.ts and that's it, right? No.

In our case, on CI (we were using a 4-core runner on GitHub Actions), half the tests were timing out with the default workers: undefined setting. The problem wasn't the worker count, it was that the tests were writing to a shared test database concurrently. Each worker opens an isolated browser context, but that buys you nothing if your test data isn't isolated too.

// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 4 : undefined,
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

The real fix was creating separate test data per worker. We did this with the workerIndex fixture. Playwright's test.extend() API is genuinely powerful here:

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

export const test = base.extend<{}, { workerDatabase: string }>({
  workerDatabase: [
    async ({ }, use, workerInfo) => {
      const dbName = `test_db_worker_${workerInfo.workerIndex}`;
      await setupDatabase(dbName);
      await use(dbName);
      await teardownDatabase(dbName);
    },
    { scope: 'worker' },
  ],
});

After moving to this structure, our test run with 4 workers finished 40% faster and timeouts dropped to nearly zero. The benefit of parallel execution doesn't materialize without isolation.

Optimizing the CI Pipeline on GitHub Actions

When we first wired Playwright into GitHub Actions, we took the naive approach: run every test in a single job and be done with it. 800 tests, 4 workers, roughly 25 minutes. Not acceptable.

The fix was Playwright's built-in sharding feature. The --shard flag lets you split a test suite across multiple GitHub Actions jobs:

# .github/workflows/playwright.yml
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --shard=${{ matrix.shard }}/4
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report-${{ matrix.shard }}
          path: playwright-report/

That structure cut the 25-minute run down to 8 minutes. But there's a small trap here: when using actions/upload-artifact@v4, you need to upload each shard's report as a separate artifact. Otherwise multiple shards try to write to the same artifact name and you get a collision.

Another thing worth noting: using npx playwright install --with-deps chromium instead of Playwright's Docker image is sometimes faster. Especially if you're only running Chromium tests, downloading the entire browser package is wasted time.

Fighting Flaky Tests: Retries Aren't Enough

Playwright has a retries option, and it's tempting to use it. You see a flaky test, you add retries: 2, and the problem looks solved. That's an illusion.

Retries hide the symptom. You still have to face the actual problem.

The most common sources of flaky tests on our project were:

1. Animations and transitions: you weren't waiting for an animation to finish before clicking a button. Playwright's waitForLoadState('networkidle') isn't always enough here. Our fix was to disable CSS animations entirely in the test environment:

// global-setup.ts
await page.addStyleTag({
  content: `
    *, *::before, *::after {
      animation-duration: 0s !important;
      transition-duration: 0s !important;
    }
  `
});

2. Race conditions: trying to click an element before an API call finished. page.waitForResponse() is very useful here, but you need to be careful with it, matching too broad a URL pattern can catch the wrong response.

3. Selector changes: CSS class names change whenever a developer refactors code. To avoid this entirely, we made data-testid attributes mandatory. We added a rule to our code review process: any new interactive element must ship with a data-testid.

Getting retries down to zero is the ideal goal, but it isn't realistic. We settled on retries: 1, and we separately log every test that actually retries. If a test retries three times in a week, that's an alarm bell, and it goes on a dedicated review list.

Playwright Trace Viewer: The Most Valuable Debugging Tool on CI

Why did a test fail locally? You open the browser and watch it. On CI? That's where most teams suffer.

Playwright Trace Viewer genuinely solves this problem. For every failed test it produces a .zip file, and opening that file at trace.playwright.dev gives you a step-by-step replay of the test, network requests, console logs, and a screenshot at every step.

In our config we only keep this active for failed tests:

export default defineConfig({
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});

The trace: 'on' option produces a trace for every test and significantly increases storage cost. on-first-retry is a balanced choice: you only get traces for the tests that are actually causing trouble.

You also need to upload trace files as artifacts on GitHub Actions:

- uses: actions/upload-artifact@v4
  if: failure()
  with:
    name: traces-${{ matrix.shard }}
    path: test-results/
    retention-days: 7

Since setting this up, debugging time on CI has dropped considerably. Developers can now inspect the trace file themselves instead of asking me "why did this fail on CI?"

An Honest Assessment for Large Suites

Playwright, especially from 1.42 onward, is a mature tool for serious, large-scale test suites. But a few things still deserve caution.

Component testing is still evolving. There's basic support for React and Vue, but it hasn't yet reached the maturity of Cypress's component testing experience. If your team writes a lot of component-level tests, don't overlook this.

The codegen feature, npx playwright codegen, is impressive, but the selectors it produces aren't always reliable. It particularly tends to generate selectors that depend on dynamic class names. I use codegen as a starting point, then edit the selectors by hand.

But on network interception, multi-tab scenarios, mobile emulation, and authentication state management, Playwright is unmatched. Running the authentication flow once and caching the result with storageState saves meaningful time on test suites that require login:

// global-setup.ts
async function globalSetup() {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('http://localhost:3000/login');
  await page.fill('[data-testid=email]', process.env.TEST_USER_EMAIL!);
  await page.fill('[data-testid=password]', process.env.TEST_USER_PASSWORD!);
  await page.click('[data-testid=login-button]');
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
  await browser.close();
}

This approach reuses authentication state instead of logging in for every test, which noticeably shortens suite run time.

In the end, working efficiently with Playwright isn't just about learning the API. You need systematic decisions around test isolation, CI sharding, trace analysis, and data management. Making those decisions early heads off much bigger problems down the road.


Abdulkadir Akyurt is a QA Engineer and Test Automation Specialist.

Follow me on Medium for more articles · Connect on LinkedIn · Instagram · X / Twitter · Visit abdulkadirakyurt.com

More like this