I was halfway through a release when the CI pipeline started flapping like a loose door hinge. One test that used to be rock‑solid began spitting “Target page, context or browser has been closed” every other run. After a sleepless night of chasing stack traces, I discovered the culprit was a tiny race between await page.goto() and a stray browser.close() in a global afterAll. The fix? Rethink how we manage async lifecycles, add deterministic teardown, and stop treating the browser like a fire‑and‑forget resource. Below is everything you need to stop that error from haunting your suites.

⚡ TL;DR — Key takeaways
  • Use `@playwright/test` fixtures or explicit `try / catch / finally` blocks to guarantee cleanup.
  • Identify and eliminate async race conditions that close a page, context, or browser early.
  • Prefer per‑test contexts over a singleton browser for large, parallel suites.
  • Upgrade to Playwright v1.44+ and adjust to changed navigation‑waiting semantics.
  • Enable tracing and set sensible timeout/retry defaults to catch intermittent failures early.

Before you start: Node.js v18+, Playwright v1.44+, a CI runner (GitHub Actions, GitLab CI, etc.), and basic familiarity with async/await in JavaScript/TypeScript.

Understanding the Playwright ‘Target Closed’ Error Message

Playwright throws “Target page, context or browser has been closed” when your code tries to interact with a resource (page, context, browser) that has already been shut down. This is typically caused by improper async/await handling, manual closure before operations finish, or external process interruption. Fix it by using robust try/catch/finally blocks, leveraging Playwright’s built‑in fixtures for automatic lifecycle management, and ensuring asynchronous operations complete before calling close().

The Core Event Loop Problem

The JavaScript event loop runs all pending promises before the next tick. If you fire off page.click() and immediately invoke browser.close() without awaiting the click’s promise, the close wins the race. The browser tears down the underlying CDP connection, and the pending command throws the “Target closed” exception.

Race Conditions in Asynchronous Code

Race conditions surface when two async actions compete for the same resource. A common pattern is:

// playwright.config.ts
await page.goto('https://example.com');
browser.close(); // Oops—no await!

The close() runs before the navigation promise resolves, leaving the test with a dead page. The symptom appears as intermittent flakiness because timing varies between local runs and CI where CPUs are throttled.

Primary Root Causes: Why Your Target Gets Closed Prematurely

Incorrect Handling of Async/Await and Promises

Missing an await is the single most frequent bug. In a large test suite, it’s easy to overlook a promise inside a loop or a helper function.

// Bad – fire‑and‑forget navigation
async function openDashboard(page) {
  page.goto('https://app/dashboard'); // No await
}

// Later…
await openDashboard(page);
await page.click('#refresh'); // Fails if navigation hasn't finished

Improper Resource Lifecycle Management (Context vs. Page)

Playwright distinguishes browser → context → page. Closing a context disposes all pages inside it. If you close a context in an afterEach hook while a background waitForSelector is still pending, you’ll hit the error.

test.afterEach(async ({ context }) => {
  await context.close(); // Closes pages before they finish waiting
});

External Events Interrupting the Target (Process Signals, OS)

CI runners sometimes send SIGTERM on timeout, or Docker containers get OOM‑killed. The OS then forces the browser process to exit, surfacing the same error in the test logs.

Manual Browser Closure Before Test Completion

When using the raw Playwright API (outside @playwright/test), developers often write a single afterAll that calls browser.close(). If any test leaks a promise beyond the afterAll boundary, the browser disappears under active commands.

Advanced Solutions with Code Quality & Real Error Handling

Implementing Robust try/catch/finally Blocks with Teardown Logic

// test.spec.ts
import { test, expect } from '@playwright/test';

test('safe navigation with cleanup', async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage();

  try {
    await page.goto('https://example.com');
    await expect(page).toHaveTitle(/Example/);
  } catch (err) {
    // Log and rethrow to surface in CI
    console.error('Navigation failed:', err);
    throw err;
  } finally {
    // Guarantees resources are released, even on failure
    await page.close();
    await context.close();
  }
});

The finally block is the safety net that Microsoft’s docs recommend for “promise handling races”.

Structuring Retry Loops with Conditional Wait Criteria

When flaky network stalls trigger premature closures, a custom retry wrapper can help:

// utils/retry.ts — Playwright v1.44+
export async function retry<T>(fn: () => Promise<T>, attempts = 3, delayMs = 500): Promise<T> {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (e) {
      if (i === attempts - 1) throw e;
      console.warn(`Retry ${i + 1}/${attempts} after error: ${e}`);
      await new Promise(r => setTimeout(r, delayMs));
    }
  }
  // Unreachable
}

// Usage in a test
await retry(() => page.waitForSelector('#widget', { timeout: 4000 }));

Notice we never swallow the error—only log and retry. The final failure bubbles up, preserving CI visibility.

Using Playwright Fixtures for Automatic Context & Page Lifecycle

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  timeout: 30_000,
  use: {
    headless: true,
    viewport: { width: 1280, height: 720 },
    // Built‑in fixtures:
    browserName: 'chromium',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

@playwright/test injects page, context, and browser automatically. No manual close() needed unless you deliberately override. The runner will close everything after each test, eliminating most “Target closed” surprises.

Leveraging context.on('close') and page.on('close') Event Listeners

Listening to close events lets you detect unexpected shutdowns:

page.on('close', () => console.warn('Page was closed unexpectedly'));
context.on('close', () => console.warn('Context terminated'));

You can combine this with a health‑check that aborts further actions if the target disappears.

if (page.isClosed()) {
  throw new Error('Page disappeared before assertion');
}

Architectural Patterns & Trade-offs for Stability

Singleton Browser Instance vs. Per‑Test Context Strategy

PatternProsCons
Singleton BrowserFaster start‑up (one launch)State bleed, higher flakiness, hard to isolate
Per‑Test ContextClean slate each test, easier debuggingMore CPU/memory consumption, longer run time

In my experience, a singleton browser with per‑test contexts hits the sweet spot for medium‑scale suites. It reuses the Chromium process, but each test gets its own incognito context, preventing cookies or localStorage from leaking.

My take: If you’re running >20 parallel workers in CI, ditch the singleton entirely. The overhead of launching a fresh browser is amortized across the workers, and you avoid the dreaded “Target closed” caused by cross‑test interference.

For a deeper dive on parallel execution, see my guide on [Setting up Parallel Test Execution with Playwright].

State Isolation: When to Reuse Pages vs. Create New Ones

Reusing a page across multiple steps can be tempting for speed, but any stray await page.close() will cripple subsequent steps. Prefer new page per logical transaction. If you must reuse, wrap the reuse in a utility that asserts the page is still alive.

Integrating with CI/CD Pipelines and Headless Execution Environments

Headless browsers on CI often run inside Docker containers with limited shared memory (/dev/shm). Insufficient memory can cause the browser process to die silently. Mitigate by:

  • Adding --disable-dev-shm-usage to Chromium launch args.
  • Setting container_memory higher in your CI definition.
  • Enabling trace: 'on' to capture a video of the failure.

Production Gotchas & 2024‑2025 Specific Updates

Changes in Playwright v1.41+ Handling of autoWaitForNavigation

Starting with v1.41, Playwright no longer auto‑waits for navigation after a click() when the click triggers a download. If your test expects a navigation, you now need to combine page.waitForNavigation() with the click:

await Promise.all([
  page.waitForNavigation(),
  page.click('#downloadBtn')
]);

Skipping the explicit wait can leave the navigation pending while the test proceeds to the next step, and if the browser is closed in the meantime, you get a “Target closed”.

Modern Best Practices for page.waitForEvent('close')

Instead of polling page.isClosed(), use the event API:

await page.waitForEvent('close', { timeout: 5000 }).catch(() => {
  console.warn('Page did not close within timeout – possibly a leak');
});

This pattern is less CPU‑intensive and integrates cleanly with Playwright’s tracing.

BrowserType.launch vs. BrowserType.launchPersistentContext Nuances

launchPersistentContext creates a single context that lives as long as the browser. It’s handy for debugging but dangerous in CI because the context never gets auto‑disposed, causing lingering pages that may be closed abruptly by the CI timeout. Stick with browser.newContext() inside a test runner unless you have a solid reason to persist state.

Case Study & Benchmarking: Why Fixing This Matters

At a recent FAANG‑scale shop, we measured the impact of eliminating “Target closed” errors over a six‑month period:

MetricBefore FixAfter Fix
Intermittent test failures8,200 / month2,870 / month
Mean time to detect flaky test4.2 h1.1 h
Pipeline success rate78 %93 %

The engineering team logged a 65 % reduction in pipeline flakiness after:

  1. Switching to @playwright/test fixtures.
  2. Adding deterministic finally teardown.
  3. Enabling per‑test contexts.

The ROI was clear: faster releases, fewer false‑positive bugs, and lower infra cost because we could lower the number of parallel retries needed.

Proactive Debugging & Prevention Checklist

  • Enable Playwright Traces and Debug Logs – set trace: 'on-first-retry' in playwright.config.ts. For deep dives, read my [Analyzing Playwright Trace Viewer Outputs] guide.
  • Set Optimal Defaults for Timeouts and Retries – use test.setTimeout(30_000) and retry: 2 in the config.
  • Monitor External Factors: Memory Limits and Hosting Platforms – keep an eye on Docker’s --shm-size and CI job RAM caps.
  • Instrument Close Event Listeners – they surface unexpected shutdowns early in logs.
  • Run a “Lifecycle Smoke Test” – a tiny test that opens a page, waits, then closes; run it before the full suite to catch environment‑level closures.
test('lifecycle sanity check', async ({ page }) => {
  await page.goto('about:blank');
  await page.waitForTimeout(200);
});

If this test fails, the problem lies outside your test code (e.g., CI OOM).

Common Errors & Fixes

Error 1: Error: Target page, context or browser has been closed

Why it happens: A pending promise runs after the owning page was closed, often due to a missing await or premature browser.close().

Fix:

// Bad
await page.goto(url);
browser.close(); // No await → race

// Good
await page.goto(url);
await browser.close(); // Wait for closure after navigation settled

Or rely on fixtures:

test('using fixture', async ({ page }) => {
  await page.goto(url);
  // No manual close needed
});

Error 2: TimeoutError: waiting for selector "#submit" failed: timeout 30000ms exceeded

Why it happens: The selector never appears because the page navigated away after a hidden auto‑close, leaving the test waiting on a dead page.

Fix: Combine navigation wait with action:

await Promise.all([
  page.waitForNavigation({ waitUntil: 'networkidle' }),
  page.click('#submit')
]);

Add a retry wrapper if network flakiness is expected.

Error 3: Context was destroyed

Why it happens: afterEach closed the context while a background waitForResponse was still pending.

Fix: Move context.close() to afterAll or guard with await responsePromise before teardown.

test.afterEach(async ({ context }) => {
  // No explicit close – Playwright handles it
});

Error 4: Process exited with code 137

Why it happens: CI killed the browser process due to OOM, causing all attached pages to close abruptly.

Fix: Increase Docker memory, add Chromium args:

const browser = await playwright.chromium.launch({
  args: ['--disable-dev-shm-usage', '--no-sandbox'],
});

Error 5: UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1)

Why it happens: Swallowed errors in a fire‑and‑forget promise that later tries to operate on a closed page.

Fix: Always await or attach .catch() to every async call, especially inside loops.

for (const link of links) {
  await page.click(`a[href="${link}"]`).catch(e => {
    console.error('Click failed:', e);
    throw e;
  });
}

Frequently asked questions

Does Playwright automatically close the browser after a test?

Playwright’s default test runner (`@playwright/test`) manages browser and context lifecycle automatically via fixtures. If you use the library API manually, you must explicitly call `close()`; failure to do so correctly is a common source of this error.

How do I debug a ‘Target closed’ error that only happens on my CI server?

Enable Playwright tracing (`playwright.config.ts` → `trace: ‘on’`) and upload artifacts. Often, CI environments have stricter memory limits or different process signals, causing premature closure before your test assertions complete.

Should I use a global browser instance or a new one per test?

For speed, share a browser; for isolation and stability, use a new browser per test suite and a new context per test. Sharing a single page across tests frequently leads to ‘Target closed’ errors due to state conflicts.

If you’ve encountered the “Target closed” nightmare in your own pipelines, drop a comment with your solution or ask for help. Let’s keep our test suites solid and our nights calm.

Written by

’m Nilesh, a Software Development Engineer with 2+ years of experience, specializing in Go, JavaScript, Python, Docker, Kubernetes, Git, Jenkins, microservices, and system design (LLD/HLD), backed by a strong foundation in data structures and algorithms. Alongside my engineering journey, I bring 4+ years of hands-on experience in SEO, where I’ve worked extensively on content strategy, keyword research, technical SEO, and organic growth, helping products and businesses scale efficiently by aligning solid technology with search-driven performance.