Parallel browser tests fail for boring reasons: two workers create the same record, one cleanup deletes data the other still needs, or a retry lands on a half-reset environment. The fix is not “reset harder.” The fix is to make test data lifecycles explicit, deterministic, and cheap to diagnose.

A seed-and-reset loop is a repeatable workflow with three steps:

  1. Seed a known baseline once, or at the start of a CI job.
  2. Give each parallel run its own isolated data slice.
  3. Tear down only what that run owns, with logs that explain what happened when resets overlap.

That distinction matters. Seeding creates the baseline state you want available to tests. Resetting returns the environment to a safe known state after a run. Isolation prevents workers from touching the same rows, files, queues, or browser-visible records in the first place.

If you do not separate those three concerns, parallelism turns a test data problem into a debugging problem.

The workflow in one sentence

Build a single seed endpoint or script, assign every run a unique namespace, store ownership metadata on every test-created record, and make cleanup idempotent so a duplicate reset is harmless.

That gives you a parallel browser test data reset workflow that can survive retries, reruns, and worker crashes.

A practical model: baseline, namespace, ownership

The easiest way to keep this stable is to treat test data as three layers.

Layer Purpose Example Failure if missing
Baseline seed Shared reference data countries, roles, feature flags tests depend on manual setup
Run namespace Isolates one CI run ci-20261114-1842 workers collide on the same records
Ownership metadata Tracks what to delete createdByRunId, createdByWorker cleanup deletes the wrong data or nothing at all

For browser automation, this is usually better than full database snapshots for every worker. Snapshots are simple to understand, but they get expensive when the dataset is large or when tests only need a small slice of mutable data.

What to store in the namespace

A namespace can be as simple as a run ID, but it should be carried through every write path:

  • user emails: qa+ci-20261114-1842-w3@example.test
  • organization slugs: org-ci-20261114-1842
  • checkout or order references: order_ci_20261114_1842_w3
  • API headers or cookies, if your backend uses them for tenant routing

If your app supports it, make the namespace visible in the UI so a failed test can be traced back without querying logs first.

Seed once, then fan out

A deterministic setup starts before the browser opens.

1. Create a seed job

Run one seed step at the beginning of the pipeline, before any parallel browser worker starts. That step should be idempotent, meaning you can run it twice without corrupting the environment.

A minimal example with Playwright tests could look like this:

ts // seed.ts import { request } from ‘@playwright/test’;

const baseURL = process.env.BASE_URL!; const seedKey = process.env.SEED_KEY!;

async function main() {
  const api = await request.newContext({ baseURL });
  const res = await api.post('/test-support/seed', {
    headers: { 'x-seed-key': seedKey },
    data: {
      baseline: 'browser-suite-v1'
    }
  });

if (!res.ok()) throw new Error(Seed failed: ${res.status()}); }

main();

The exact endpoint is your choice. What matters is the contract: seed the baseline, return a stable result, and log the seed version.

2. Give each worker its own run ID

In CI, generate a run ID once and share it with all workers. Do not let each test invent its own namespace unless you want to spend time cleaning up ghost data.

export RUN_ID="ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
export TEST_NAMESPACE="$RUN_ID"

If you split browser tests into shards or workers, derive worker names from the shared run ID:

  • ci-123-1-w1
  • ci-123-1-w2
  • ci-123-1-w3

That makes cleanup queries easier because the run prefix groups everything together.

3. Route writes through a helper

Every test that creates data should call a helper that tags the record.

ts async function createTestUser(api, runId: string, workerId: string) { return api.post(‘/users’, { data: { email: qa+${runId}-${workerId}@example.test, createdByRunId: runId, createdByWorker: workerId } }); }

If your application cannot accept those fields directly, store them in a separate audit table or metadata store. The key is that cleanup can find the record later.

Reset safely when multiple workers finish at the same time

Resets race when two workers try to clean the same environment slice. The way out is to make cleanup idempotent and scoped.

Cleanup rules that hold up under parallelism

  1. Delete by namespace, not by guessed state. Search for createdByRunId = currentRunId rather than deleting “recent” records.

  2. Use soft delete or status transitions where possible. Hard deletes are fine, but soft delete can be safer if another process still references the record.

  3. Make repeated cleanup harmless. A delete request should succeed even if the record is already gone.

  4. Do not clean shared seed data from worker teardown. Shared seed data should only be reset from a dedicated job or an environment recycle step.

  5. Order matters. Delete dependent records before parent records, or use cascading rules that you have verified.

The most expensive cleanup bug is one that looks successful while leaving orphaned data behind.

Example teardown endpoint

A backend cleanup endpoint can accept a run ID and remove only matching data:

ts await api.post(‘/test-support/cleanup’, { data: { runId: process.env.RUN_ID, mode: ‘idempotent’ } });

The response should tell you what was removed, what was already absent, and what failed. That detail helps when a cleanup races with a retry.

What to log when resets race each other

If cleanup overlaps, the browser test result alone is not enough. Log the lifecycle of the data itself.

Minimum useful log fields

  • runId
  • workerId
  • namespace
  • seedVersion
  • recordType
  • recordId
  • action (seed, create, reset-start, reset-skip, reset-delete, reset-fail)
  • timestamp
  • requestId or correlation ID
  • result and reason

A good log line is short enough to scan but specific enough to reconstruct the race.

{ “runId”: “ci-123-1”, “workerId”: “w2”, “namespace”: “ci-123-1-w2”, “action”: “reset-delete”, “recordType”: “user”, “recordId”: “usr_48291”, “seedVersion”: “browser-suite-v1”, “result”: “deleted” }

What not to log

Do not log only “reset succeeded” unless you are comfortable with blind triage. You need enough detail to answer three questions:

  • Which worker created the data?
  • Which worker tried to delete it?
  • Was the record already gone, locked, or still in use?

A CI shape that stays deterministic

A simple pipeline order is usually enough:

  1. Provision environment.
  2. Seed baseline data.
  3. Allocate run ID.
  4. Start parallel browser workers.
  5. Collect artifacts and logs.
  6. Run idempotent cleanup.
  7. Archive reset report.

In GitHub Actions, the structure looks like this:

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: node scripts/seed.js
      - run: npx playwright test --workers=4
      - if: always()
        run: node scripts/cleanup.js

The important part is not the exact runner. It is that seed happens before test fan-out, and cleanup happens even when tests fail.

Failure modes worth designing for

1. Two workers create the same record

This happens when both use the same static email, slug, or order ID. Fix it by generating names from runId + workerId, not from timestamps alone. Timestamps can still collide at CI speed and are harder to trace.

2. Cleanup deletes data still needed by another worker

This usually means the data is not truly owned per worker. Split shared fixture data from mutable per-worker data, and delete only the latter.

3. A retry sees leftover state from the failed run

A retry should use a fresh run ID. Reusing the old namespace is tempting, but it creates ambiguity about whether a test is observing stale or current data.

4. Seed runs twice and duplicates baseline records

Make the seed endpoint or script idempotent. Prefer upserts, natural keys, or version checks over blind inserts.

5. A browser test passes while cleanup fails

This is a real failure, not a harmless warning. If cleanup fails, the next run may become nondeterministic. Treat cleanup logs as first-class output.

When a database snapshot is better

A seed-and-reset loop is not always the right answer. Use a snapshot or disposable environment when:

  • tests need broad relational state that is expensive to recreate,
  • the app mutates many tables across one flow,
  • or cleanup logic is already more complex than environment recreation.

Snapshots can reduce per-test setup work, but they raise storage and orchestration cost. They also make it easier to hide bad test isolation, because every worker gets the same frozen world.

I would choose seed-and-reset first when the suite mainly creates a few records per test and the app already exposes a small test-support API. I would move to snapshots when cleanup logic becomes a second application.

A small rubric for deciding your reset strategy

Constraint Better fit Why
Few mutable records per test Seed and reset Lower implementation cost
Many cross-table writes Snapshot or disposable environment Less custom teardown code
Strong need for reproducible debugging Seed and reset with logging Clear ownership trail
Shared staging data cannot be touched Isolated environment Avoids accidental cleanup
Parallel workers frequently collide Per-worker namespace Prevents data races

Who should skip this approach

This workflow is not a good fit if your test environment is a shared staging instance that product teams actively use and you cannot safely tag or isolate records. It is also a poor fit when tests depend on third-party side effects you cannot reverse, such as emails or payment events, unless you have a dedicated test transport or sandbox.

If your test data is mostly read-only, do not build a full reset system just because parallelism is available. You may only need a stable read baseline and a few isolated write tests.

The bottom line

A deterministic parallel browser suite depends on ownership, not optimism. Seed the baseline once, isolate each worker with a namespace, and make cleanup idempotent with logs that explain every reset decision. If you can answer “who created this record, who tried to remove it, and why did the second attempt behave differently?” your reset workflow is probably sound.

FAQ

How is seeding different from resetting?

Seeding creates known initial data, resetting removes or restores data after a run. Seeding happens before tests need the data, resetting happens after tests are done with it.

Should each parallel worker get its own database?

Not always. A separate database is the simplest isolation, but it can be expensive. A shared database with per-worker namespaces is often enough if the app can tag records reliably.

What is the safest way to avoid collisions in browser tests?

Use a shared run ID plus a worker ID in every mutable test record, and avoid static identifiers for emails, slugs, or order numbers.

Why do cleanup jobs need to be idempotent?

Because retries, duplicate teardown steps, and race conditions happen. If cleanup can run twice without breaking the environment, it is much easier to operate in CI.

What should I log when a reset fails?

Log the run ID, worker ID, record ID, action, seed version, result, and failure reason. Without that, race conditions become guesswork.

When should I move away from seed-and-reset?

When the teardown logic starts matching the complexity of the app itself, or when the test data surface is too large to isolate economically. At that point, snapshots or disposable environments may be cheaper to maintain.