all posts

Sharding a Playwright suite that has outgrown one machine

Ajay Kumar··8 min read

Browser tests have a cost structure that unit tests don't. Each one runs a real browser: a Chromium process tree using several hundred megabytes, rendering, executing JavaScript, waiting on network. Six hundred of those in sequence is forty minutes, and forty minutes is long enough that people stop running them before merging.

Parallelism is the answer, but it comes in two forms that get conflated, and knowing which one you need saves you from optimising the wrong axis.

Workers and shards are different things

Workers are parallel processes on one machine. Shards are subsets of the suite split across machines.

// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 4 : undefined,   // processes on THIS machine
  fullyParallel: true,                       // parallelise within files too
});
# Shards: each command runs on a DIFFERENT machine
npx playwright test --shard=1/8
npx playwright test --shard=2/8
# ... 8 machines, each running 4 workers = 32 concurrent browsers

Workers are limited by the machine — memory first, then CPU. Shards are limited by how many machines you're willing to run. The practical sequence is: raise workers until the machine is saturated, then add shards.

Where workers stop helping

Memory, almost always, and the arithmetic is unforgiving.

Chromium per worker      ~300-500 MB   (more with heavy pages or video)
Node test runner process  ~150 MB
Your app under test       ~500 MB
                          ------------
8 workers                 ~4.5 GB       before the OS has anything

Exceed available memory and the failure is not a clean error. The kernel's OOM killer takes whichever process it likes, which is usually a browser mid-test, and you get a test that fails with a target-closed error. That looks exactly like a flaky test, so teams add retries, which makes the suite slower, which makes them add workers, which makes it worse.

If your flakiness rate rises when you increase workers, suspect memory before suspecting your tests. 'Target closed' and 'browser has disconnected' under parallelism are the signature of an OOM kill, not a race condition in your code.

The rule of thumb that holds: roughly one worker per 1 GB of available memory, capped at the core count. Eight workers wants 8 to 12 GB. If you have 4 GB, running four workers is slower and less reliable than running two.

The real blocker is shared state

The technical side of parallelism is a config flag. The reason suites can't use it is almost always that the tests share a backend.

  • Two tests log in as the same user, and one logs out while the other is mid-flow.
  • One test creates a record the next test's list assertion counts.
  • A test changes an account setting or feature flag that others read.
  • Tests assert on 'the most recent order' and another shard just created one.
  • A test resets the database between runs, which is fine sequentially and catastrophic in parallel.

The usual mitigation is per-test unique data: every test creates its own user with a random email and operates only on records it made. That works and it's the right first step, but it doesn't cover global state — feature flags, admin settings, anything genuinely singular — and it makes tests longer and noisier to read.

The stronger version is a private backend per shard. Not just a database, but the whole stack: app, database, any dependent services, snapshot-restored so it starts in about a second rather than being built.

// Each shard gets its own full environment — restored, not built
import { Sandbox } from "@pandastack/sdk";

export default async function globalSetup() {
  const env = await Sandbox.fork(process.env.STACK_SNAPSHOT);
  process.env.BASE_URL = env.url;        // tests point here
  process.env.SANDBOX_ID = env.id;
}

export async function globalTeardown() {
  await Sandbox.delete(process.env.SANDBOX_ID);   // no cleanup, just gone
}

This removes the entire class of cross-shard interference, and it removes cleanup logic — the environment is destroyed rather than reset, so there's no truncation script that can be incomplete. Tests can also do genuinely destructive things: delete all users, fill the disk, change a global setting, because nothing else is watching.

Merging the results

Eight shards produce eight reports, which is eight places to look for the one failure. Playwright's blob reporter exists for this and it's a two-line fix that people skip.

# On each shard
npx playwright test --shard=$N/8 --reporter=blob

# After all shards finish, in a merge job
npx playwright merge-reports --reporter=html ./all-blob-reports

# One HTML report: full timeline, traces, and the flaky-test view intact

Balancing shards

Playwright splits by test count, not duration. If one shard happens to collect the slow tests, your suite takes as long as that shard and the other seven sit idle. The fix is to feed durations back in.

# Playwright can use a timing report from a previous run to balance shards
npx playwright test --shard=$N/8 \
  --reporter=blob \
  --last-failed=false \
  --pass-with-no-tests

# Store the JSON report as a CI artifact and restore it on the next run —
# without it, sharding is a coin flip on whether the split is even.

Even without tooling, watch your per-shard durations. A spread of 4 to 11 minutes across eight shards means you're paying for eleven and using an average of six.

Do these first

Sharding costs money and coordination. Several cheaper things often produce a bigger improvement.

  1. Stop logging in through the UI. Authenticate once in global setup, save the storage state, and reuse it. This alone frequently removes several seconds from every test in the suite.
  2. Seed via the API, not the interface. Creating test data by clicking through forms is slow and tests the same forms repeatedly for no additional coverage.
  3. Delete tests that assert the same thing. Large E2E suites accumulate near-duplicates, and each one costs you on every run forever.
  4. Move what you can down the pyramid. If an assertion can be made at the component or API level, it will run in milliseconds instead of seconds.
  5. Fix the flaky tests rather than retrying them. A test retried twice costs three times as long when it fails, and a retry that passes is a test that told you nothing.

Then shard. The end state that works well is a handful of shards, each with a private restored environment, results merged into one report, and durations fed back for balance. That combination scales to a suite of thousands and keeps the feedback loop inside the window where people will actually wait for it.

Frequently asked questions

What is the difference between Playwright workers and shards?

Workers are parallel processes on a single machine, configured with the workers option. Shards split the test suite across multiple machines, using --shard=N/M where each machine runs a different subset. They compose: eight shards each running four workers gives you 32 concurrent browsers. Workers are limited by the machine's memory and cores, shards by how many machines you are willing to pay for. The usual sequence is to raise workers until one machine is saturated, then add shards.

How many Playwright workers should I run?

Roughly one per gigabyte of available memory, capped at the core count. Each worker runs a Chromium process tree using 300 to 500 megabytes, plus the Node runner and whatever your application under test consumes — eight workers realistically wants 8 to 12 GB. Exceeding available memory does not produce a clean error: the kernel's OOM killer terminates a browser mid-test, which surfaces as a target-closed failure indistinguishable from flakiness. On a 4 GB machine, two workers is both faster and more reliable than four.

Why do my browser tests become flaky when I run them in parallel?

Two likely causes. The first is memory: 'target closed' and 'browser has disconnected' errors appearing only under parallelism are the signature of an OOM kill rather than a race in your code, so check memory before rewriting tests. The second is shared state — two tests logging in as the same user, one creating a record another counts, or a test resetting the database while others run. Per-test unique data helps, but a private backend per shard removes the whole class of problem including global state like feature flags.

How do I combine test reports from multiple shards?

Use Playwright's blob reporter on each shard and merge afterwards. Run the shards with --reporter=blob, collect the outputs as CI artifacts, then run playwright merge-reports --reporter=html against the collected directory in a final job. You get one HTML report with the full timeline, traces and flaky-test view intact, rather than eight separate reports to search through for the one failure. It is a two-line change that most teams skip until the first painful debugging session.

What should I do before sharding my E2E suite?

Several cheaper things usually help more. Stop logging in through the UI — authenticate once in global setup and reuse the saved storage state, which often removes seconds from every test. Seed data through the API rather than by clicking forms. Delete near-duplicate tests, which large suites accumulate and which cost you on every run forever. Move assertions down to the component or API level where possible. And fix flaky tests rather than retrying them: a retried test costs triple when it fails and tells you nothing when it passes.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.