How to Speed Up Playwright Tests
Playwright tests slow in CI? Parallelize with workers and sharding, reuse auth state, cut waits and tracing overhead. A practical 4-pillar speed guide.
BuildPulse Team
May 15, 2026

If your Playwright experience was like mine, it started out great. Tests ran quick, issues were caught early, feedback was fast. Then the suite grew, the team grew, and suddenly CI pipelines were sluggish, pull requests sat waiting on test results, and everyone was frustrated at how long it took to merge and ship.
Fortunately, Playwright gives you a lot of levers. I've distilled what works into four pillars, updated for how Playwright works in 2026:
- Parallelization: use every core and every machine you're paying for.
- Minimization: stop doing work that doesn't validate anything.
- Optimization: make each test do its job faster.
- Stabilization: flaky tests are slow tests, because retries are pure waste.
Parallelization
Turn on Full Parallelism
Playwright parallelizes across files by default, but tests within a file run sequentially unless you say otherwise. For most suites you want:
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 4 : undefined, // match your CI runner's cores
})
More workers mean faster wall-clock time up to your CPU/memory limit. On CI, set workers to the runner's real core count; oversubscribing slows you down.

With fullyParallel and 4 workers, 12 browser tests across 4 spec files complete in 6 seconds; the list reporter shows tests from different files interleaving as workers pick them up.
Shard Across Machines (Built In)
Once one machine is saturated, split the suite across several. Playwright shards natively:
npx playwright test --shard=1/4
With a GitHub Actions matrix, emit blob reports per shard and merge them into one HTML report:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
# later job:
- run: npx playwright merge-reports --reporter=html ./all-blob-reports

Shard 1 of 4 takes 3 of the 12 tests and finishes in 2.2s; four shards running side by side turn a 6s suite into a 2s wait, and the ratio grows with suite size.
Reuse Browser Contexts, Not Browsers
Launching a browser is expensive; creating a context is nearly free and still gives you a clean session:
const context = await browser.newContext()
const page = await context.newPage()
Playwright's test runner already does this per test. The rule matters most in custom fixtures and setup scripts: never launch a new browser when a new context will do.
Minimization
Run Only What Changed on PR Builds
For pull requests, you often don't need the full suite on every push:
npx playwright test --only-changed=origin/main
Playwright analyzes which test files are affected by your diff. Keep full runs for merges to main.

After editing one spec file, --only-changed runs just its 3 tests instead of all 12.
Skip Artifacts You Won't Look At
Video, screenshots, and traces on every test are silent time sinks. Capture them only when something fails:
use: {
trace: 'on-first-retry',
video: 'on-first-retry',
screenshot: 'only-on-failure',
},
Disable CSS Animations Globally
Animation waits add up on every interaction:
await context.addInitScript(() => {
const style = document.createElement('style')
style.textContent = '* { transition: none !important; animation: none !important; }'
document.head.appendChild(style)
})
Use API Calls Instead of UI Interactions for Setup
Clicking through the UI to create test data is the slowest possible way to do it:
test('setup via API', async ({ request }) => {
await request.post('/api/createUser', { data: { name: 'John' } })
// now test the UI behavior you actually care about
})
Reuse Your Dev Server Locally
If you use the webServer option, avoid a cold server boot on every local run:
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
Optimization
Authenticate Once with a Setup Project
Logging in per test is the classic Playwright time sink. The current recommended pattern is a setup project that signs in once and saves storage state; every other project depends on it:
// playwright.config.ts
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
// auth.setup.ts
import { test as setup } from '@playwright/test'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill(process.env.TEST_USER!)
await page.getByLabel('Password').fill(process.env.TEST_PASS!)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL('/dashboard')
await page.context().storageState({ path: 'playwright/.auth/user.json' })
})
One login for the whole run instead of one per test.
Use Fast, Resilient Locators
Avoid XPath: it requires full DOM traversal and breaks on markup changes. Prefer role-based and test-id locators:
await page.getByRole('button', { name: 'Submit' }).click()
await page.getByTestId('submit-button').click()
Kill Static Waits
await page.waitForTimeout(5000) is a guaranteed 5 seconds whether the app needs it or not. Playwright's locator assertions auto-wait, so lean on them:
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
If you're reaching for a timeout, there's almost always a condition you should be waiting on instead.
Stabilization
Flaky tests are a speed problem, not just a trust problem: every retry doubles that test's cost, and every "re-run CI" click costs the whole pipeline.
Configure Retries, Then Actually Fix the Flakes
export default defineConfig({
retries: process.env.CI ? 2 : 0,
})
Retries keep the pipeline green while you fix root causes, but they hide real bugs if nobody watches which tests keep retrying. Track your retry rate; a rising one means the suite is rotting.

What flakiness looks like in the report: a race against a late-mounting banner fails attempt one (with the full call log), passes on retry #1, and lands in the summary as 1 flaky instead of a red build. That marker is your signal to fix the race, not celebrate the green.
Retry Blocks Within a Test with toPass
For interactions that race against app readiness, wrap the block instead of padding it with timeouts:
await expect(async () => {
await page.getByRole('button', { name: 'Click me' }).click()
await expect(page.getByRole('heading', { name: 'Nice click' })).toBeVisible()
}).toPass()
If the JavaScript handler hasn't attached yet, the inner block fails fast and retries until it passes or the test times out. No fixed sleeps involved.
Find the Flaky Tests Before They Find You
At suite scale you need data, not vibes: which tests flake, how often, and how much time they burn. That's exactly what BuildPulse Flaky Tests does: detection, ranking by cost, and one-click quarantine so a known-flaky test stops blocking merges. For the hands-on playbook, see our ultimate guide to fixing flaky tests.
Full Speed Ahead
These investments compound as the team and codebase grow. Beyond raw speed, they improve developer experience, cut CI costs, and help you ship faster.
And when you've squeezed the config dry, the remaining lever is hardware: BuildPulse Runners run your GitHub Actions jobs 2x faster at half the cost, with no tooling changes.
If you're tired of slow, buggy CI holding your team back, you can try BuildPulse for free.
Stop guessing which tests you can trust
BuildPulse finds your flaky tests, ranks them by the engineering time they cost, and lets you quarantine the worst in one click. See results on your first build.
Free to start · No credit card required · Setup is a single CI step
Related posts