PRACTICAL GUIDE / debug Playwright worker lifecycle resource leaks

Find the resource that outlives a Playwright worker

Learn how Playwright worker restarts expose leaked servers and leases, then use fixture ownership and lifecycle evidence to make cleanup reliable in CI.

By The Testing AcademyUpdated August 4, 20269 min read
All field guides
In this guide6 sections
  1. Why worker restarts expose hidden leaks
  2. Give every resource one visible owner
  3. Build evidence around the worker lifetime
  4. Separate a leak from expected reuse
  5. Choose the fix and pay its real cost
  6. Know when lifecycle changes hide the bug

What you will learn

  • Why worker restarts expose hidden leaks
  • Give every resource one visible owner
  • Build evidence around the worker lifetime
  • Separate a leak from expected reuse

One test times out, the replacement worker starts, and CI reports that a port or account is already in use. Locally, the suite exits cleanly because one worker handles a short run. The failure is not random: a resource has a longer lifetime than the Playwright worker that was supposed to own it.

Why worker restarts expose hidden leaks

Playwright Test runs tests in separate worker processes. It reuses a healthy worker across files when their environments match, which makes worker-scoped setup efficient. When a test fails, the runner discards that worker and starts a new process so later tests do not inherit its browser or in-memory state.

That restart is the detail many fixture designs miss. A resource created once “for the suite” may actually be created once per worker lifetime. After a failure, beforeAll can run again and worker fixtures are set up again. Seeing two setup messages is therefore not proof of a leak. The proof is an old resource that remains reachable after the worker responsible for it has torn down.

Some resources disappear with the operating-system process. An ordinary listening socket owned by the worker closes when that process exits. External resources do not. A detached child process, cloud test account, database lease, message consumer, container, or remote browser session can survive its creator. A module-level variable becoming unreachable does nothing to those resources.

Fixture scope defines reuse, not ownership by itself. A worker-scoped fixture is set up once for a worker and torn down when that worker shuts down. A test-scoped fixture is set up and torn down for each test. Neither scope helps when setup happens outside the fixture, cleanup sits in an unrelated hook, or teardown returns before the remote system has actually released the resource.

The failure often appears one step later. The leaking test may still be green. The next worker gets EADDRINUSE, a duplicate-consumer error, an exhausted account pool, or data left by a previous process. Start diagnosis from the conflicting resource ID, then work backward to its owner. Do not assume the test named in the later error created it.

Give every resource one visible owner

The safest fixture creates the resource, waits until it is usable, yields it with use, and closes it in finally. Here is a complete worker-scoped HTTP service for tests that need one server per worker. Letting the operating system choose the port avoids collisions between parallel workers, while the log records which process owns that port.

TypeScript
// tests/fixtures.ts
import { once } from 'node:events';
import { createServer } from 'node:http';
import { test as base, expect } from '@playwright/test';

type WorkerFixtures = {
  healthServerUrl: string;
};

export const test = base.extend<{}, WorkerFixtures>({
  healthServerUrl: [async ({}, use, workerInfo) => {
    const server = createServer((request, response) => {
      if (request.url === '/health') {
        response.writeHead(200, { 'content-type': 'application/json' });
        response.end(JSON.stringify({ status: 'ready' }));
        return;
      }

      response.writeHead(404);
      response.end();
    });

    server.listen(0, '127.0.0.1');
    await once(server, 'listening');

    const address = server.address();
    if (!address || typeof address === 'string') {
      throw new Error('Expected the server to listen on a TCP port');
    }

    const owner = {
      workerIndex: workerInfo.workerIndex,
      parallelIndex: workerInfo.parallelIndex,
      pid: process.pid,
      port: address.port,
    };
    console.log(JSON.stringify({ event: 'worker-resource-open', ...owner }));

    try {
      await use(`http://127.0.0.1:${address.port}`);
    } finally {
      await new Promise<void>((resolve) => server.close(() => resolve()));
      console.log(JSON.stringify({ event: 'worker-resource-close', ...owner }));
    }
  }, { scope: 'worker', timeout: 30_000 }],
});

export { expect };

A test imports the extended test, not the base object from @playwright/test:

TypeScript
// tests/health.spec.ts
import { test, expect } from './fixtures';

test('worker service is ready', async ({ request, healthServerUrl }) => {
  const response = await request.get(`${healthServerUrl}/health`);

  expect(response.status()).toBe(200);
  expect(await response.json()).toEqual({ status: 'ready' });
});

The finally block matters. It runs when a test fails after await use(), when the worker finishes normally, and when Playwright retires the worker. The explicit fixture timeout gives slow setup and teardown their own budget rather than forcing every test to inherit a long timeout.

This example closes an in-process server. A child process also needs an exit wait after its termination signal. A remote lease needs a delete request followed by confirmation that the lease is gone. Logging “cleanup requested” is not evidence of cleanup completion.

Worker scope saves setup time, but it introduces shared state. If one test can change the service, account, or database in a way another test observes, use test scope or add a reliable reset between tests. The faster fixture is not cheaper when it turns order-dependent failures into routine triage.

Build evidence around the worker lifetime

Reproduce with enough repetition to force reuse and with more than one lane to expose ownership mistakes:

Shell
npx playwright test tests/health.spec.ts --workers=2 --repeat-each=20 --reporter=line

Run with --workers=1 once as a diagnostic comparison, not as the fix. If the leak disappears, parallel ownership or a shared identifier is likely involved. If it remains, focus on teardown, repeated setup in one lane, or a resource created by the runner process rather than a worker.

Record four values for every open and close: the resource ID, workerIndex, parallelIndex, and process ID. The worker index identifies one worker process and changes when a failed worker is replaced. The parallel index identifies the lane and can remain the same across that restart. Confusing the two creates misleading logs and sometimes duplicate names.

For example, suppose the records show:

Example
open  account=qa-2 workerIndex=5 parallelIndex=2 pid=4811
close account=qa-2 workerIndex=5 parallelIndex=2 pid=4811
open  account=qa-2 workerIndex=8 parallelIndex=2 pid=4927

That sequence is healthy reuse of a lane across two worker lifetimes. Remove the close record, and the log becomes suspicious, but it is still not conclusive. Query the account service and confirm whether the first lease is active. Logging can be lost when a process crashes, while a cleanup call may complete even if its final message never flushes.

The resource system is the authority. Check the listening port owner, active database sessions, running containers, queue consumer registry, or remote-session dashboard. Attach its resource ID to the Playwright output. A screenshot of the later failure without that identity cannot tie the conflict to the earlier worker.

Teardown errors deserve their own signal. Do not swallow them in a broad catch block. If deletion is idempotent, treat “already gone” as success and report other responses. A green test followed by a cleanup exception should fail the run because the next test is otherwise being asked to discover the leak indirectly.

Separate a leak from expected reuse

A resource remaining open between two tests in the same healthy worker is expected for worker scope. The matching close should appear only when that worker ends. Demanding a close after every test and calling the absence a leak misreads the lifecycle.

A new workerIndex after a failure is expected too. Playwright replaces the process to protect later tests from contaminated state. If setup runs twice under different worker indexes, ask whether the first resource closed. Do not add a global “initialize once” flag in a module. Each worker has its own memory, so that flag cannot coordinate processes.

By contrast, a growing count of active remote sessions after each worker retirement is a leak. So is a detached child process whose parent PID no longer exists, or a queue consumer still receiving messages after its close record. Compare the count with a baseline before and after a controlled run. Memory use alone is weaker evidence because runtimes cache and garbage collection does not return every page immediately to the operating system.

Also inspect where the resource was created. Code in globalSetup belongs to the runner-level lifecycle, not a worker fixture. Project dependencies are often easier to observe because they appear in reports and can use fixtures, but they still have a different lifetime from test workers. Choose the owner that matches the resource instead of moving everything to global setup to make duplication disappear.

Choose the fix and pay its real cost

Move per-test state to a test-scoped fixture when isolation matters more than startup time. This is appropriate for mutable users, browser contexts, temporary records, and message subscriptions whose contents affect assertions. The cost is more setup calls and possibly longer CI runs.

Keep an expensive server or immutable account worker-scoped when tests can safely share it. Allocate names with parallelIndex if the external system expects one stable resource per parallel lane. Include the worker index in ownership metadata so a replacement can distinguish its lease from the predecessor’s. The cost is stronger reset logic and more careful concurrency design.

For resources outside the worker process, add a second cleanup layer. Tag them with run ID, project, shard, parallel index, worker index, and creation time. Give temporary leases an expiry where the system supports it, then run reconciliation that removes abandoned resources from dead runs. This costs implementation effort and can delay cleanup, but it covers abrupt termination where fixture code cannot run.

Make cleanup idempotent and await its completion. A retrying delete is safer than a one-shot fire-and-forget call, provided it has a bounded timeout and distinguishes “already deleted” from authorization or service failures. Keep setup idempotent too, especially when a stable parallel lane name is reused after a worker restart.

Know when lifecycle changes hide the bug

Do not set workers: 1 permanently to cure a shared-name collision. Serial execution reduces the chance of overlap but leaves ownership wrong, and the problem returns when the suite is sharded or another job runs at the same time.

Avoid process.exit() as cleanup. It skips normal asynchronous shutdown and can prevent Playwright from finishing reports and traces. It also kills unrelated resources owned by the worker instead of proving each one closed correctly.

Do not convert every worker fixture to test scope after one leak. Recreating a container, service, or seeded database for every test can make the suite unusably slow. Fix the missing teardown or shared mutable boundary first, then change scope only when the tests truly require isolation.

Finally, do not promise that finally survives a hard kill. A canceled CI job, machine crash, out-of-memory termination, or SIGKILL can stop the process before JavaScript runs cleanup. When the resource can outlive the machine, expiry and reconciliation are part of the design, not optional polish.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why does Playwright start my worker fixture more than once?

The runner creates a new worker process after a test failure, so worker-scoped setup runs again in that process. Multiple setup records are expected when their worker indexes differ; a leak exists when an old external resource remains active after its owner shuts down.

What is the difference between workerIndex and parallelIndex?

A restarted worker receives a new `workerIndex`, while its replacement can keep the same `parallelIndex`. Use the worker index to identify a process lifetime and the parallel index to allocate a stable lane such as a test account or shard-local database.

Where should cleanup for a Playwright worker resource live?

Put the cleanup after `await use()` in the same fixture that creates the resource, protected by `try` and `finally`. This keeps ownership visible and lets Playwright run teardown when the worker is retired after a failure.

Should a database record be test-scoped or worker-scoped?

Use test scope when a test mutates the record or requires a clean starting state. Worker scope can suit an expensive, immutable account or service, but every test sharing it must tolerate the state left by its neighbors.

Will fixture teardown run if CI kills the process?

Treat a hard kill, host crash, or lost container as a boundary where in-process cleanup may never execute. External resources need a second defense such as idempotent deletion, owner labels, and an expiry or later reconciliation job.