Playwright Browser Has Been Closed: Fix Mid-Test Deaths
Check fixture teardown order first — most cases are premature closes.
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
- ✓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
- 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
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 — kills its pages instantly, context.close() ends the world. Most 'browser has been closed' errors are actually destroyed contexts or pages, with the browser process itself perfectly alive.browser.close()
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.
close() calls before theorizing about crashes. Your own teardown is the prime suspect in most closed-browser cases.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.
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.
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.
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 calls outside fixture teardown files; name helper parameters close()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.
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.
The Shared Context That Let Every Test Kill the Next One
- 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.
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.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.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.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.| File | Command / Code | Purpose |
|---|---|---|
| tests | from playwright.sync_api import sync_playwright | Browser, Context, Page |
| tests | @pytest.fixture | Teardown Order |
| tests | from playwright.sync_api import sync_playwright | Video and Trace |
| tests | from playwright.sync_api import Page | Fixture Patterns That Keep the Browser Alive |
| tests | from playwright.sync_api import sync_playwright | Real Crashes |
Key takeaways
Common mistakes to avoid
5 patternsClosing the page inside a fixture before the test runs
Sharing one context across tests and closing it early
Fire-and-forget closes that race the next step
Arming video/trace after the failure already happened
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
Interview Questions on This Topic
What causes 'browser has been closed' in Playwright?
Frequently Asked Questions
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
That's Playwright. Mark it forged?
5 min read · try the examples if you haven't