Home › Testing › Playwright Browser Has Been Closed: Fix Mid-Test Deaths
Intermediate 5 min · September 23, 2026

Playwright Browser Has Been Closed: Fix Mid-Test Deaths

Check fixture teardown order first — most cases are premature closes.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Notes here come from systems that actually shipped.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 11 min
  • ✓A Playwright Python project with pytest fixtures you can edit
  • ✓Basic Python async/sync and yield-fixture concepts
  • ✓Ability to run tests headed on your machine for visual debugging
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Browser-has-been-closed means the action's target (browser, context, or page) no longer exists when the action runs
  • The usual killer is your own teardown: fixture closes before yield returns, or shared state closed by another test
  • Fix ownership: one context per test, close only after yield in reverse setup order, await every close
  • For real crashes, run headed to watch it happen and arm video plus tracing before the risky phase
✦ Definition~90s read
What is Playwright Browser Has Been Closed Mid-Test?

The 'browser has been closed' error means an action targeted a browser, context, or page that no longer exists. Playwright structures browser state as a hierarchy — the browser process owns contexts (isolated sessions with separate storage), contexts own pages (tabs) — and closing any level destroys everything beneath it.

★
Think of the browser as a library, contexts as study rooms, and pages as desks.

The error message names the symptom uniformly, but the causes split into two families: premature destruction by your own code (teardown before yield returns, shared state closed by neighbors, unawaited closes racing actions) and genuine process death (memory exhaustion, container kills, driver faults).

Pytest fixtures manage lifetimes with yield: setup runs before it, the test body runs during it, teardown runs after. Every close belongs in the post-yield half, in reverse creation order, awaited to completion — and browser-state fixtures belong at function scope so each test gets an isolated world no neighbor can destroy.

Helpers borrow pages they must never close. Most reported incidents resolve to violations of these rules, discoverable by reading fixture code rather than debugging browsers.

For the genuine crashes, Playwright offers headed execution (watch the death happen), video recording (see the visual sequence to the end), and tracing (replay the action, network, and console timeline). All three must be armed before the risky phase because they attach to the context a crash destroys.

With ownership discipline preventing the false alarms and recorders capturing the real deaths, vanishing browsers become routine, diagnosable, ownable events.

Plain-English First

Think of the browser as a library, contexts as study rooms, and pages as desks. Your test sits at a desk in a room — but the janitor (teardown code) locks the room early, or another student (another test) returns the shared room key mid-session. Your next trip to the desk fails because the room is gone, not because the desk broke. The fix is scheduling the janitor after closing time and giving each student their own room.

Your test fills a form, clicks submit, and then — browser has been closed. The window is gone, the next action has nowhere to land, and the error reads like the browser simply gave up on life. Re-run and it passes. Run the full file and it fails on a different test. The browser, it seems, closes whenever it pleases.

It does not. In the large majority of cases, your own code closed it: a fixture whose teardown runs too early, a shared context one test destroys while another still needs it, an unawaited close that wins a race against the next action. The remaining cases are real deaths — crashes from exhausted memory, killed processes in containers — but they wear the identical message, which sends teams chasing browser bugs for what are actually ordering bugs.

This article separates murder from natural causes. You will learn the browser-context-page ownership chain, the yield-based teardown pattern that keeps resources alive exactly as long as tests need them, headed-mode debugging that shows the death as it happens, and video/trace capture that preserves evidence past the crash. Vanishing browsers become diagnosable events with named owners.

Browser, Context, Page: Who Owns What

Playwright organizes browser state as a strict hierarchy: the browser (the process) owns contexts (isolated sessions, like incognito profiles with separate cookies and storage), and each context owns pages (tabs). Closing any level destroys everything beneath it — context.close() kills its pages instantly, browser.close() ends the world. Most 'browser has been closed' errors are actually destroyed contexts or pages, with the browser process itself perfectly alive.

In pytest-playwright projects, fixtures manage this hierarchy: a session fixture may own the browser while each test gets a fresh context and page. The failure mode is always ownership confusion — code acting on a level it does not own, or after its owner destroyed it. A helper that closes 'its' page destroys a tab the fixture still plans to use; a teardown that closes the context while a page assertion is queued pulls the floor out mid-step.

Learn to read the error as a question about owners: which level vanished, who owned it, and who destroyed it early? The answer is usually one fixture away. Crashes destroy from the bottom up with no owner at fault; teardown bugs destroy from an explicit close call you can find with grep. Start every investigation by listing the closes — the guilty one is almost always in your own codebase.

tests/ownership_chain.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()      # the process (library)
    context = browser.new_context()    # an isolated session (study room)
    page = context.new_page()          # a tab (desk)
    page.goto('https://example.com')
    page.get_by_role('button', name='Save').click()
    # Closing cascades DOWNWARD:
    # context.close() kills its pages; browser.close() kills everything.
    page.close()
    context.close()
    browser.close()

# The error means one of these was gone before the action ran.
📊 Production Insight
Grep for close() calls before theorizing about crashes. Your own teardown is the prime suspect in most closed-browser cases.
🎯 Key Takeaway
Closes cascade downward — read every closed-browser error as a question about which owner acted early.

Teardown Order: Close After Yield, in Reverse

Pytest fixtures with yield split time in two: everything before the yield is setup, the test body runs during the yield, and everything after it is teardown. Code placed before the yield that destroys state — a close, a logout, a navigation away — sabotages the test before its first line. The bug reads as a browser problem because the failure surfaces in the test, but the cause sits in the fixture's setup half.

Correct teardown mirrors setup in reverse: pages close before their contexts, contexts before the browser, each close awaited (or returned) so destruction completes deterministically. Unawaited closes in sync code still race subsequent steps because browser-side teardown is asynchronous — the next test can start acting while the previous world is half-destroyed. Deterministic order means deterministic failures, which means fixable failures.

Scope completes the pattern. Browser process fixtures can be session-scoped (launch once, share the cost), but contexts and pages belong at function scope — one fresh isolated world per test. Function-scoped state cannot be poisoned by neighbors: no test can close, pollute, or navigate another test's pages. Sharing contexts across tests saves seconds of setup and spends hours of debugging; the trade is never worth it outside explicitly documented, read-only scenarios.

tests/conftest.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import pytest

# BAD: close runs during setup — the test gets a dead page
@pytest.fixture
def dead_page(browser):
    context = browser.new_context()
    page = context.new_page()
    context.close()   # runs BEFORE the test body!
    yield page        # test receives a page in a closed context

# GOOD: setup -> yield (test runs here) -> teardown
@pytest.fixture
def logged_in_page(browser):
    context = browser.new_context()
    page = context.new_page()
    page.goto('https://example.com/login')
    page.get_by_label('Email').fill('qa@example.com')
    page.get_by_role('button', name='Sign in').click()
    yield page        # <-- the test body executes during this yield
    page.close()      # teardown: reverse order of creation...
    context.close()   # ...pages before contexts, every close awaited
📊 Production Insight
Fixture scope is a blast-radius decision. Function scope contains every failure to the test that caused it.
🎯 Key Takeaway
Setup before yield, test during yield, teardown after — reversed creation order, function-scoped state.

Headed Mode: Watch the Browser Die Live

When the browser genuinely dies, you want to watch it happen. Headed mode (headless=False, optionally with slow_mo=100) puts the window on screen so the death is visible: a sudden vanish mid-action means a crash — out-of-memory kill, sandbox violation, driver mismatch — while an orderly absence from the start means teardown never delivered a live page. That single visual distinction routes the investigation correctly in seconds.

Headed runs also perturb timing just enough to separate races from certainties. A failure that vanishes under headed observation was likely a close racing the next action — the slower headed pace lets the action win. A failure that persists headed is deterministic: same line, same owner, same early close. Use the difference diagnostically rather than superstitiously.

Keep a headed-debug recipe in the team's runbook: the exact launch flags, how to run a single test headed, and where to look (window behavior, console output, exit code). Browser deaths are stressful precisely because they feel environmental; a fixed recipe converts panic into procedure. The goal is not to run headed always — it is to make the times you need it count. That slower headed pace is diagnostic data, not superstition — use what it tells you.

📊 Production Insight
A documented headed-debug recipe turns browser-death panic into a three-minute procedure anyone can run.
🎯 Key Takeaway
Headed mode makes the death visible — sudden vanish means crash, orderly absence means teardown.

Video and Trace: Evidence Past the Crash

Video (record_video_dir) and tracing (tracing.start/stop) are the flight recorders for browser deaths — but both attach to the context, which is exactly what a crash destroys. Configured after the failure, they capture nothing; the recorder must be running before the plane takes off. Arm them at context creation for any suite phase known to be crash-adjacent: canvas-heavy pages, large uploads, memory-hungry dev builds.

Each recorder answers different questions. Video shows the visual sequence — what the page looked like frame by frame until the end — which distinguishes app freezes (page stuck, browser alive) from process deaths (frames stop because nothing renders). Tracing records the action timeline with network, console, and DOM snapshots per step, so you can see the last successful action and the exact call that met the closed browser. Together they usually name the cause outright: the leaking canvas, the 766 MB heap, the navigation to the crashing URL.

Treat evidence as infrastructure with retention policy. Keep video on for retries in CI (record_video_dir plus retain-on-failure semantics in your runner config) and tracing for the flaky subset, so every closed-browser report arrives with its black box attached. Debugging from artifacts takes one pass; debugging from the error string alone takes a week of re-runs hoping to witness the crash.

tests/capture_evidence.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    # Arm artifacts AT CREATION — before anything risky runs
    context = browser.new_context(record_video_dir='videos/')
    context.tracing.start(screenshots=True, snapshots=True, sources=True)
    page = context.new_page()
    try:
        page.goto('https://example.com/canvas-heavy')
        page.get_by_role('button', name='Render').click()
        # ... risky phase under observation ...
    finally:
        # Stop tracing into a file even if the test body raised
        context.tracing.stop(path='trace.zip')
        page.close()
        context.close()  # video finalizes on context close
    browser.close()

# Replay: python -m playwright show-trace trace.zip
# Videos land in videos/ named per page.
⚠ Arm Evidence Before the Crash
Video and tracing attach to the context — the exact thing a crash destroys. Configure them at context creation, before the risky phase, or the evidence dies with the patient. Post-mortem configuration is no configuration.
📊 Production Insight
Flight recorders convert crash investigations from stochastic stakeouts into single-pass diagnoses. Retention is cheap; re-runs are not.
🎯 Key Takeaway
Video shows the visual end, tracing shows the action timeline — arm both at context creation.

Fixture Patterns That Keep the Browser Alive

Ownership discipline is the policy that ends closed-browser errors: fixtures create and destroy, helpers borrow. A helper receiving a page may act on it — click, fill, assert — but must never close it, navigate it away from under the caller, or stash it in module state for later. The fixture that yielded the page is its sole owner and sole destroyer, after the yield, in reverse order.

Enforce the rule mechanically. Lint or review for close() calls outside fixture teardown files; name helper parameters page to signal borrowing; keep page references out of module globals and class attributes where lifetimes escape test boundaries. When sharing is genuinely needed (an expensive authenticated context for read-only specs), document the owner in the fixture's docstring and forbid closes in every consumer — shared ownership without written rules always ends with someone closing early.

The payoff compounds. Ownership-clean suites isolate failures: a crash or close affects exactly the test that caused it, with artifacts pointing at one owner. Ownership-dirty suites smear every teardown bug across neighbors, producing the moving-failure reports that waste whole sprints. Borrow, do not own; yield, then destroy; write down the exceptions. Three sentences that retire an entire error category.

tests/ownership_helpers.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import pytest
from playwright.sync_api import Page

# Helpers BORROW pages: use them, never close them
def save_form(page: Page) -> None:
    page.get_by_test_id('save-btn').click()
    page.get_by_text('All changes saved').wait_for()
    # NO page.close() here — the fixture owns the page

@pytest.fixture
def app_page(browser):
    context = browser.new_context()
    page = context.new_page()
    page.goto('https://example.com/app')
    yield page
    page.close()
    context.close()

def test_rename(app_page: Page):
    app_page.get_by_label('Title').fill('Q3 plan')
    save_form(app_page)  # borrows the page, returns it alive
    assert app_page.get_by_label('Title').input_value() == 'Q3 plan'
📊 Production Insight
Ownership rules are the cheapest reliability policy a test suite can adopt: three sentences, enforced in review, ending a whole error class.
🎯 Key Takeaway
Fixtures own, helpers borrow — closes live only in post-yield teardown, and sharing is documented or forbidden.

Real Crashes: Memory, Containers, and Drivers

Sometimes the browser really dies, and ownership discipline only proves your innocence — it does not prevent the crash. Real deaths have physical causes: a tab's memory grows until the OS kills the renderer, the container's tiny /dev/shm starves Chromium's shared buffers, a browser and driver version mismatch crashes on launch, or a missing sandbox flag kills the process running as root in CI. Each leaves signatures — OOM killer lines, nonzero exit codes, launch-time failures — that look nothing like teardown bugs once you know where to look.

Containers are the most common crime scene. Default Docker /dev/shm is 64MB, far below what Chromium wants for heavy pages, and the symptom is tabs dying mid-render with no Python exception until the next action touches the corpse. Launch with --disable-dev-shm-usage to spill buffers to /tmp, run as non-root or pass --no-sandbox where policy allows, and size container memory from measured peak usage rather than defaults. These three moves resolve the majority of works-locally-dies-in-CI browser deaths.

Memory discipline inside tests matters too: close pages you open, avoid accumulating contexts across long sessions, and split canvas- or video-heavy flows into smaller tests so each crash's blast radius is one behavior. When a death persists, capture the exit path — browser process logs, container OOM events, the trace's final actions — and fix the resource, not the test. A browser that dies of starvation needs feeding, not retrying.

tests/container_safe_launch.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    # Container-safe launch for CI images (tiny /dev/shm, root user)
    browser = p.chromium.launch(args=[
        '--no-sandbox',            # required when running as root in Docker
        '--disable-dev-shm-usage', # spill buffers to /tmp, not 64MB /dev/shm
    ])
    context = browser.new_context()
    page = context.new_page()
    page.goto('https://example.com/heavy')
    # ... test body ...
    page.close()      # free renderer memory as you go in long flows
    context.close()
    browser.close()
📊 Production Insight
Resource-caused deaths have physical signatures — OOM lines, shm exhaustion, launch crashes. Read the machine's logs before retrying the test.
🎯 Key Takeaway
Real crashes come from memory, shm, sandboxes, and drivers — size the container, pass the flags, and feed the browser.
● Production incidentPOST-MORTEMseverity: high

The Shared Context That Let Every Test Kill the Next One

Symptom
Different tests failed each run with browser-has-been-closed, always tests running after another test in the same worker. Solo runs passed; full-file runs failed — the signature of cross-test contamination rather than browser instability.
Assumption
The team assumed fixtures were magic boxes that 'handle the browser' and never read the teardown order. Nobody realized the shared context was module-scoped, or that test one's cleanup was every later test's catastrophe.
Root cause
A 'performance optimization' had hoisted the browser context to module scope so tests would share it, but the teardown still closed the context after each test. Test one passed, closed the shared context, and test two received pages in a dead room. Ordering shuffles and worker counts moved the failures between tests, disguising a deterministic ownership bug as infrastructure flakiness.
Fix
They switched browser-state fixtures to function scope (fresh context per test), moved all closes post-yield in reverse order with awaits, and armed video plus tracing on the retry pipeline. Closed-browser errors vanished, and the remaining genuine crash — a memory leak in a canvas-heavy page — was caught by the new video evidence within a week.
Key lesson
  • Shared mutable browser state is shared fate: one test's teardown becomes every later test's failure. Isolate by default.
  • Teardown code deserves the same review rigor as test code — it runs with the power to destroy every test's world.
  • Evidence infrastructure (video, trace) pays off beyond its target: arming it for one bug class catches the next for free.
Production debug guideFive checks that distinguish premature teardown from genuine crashes.5 entries
Symptom · 01
Fails at the same line on every run
→
Fix
Open the fixture and find the yield: any close() call before it runs during setup — move all closes after the yield. Order multiple teardowns reverse to setup (pages, then contexts, then browser) and await each. Re-run the single test; deterministic same-line failures should turn green.
Symptom · 02
First test passes, subsequent tests fail closed
→
Fix
Check fixture scope: module- or session-scoped browser state shared across tests means one test's teardown kills the next test's pages. Switch browser-state fixtures to function scope (one context per test) unless sharing is deliberate — and if deliberate, document the owner and forbid closes in tests.
Symptom · 03
Intermittent closes that move between tests and machines
→
Fix
Audit every close in fixtures and helpers for missing awaits, and run the suite headed (headless=False) to watch timing live. If headed passes while headless flakes, a close is racing the next action — awaiting it removes the race on all machines, fast and slow.
Symptom · 04
Browser window vanishes mid-test
→
Fix
Launch with headless=False plus slow_mo and watch the window: a sudden vanish mid-action indicates a crash (check memory, container limits, driver versions), while orderly disappearance at step boundaries indicates teardown. Pair with video/trace armed beforehand to keep the evidence.
Symptom · 05
No evidence survives the failure (nothing recorded)
→
Fix
Set record_video_dir on the context and wrap the risky phase with tracing.start() / tracing.stop(path=...) before running — then reproduce. The video shows the visual sequence to the crash; the trace shows the action, network, and console timeline. Fix the revealed cause (leak, killer, ordering) instead of re-running blind.
Browser-Has-Been-Closed Errors — Root Cause vs Fix at a Glance
Root CauseHow to ConfirmFixPrevention
Fixture closed the page/context too earlyFails at the same line every run; teardown code sits before the yield returnsClose only after yield; teardown in reverse setup orderReview fixtures for yield placement; keep closes post-yield
Shared context closed by another testFirst test passes, later ones fail; context is module-scoped or globalOne context per test; document any sharing explicitlyDefault to function-scoped fixtures for browser state
Async close raced the next actionIntermittent; passes on slow machines, fails on fast onesAwait every close; order teardowns deterministicallyNever fire-and-forget closes in fixtures or helpers
Crash destroyed the context (real browser death)Headed run shows the window vanish; trace ends mid-actionFix the crash cause (resource, navigation); capture video/traceArm artifacts before risky phases; monitor browser exit codes
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
testsownership_chain.pyfrom playwright.sync_api import sync_playwrightBrowser, Context, Page
testsconftest.py@pytest.fixtureTeardown Order
testscapture_evidence.pyfrom playwright.sync_api import sync_playwrightVideo and Trace
testsownership_helpers.pyfrom playwright.sync_api import PageFixture Patterns That Keep the Browser Alive
testscontainer_safe_launch.pyfrom playwright.sync_api import sync_playwrightReal Crashes

Key takeaways

1
Most closed-browser errors are premature teardown, not dead browsers
check fixture order first.
2
Ownership chain
browser owns contexts, contexts own pages; closes cascade downward.
3
Teardown runs after yield in reverse setup order, with every close awaited.
4
Headed mode shows the death live
vanishing window means crash, absent window means teardown bug.
5
Arm video and tracing before risky phases; post-crash configuration captures nothing.
6
Helpers borrow pages, fixtures own them
never close what you did not create.

Common mistakes to avoid

5 patterns
×

Closing the page inside a fixture before the test runs

Symptom
Every test using the fixture fails instantly — the browser was closed during setup, before the first test line executed.
Fix
Yield the page (or context) from the fixture and close only after the yield returns. The test body runs during the yield — everything after it is teardown, in reverse order of setup.
×

Sharing one context across tests and closing it early

Symptom
The first test passes and the rest fail with browser-closed, because teardown of test one destroyed the shared resource test two assumed alive.
Fix
Create one context per test (the default) or explicitly share with documented ownership. Never close a context in a helper while other tests still reference its pages.
×

Fire-and-forget closes that race the next step

Symptom
Intermittent closed-errors on fast machines where the close wins the race, and passes on slow ones — timing-dependent teardown chaos.
Fix
Await (or return) every close, and order teardowns reverse to setup: pages before contexts, contexts before the browser. Unhandled async closes race the next test's actions.
×

Arming video/trace after the failure already happened

Symptom
The failing run produces no recording — the crash destroyed the context the artifact depended on before capture started.
Fix
Attach video and tracing to the context that owns the failure (record_video_dir, tracing.start()), and start them before the risky phase. Evidence must be armed before the crash, not after.
×

Helper functions that close pages they do not own

Symptom
A cleanup helper closes the page mid-test and the next line fails — ownership confusion between borrowed and owned resources.
Fix
Guard shared helpers with liveness checks or restructure so each helper receives a live page it does not own. Helpers borrow pages; fixtures own them.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What causes 'browser has been closed' in Playwright?
Q02JUNIOR
Explain the browser, context, and page hierarchy.
Q03SENIOR
A fixture closes the page before yield. What happens and why?
Q04SENIOR
How do you debug a mid-test browser disappearance?
Q05SENIOR
What fixture policy prevents closed-browser errors suite-wide?
Q01 of 05JUNIOR

What causes 'browser has been closed' in Playwright?

ANSWER
The target of the action — browser, context, or page — no longer exists when the action runs. Usually a fixture closed it too early (teardown before yield returns) or another test closed shared state. I would check teardown order first, then look for crashes with a headed run.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What does 'browser has been closed' mean in Playwright?
02
Browser vs context vs page — which one actually closed?
03
How does headed mode help debug this?
04
Can video and trace capture a crash that kills the context?
05
What is the correct fixture teardown pattern?
06
The error says the browser closed — is the browser really dead?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Notes here come from systems that actually shipped.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Playwright. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Playwright Strict Mode Violation: Locator Resolved to N Elements
3 / 3 · Playwright