PRACTICAL GUIDE / Playwright browser context isClosed teardown assertion
Prove your Playwright context really reached teardown
Use BrowserContext.isClosed() in Playwright fixtures to catch ownership mistakes, separate expected teardown from crashes, and protect saved artifacts.
In this guide7 sections
- Understand what isClosed proves and what it cannot prove
- Put the assertion in the fixture that owns the context
- Work through the failures that look like teardown leaks
- A page was closed instead of its context
- A helper closed a context it did not own
- The checked context closed, but a sibling context leaked
- The browser disconnected before graceful teardown
- Separate premature closure from work that escaped the test
- Tell a lifecycle defect from a version or assertion defect
- Roll the guard into an existing suite without changing every test
- Know when not to assert context closure
What you will learn
- Understand what isClosed proves and what it cannot prove
- Put the assertion in the fixture that owns the context
- Work through the failures that look like teardown leaks
- Separate premature closure from work that escaped the test
A helper creates its own browser context, closes the last page, and reports successful cleanup. The test passes, but browser.contexts() still contains that context when teardown ends. A page was closed; the session that owned cookies, routes, and artifacts was not.
A teardown assertion exposes this leak at the fixture boundary, where ownership is still clear.
Understand what isClosed proves and what it cannot prove
BrowserContext.isClosed() was added in Playwright 1.59. It returns a boolean and does not wait. According to the API contract, true means the browser context is in the process of closing or has already closed. That wording is important: the method is a state probe, not a completion promise.
Use these operations for different questions:
context.isClosed()answers whether the context has entered its closing state.await context.close()asks Playwright to close the context and waits for that operation to finish.context.on('close', listener)records that a close event occurred, regardless of what caused it.browser.contexts()lists the browser's currently open contexts.browser.isConnected()reports whether the client is still connected to the browser.
An assertion after an awaited close is a useful invariant:
await context.close({ reason: 'owned fixture teardown' });
expect(context.isClosed()).toBe(true);The assertion catches surprising ownership and control-flow mistakes, but the await does the important lifecycle work. Checking in a loop until isClosed() becomes true is weaker. The boolean may flip while closing is still underway, so such a loop can race video, HAR, or other artifact finalization.
Context closure also closes every page belonging to that context. The reverse is not true. Closing every current page does not close the context, and a context with zero pages is still a valid open context that can create another page later. That asymmetry explains many false cleanup checks.
The close event does not carry a cause. Officially, it can fire because the context was closed, the browser application closed or crashed, or browser.close() was called. isClosed() has the same limitation: it confirms state, not intent. If the distinction matters, record intent in your fixture before initiating the close and observe browser disconnection separately.
One more boundary matters for artifacts. Playwright recommends explicitly closing contexts that your code creates before closing the browser. A browser close is similar to force-quitting the browser, while graceful context closure allows artifacts such as HARs and videos to be fully flushed. An isClosed() value obtained after a browser crash does not prove that graceful flush happened.
Put the assertion in the fixture that owns the context
The fixture that calls browser.newContext() should normally be the fixture that calls context.close(). Keeping both operations in one scope makes teardown reviewable and prevents tests from guessing who owns the resource.
This test-scoped fixture creates a context, exposes it to the test through use, and closes it in finally. It also records whether the context close event was observed.
// fixtures/owned-context.ts
import {
test as base,
expect,
type BrowserContext,
} from '@playwright/test';
type OwnedContextFixtures = {
ownedContext: BrowserContext;
};
export const test = base.extend<OwnedContextFixtures>({
ownedContext: async ({ browser }, use, testInfo) => {
const context = await browser.newContext({
recordVideo: { dir: testInfo.outputPath('videos') },
});
let closeEventSeen = false;
context.once('close', () => {
closeEventSeen = true;
});
try {
await use(context);
} finally {
await context.close({
reason: `fixture teardown for ${testInfo.title}`,
});
expect(context.isClosed(), 'owned context should be closed').toBe(true);
expect(closeEventSeen, 'context close event should be observed').toBe(true);
}
},
});
export { expect } from '@playwright/test';The finally block matters when the test assertion fails. Without it, code after await use(context) may be skipped by an exception, leaving closure to broader worker shutdown. Playwright fixtures are designed with setup before use and teardown after it; try/finally makes the intent explicit to anyone maintaining the fixture.
The reason option does not turn the close event into a typed event with a reason field. It supplies a reason that can be reported to operations interrupted by context closure. Keep your own lifecycle record if you need to classify the initiator.
Tests consume the custom fixture instead of manually managing the context:
// tests/account-settings.spec.ts
import { test, expect } from '../fixtures/owned-context';
test('saves an email preference', async ({ ownedContext }) => {
const page = await ownedContext.newPage();
await page.goto('/settings/notifications');
await page.getByLabel('Product updates').check();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('Preferences saved');
});There is no close call in the test. That is deliberate. Test code verifies product behavior; the fixture enforces infrastructure ownership. A test that closes the shared fixture early would make later steps fail with a broad target-closed error and would also complicate the fixture's artifact handling.
Do not add this wrapper around Playwright Test's standard context fixture just to assert what the runner already owns. The built-in fixture provides a fresh isolated context for each test and tears it down. Create a custom fixture when you create an additional context, need non-default context options, or specifically test context lifecycle behavior.
Work through the failures that look like teardown leaks
Three distinct defects can end with a message containing Target page, context or browser has been closed. That text tells you an operation lost its target. It does not identify which target closed or who closed it.
A page was closed instead of its context
The first failure comes from a helper that uses page.close() as its cleanup operation. That may be correct when the helper only owns a page inside a caller-owned context. It is wrong when the helper created the context as well.
Make ownership visible in the returned type. A factory that owns a context can return one close operation for the complete unit rather than leaking separate page and context handles to every caller.
import type { Browser, BrowserContext, Page } from '@playwright/test';
export type IsolatedSession = {
context: BrowserContext;
page: Page;
close(): Promise<void>;
};
export async function createIsolatedSession(
browser: Browser,
): Promise<IsolatedSession> {
const context = await browser.newContext();
const page = await context.newPage();
return {
context,
page,
async close() {
await context.close({ reason: 'isolated session disposed' });
if (!context.isClosed()) {
throw new Error('Isolated session context did not close');
}
},
};
}This design costs a small wrapper and can feel ceremonial for a one-page script. It earns its keep in a framework because callers have one disposal method and no reason to decide whether the page or context is the owned resource.
If the caller needs to close a popup without ending the session, it can still close that page directly. The session's close() method remains responsible for final context teardown.
A helper closed a context it did not own
The second failure is the mirror image. A login helper accepts the test's existing context, opens a temporary page, and calls context.close() in its finally block. Login succeeds, then the first action in the test fails because the helper destroyed a caller-owned resource.
The ownership rule is simple: accepting a context as an argument does not transfer ownership unless the API explicitly says so. A borrowed-context helper should close only resources it created and that are safe to close independently.
import type { BrowserContext } from '@playwright/test';
export async function authenticateBorrowedContext(
context: BrowserContext,
username: string,
password: string,
): Promise<void> {
const loginPage = await context.newPage();
try {
await loginPage.goto('/login');
await loginPage.getByLabel('Username').fill(username);
await loginPage.getByLabel('Password').fill(password);
await loginPage.getByRole('button', { name: 'Sign in' }).click();
await loginPage.waitForURL('**/dashboard');
} finally {
await loginPage.close();
}
}After this helper returns, context.isClosed() should still be false because the caller intends to continue using it. That is a legitimate negative assertion at the helper boundary. It verifies that a borrowed resource was not disposed:
await authenticateBorrowedContext(ownedContext, user, password);
expect(ownedContext.isClosed()).toBe(false);The cost is that the helper cannot guarantee full session cleanup by itself. That responsibility stays with the owning fixture, which is exactly where it belongs.
The checked context closed, but a sibling context leaked
A teardown can close the handle it retained and still leak a different context created along an error branch. This happens in factories that create a temporary authentication context, then create the real test context, but lose the first reference when authentication throws. An assertion against the real context passes because that context closes correctly.
For a focused framework test, compare the browser's open-context set before and after the factory runs. browser.contexts() is documented to return the browser's open contexts, so it catches extra sessions without depending on a private counter.
import { expect, type Browser } from '@playwright/test';
export async function expectNoNewContextLeaks(
browser: Browser,
run: () => Promise<void>,
): Promise<void> {
const baseline = new Set(browser.contexts());
let runError: unknown;
try {
await run();
} catch (error) {
runError = error;
}
const leaked = browser.contexts().filter(context => !baseline.has(context));
for (const context of leaked) {
await context.close({ reason: 'framework leak check cleanup' });
}
expect(leaked, 'factory left new browser contexts open').toHaveLength(0);
if (runError) {
throw runError;
}
}The helper performs cleanup even when it finds a leak, so the diagnostic test does not contaminate later work in the same worker. It saves the original error and rethrows it only after checking the context set. In production framework code, preserve both errors if the cleanup itself can fail; do not let a secondary close error erase the failure that entered the bad branch.
This baseline technique is too broad for every product test. A harness may intentionally create another context during the measured operation, and a worker-scoped service may own contexts outside the test's fixture graph. Use it around a specific factory or in a serial framework contract test where all legitimate context creation is known. For ordinary teardown, asserting the exact owned handle is clearer and cheaper.
The browser disconnected before graceful teardown
A browser process crash, an external kill, or an early browser.close() can close all contexts. A teardown assertion may still see isClosed() === true, yet artifacts can be incomplete and the close was not initiated by the fixture.
Record a small event ledger. Do not assert a universal event order between every page and context event unless the API documents it. Instead, capture facts that answer the ownership question: did fixture teardown begin, did the context close, and did the browser disconnect?
import type { Browser, BrowserContext, TestInfo } from '@playwright/test';
type LifecycleEntry = {
event: 'fixture-close-started' | 'context-closed' | 'browser-disconnected';
at: string;
};
export async function observeLifecycle(
browser: Browser,
context: BrowserContext,
testInfo: TestInfo,
run: () => Promise<void>,
): Promise<void> {
const entries: LifecycleEntry[] = [];
const record = (event: LifecycleEntry['event']) => {
entries.push({ event, at: new Date().toISOString() });
};
const onContextClose = () => record('context-closed');
const onDisconnected = () => record('browser-disconnected');
context.on('close', onContextClose);
browser.on('disconnected', onDisconnected);
try {
await run();
record('fixture-close-started');
await context.close({ reason: 'observed fixture teardown' });
} finally {
context.off('close', onContextClose);
browser.off('disconnected', onDisconnected);
await testInfo.attach('browser-lifecycle.json', {
body: Buffer.from(JSON.stringify(entries, null, 2)),
contentType: 'application/json',
});
}
}If browser-disconnected appears before fixture-close-started, the context did not reach the intended owner-controlled close. If the context close entry follows the fixture marker and the close promise resolves, the evidence supports expected teardown. Timestamps help read the attempt, but they are not performance measurements and should not be used to assert tiny timing gaps.
The ledger adds listener and attachment overhead. Use it while diagnosing intermittent closure or in a framework-level lifecycle test. Keeping it on every fast UI test can create noisy reports without improving the product assertion.
Separate premature closure from work that escaped the test
The broad target-closed message also appears when context teardown is correct. A helper can start page work without returning or awaiting its promise. The test completes, the owning fixture begins its legitimate close, and the detached operation touches the page afterward. Moving the close later treats the symptom by keeping a resource alive for work whose owner is undefined. The repair is to await, cancel, or drain that work before the fixture releases the context.
This failure looks almost identical to a helper that closes a borrowed context too early. In both cases, the first visible error can say that a page, context, or browser has been closed. The lifecycle order separates them. With premature closure, context-closed appears before the owning fixture records fixture-close-started, while the browser can remain connected. The next awaited page operation then fails. With escaped work, the fixture marker appears first, the context close follows, and the detached operation reports its error afterward. In that sequence, the close is on time and the operation is late.
A browser-level loss has a third shape. browser-disconnected appears before the owner's close marker, all associated contexts become unusable, and the fixture may observe isClosed() as true even though it never completed its intended context close. That sequence routes the investigation toward the browser process or executor rather than toward the helper that happens to issue the next page command.
Read the diagnostic output as ordered facts, not as a bag of booleans. A healthy owner-controlled ledger contains fixture-close-started followed by context-closed, the close promise resolves, and the browser remains connected at that boundary. A broken early-close ledger contains context-closed before the marker or never records the marker because control flow failed sooner. A broken browser lifecycle contains browser-disconnected before owner closure. The misleading output is a lone isClosed: true: all three paths can produce it. A lone isClosed: false is also incomplete if it was sampled before teardown was supposed to begin.
The stack of the first rejected operation supplies the other half of the evidence. For an escaped promise, find where the operation was launched and whether the helper returned that promise to its caller. For premature closure, find the first close initiator and the ownership contract of the resource it received. Do not focus only on the line that noticed the closed target. That line is often an innocent consumer several calls removed from the lifecycle mistake.
The two fixes carry different costs. Awaiting background work adds its actual duration to the test or teardown critical path. Cancelling it can make the suite faster, but it also abandons any assertion or side effect that the work was meant to verify. Moving every close to worker shutdown reduces immediate target-closed errors while increasing context lifetime, retained state, memory use, and the chance that one test contaminates another. The correct cost follows declared ownership: required work is awaited, optional work has explicit cancellation, and owned contexts close at their designed scope.
For rollout, land observability before enforcement on one high-risk factory. Capture the creation site, declared owner, close marker, close result, browser connection fact, and the first operation that fails after closure. Use that evidence to repair detached tasks and borrowed-resource helpers. Then enable the teardown assertion for the factory and its contract tests. Expanding the assertion first can turn many previously green product tests red during fixture teardown without enough information to tell whether the context leaked, closed early, or closed correctly under late work.
The first break usually appears in framework conveniences that hide promises or ownership. Fire-and-forget navigation, event processing started in a page object, and helpers that accept a context but dispose the whole session are higher-risk than ordinary awaited locator calls. Migrate those helpers before adding the guard to every caller. Keep original test failures and teardown failures separately visible in any wrapper so a cleanup defect does not erase the product assertion that triggered the path.
The automation framework team owns context factories, fixture scopes, and the close contract. Test authors own unawaited work launched from their cases or page helpers. The CI or browser-runtime owner becomes responsible when the ledger shows disconnection before fixture intent and executor evidence supports a process loss. A useful handoff contains run, shard, project, worker and retry identity, the context creation and ownership site, first failing operation and stack, ordered ledger, browser connection state, close outcome, and whether video or HAR finalization completed. A generic target-closed line contains none of the evidence needed to choose among those owners.
This guard does not catch resources outside the browser context. A fixture can close its context perfectly while leaking a database lease, a Node timer, a temporary server, or another process. Give each resource type its own owner and completion check. BrowserContext.isClosed() should not become a general teardown-health score.
Tell a lifecycle defect from a version or assertion defect
Begin with the project-local version, not a globally installed Playwright CLI:
npx playwright --version
npx playwright test tests/account-settings.spec.ts \
--reporter=line \
--trace=retain-on-failureWhen TypeScript says Property 'isClosed' does not exist on type 'BrowserContext', the likely boundary is version 1.59. Inspect the package version resolved by the project and upgrade Playwright packages together. Do not cast the context to any; that turns a useful compatibility error into a runtime method-not-found failure.
When runtime output says Target page, context or browser has been closed, inspect the first operation that failed and the last owner action before it. The message deliberately covers several targets. Use these observations to narrow it:
context.isClosed()false withpage.isClosed()true points to page-level closure.context.isClosed()true withbrowser.isConnected()true points to a closed context while the browser remains available.browser.isConnected()false explains why every context under that browser became unusable.- A context close event without your
fixture-close-startedmarker points to another initiator or a browser-level event. - A resolved
context.close()followed by missing video handling points to where the artifact is consumed, not to whether the close call was awaited.
Trace Viewer is valuable for the final page action and the error that interrupted it. It does not replace a Node-side fixture ledger. A context may close during fixture teardown after the last browser action, and the useful stack can be in the HTML report's fixture error rather than in a page timeline.
Check the report attempt that actually failed. A retry creates a new test attempt and usually a new fixture instance. A passing retry does not prove the first attempt's context closed correctly. Preserve the failing attempt's trace and lifecycle attachment long enough to compare them.
A different near-miss is an assertion placed too early:
const closePromise = context.close();
expect(context.isClosed()).toBe(true);
await closePromise;The boolean may already be true, but this sequence asserts only that closing started. Put the assertion after await context.close() when your contract is completed teardown. If you need to test the transitional state of Playwright itself, isolate that as a Playwright compatibility test rather than using it as ordinary suite cleanup.
Roll the guard into an existing suite without changing every test
Start with an inventory of direct context creation. A text search is more useful than adding assertions randomly:
rg -n "browser\.newContext|launchPersistentContext" tests e2e fixtures
rg -n "context\.close|browser\.close|page\.close" tests e2e fixturesClassify each creation site as test-owned, fixture-owned, worker-owned, or application-harness-owned. Then pair every creation site with one closing owner. The migration is not complete merely because every file contains a close call; duplicated ownership is as dangerous as missing ownership.
Move one high-risk factory behind an owned fixture first. Good candidates record video or HAR, create multiple pages, or have historically produced target-closed failures. Keep the existing test API stable by exposing the same page object from the new fixture if necessary. Once its teardown assertion stays clean in CI, migrate the next factory.
Treat worker-scoped contexts as a separate design, not a faster version of the test-scoped fixture. Their owner lives for several tests, so isClosed() should remain false between those tests and become true only when the worker fixture tears down. A per-test closure assertion would destroy the shared session on the first case. If reuse is intentional, add checks for the state that must be reset between tests, then keep the final close assertion in the worker fixture that created the context.
Worker reuse saves context creation time but spends isolation. Cookies, permissions, routes, opened pages, and application storage can survive unless the framework resets them. Do not move to worker scope merely to reduce a CI duration graph. First show that the suite has a complete reset contract and that shared state is part of the intended test architecture.
Also separate leak detection from process shutdown. When the Playwright worker exits, the shared browser can make an unclosed context disappear. An assertion performed only after browser shutdown will therefore report a closed handle without proving the context owner acted. Run ownership checks before the enclosing browser is closed, and record the owner's close marker. The outer process is a safety net, not evidence of correct fixture teardown.
Add a narrow framework test that proves both sides of the contract. One case should use the fixture and verify its context remains open during the test. The fixture itself verifies closure after use. A second case should exercise a borrowed-context helper and prove it does not close the context. This is more valuable than repeating expect(context.isClosed()).toBe(false) in every product test.
For CI, retain failure evidence and keep the version explicit in the lockfile. A minimal configuration can make fixture errors and artifacts available without changing pass criteria:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
reporter: process.env.CI
? [['line'], ['html', { open: 'never' }]]
: [['list'], ['html', { open: 'never' }]],
use: {
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
});Video retention increases disk use and upload time. Enable it for the projects where closure defects are under investigation, then revisit the setting after the fixture boundary is stable. A lifecycle assertion should reduce uncertainty, not become a permanent excuse to retain every large artifact.
Know when not to assert context closure
Skip the assertion when your code does not own the context. A page object, login helper, or API wrapper that receives BrowserContext should not dictate its terminal state. Assert that the resource remains usable if accidental closure is the risk, and leave final teardown to the caller.
Do not call isClosed() repeatedly as a wait strategy. Await the operation responsible for closure. Polling a synchronous flag adds latency and can still cross the artifact-flush boundary too early.
Avoid making isClosed() === true the only definition of graceful cleanup. A crashed browser and an intentional context close can both satisfy it. Pair the value with the resolved close promise and, when cause matters, an owner marker plus browser connection state.
Do not manually close Playwright Test's normal context fixture at the end of every test. That duplicates runner teardown, scatters infrastructure logic across product tests, and can interfere with artifact handling. A custom owned context is the right place for explicit lifecycle checks.
Finally, do not turn the assertion into a soft expectation. A leaked context is framework state corruption, not an optional visual mismatch. If the owning fixture cannot close its resource, fail that attempt with the close error and preserved lifecycle evidence. The concrete cost is a red test after the product assertion passed, but that red result is honest: the attempt did not complete its infrastructure contract.
// 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.
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.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
What does BrowserContext.isClosed() actually prove?
It returns `true` when the context is closing or has already closed. The value is a synchronous state snapshot, so it does not replace awaiting the promise returned by `context.close()`.
Does isClosed true mean videos and HAR files are fully saved?
No. A closing context can already return `true`; await `context.close()` before consuming artifacts. The assertion is useful as an ownership check after that completion boundary, not as an artifact-flush signal by itself.
Can I close Playwright Test's built-in context fixture myself?
Avoid doing that in ordinary tests because the runner owns and tears down its `context` fixture. Create a custom context fixture when the test needs explicit close timing or a teardown assertion.
Why does TypeScript say BrowserContext has no isClosed method?
`BrowserContext.isClosed()` was added in Playwright 1.59. Check the project-local version with `npx playwright --version`, then upgrade all Playwright packages together before using the API.
How can I tell an expected context close from a browser crash?
Record your intended close before calling it and listen for the browser's `disconnected` event. Both situations can close a context, so `isClosed()` alone cannot identify the cause.
RELATED GUIDES
Continue the learning route
GUIDE 01
Playwright Java JUnit BrowserContext Isolation Architecture
Playwright Java junit browser context architecture: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation.
GUIDE 02
Assert CSS Pseudo-Elements with Playwright
Learn Playwright toHaveCSS pseudo element assertion with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.
GUIDE 03
20 Playwright Context, Page, Popup, and Frame Interview Scenarios
Solve 20 senior Playwright browser topology scenarios covering context isolation, multiple pages, popups, frames, permissions, events, and cleanup.
GUIDE 04
Playwright Agentic Browser Automation and Evidence Guide
A practical guide to Playwright agentic browser automation evidence, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 05
Choose Playwright Browser Channels for Release Confidence
Master Playwright browser channels with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.