Browser Context Pooling Breaks Playwright Isolation

Playwright's browser context model is one of its genuine architectural advantages over Selenium 4 — lightweight, fast, and composable. Most teams discover context pooling through performance benchmarks, see suite run time drop from 18 minutes to 4, and ship it. What they don't audit is whether scenario isolation survived the optimization. It usually didn't.

The problem is structural. BDD frameworks like Cucumber-JVM 7, Behave, and SpecFlow assume that each scenario starts with a clean slate. Browser context pooling violates that assumption by design: cookies, localStorage, IndexedDB state, and even service worker registrations can bleed from one scenario into the next. The failure mode is a flaky suite that passes in isolation and fails in parallel — the worst kind of non-determinism to debug.

By the end of this article you'll know exactly where the leak occurs in a typical Playwright + BDD setup, how to instrument it, and what the correct pooling boundary looks like. The patterns apply whether you're on Python/Behave, TypeScript/Playwright-BDD, or a custom Cucumber runner.

Learn Modern API Test Automation

Build real-world automation skills with Python, BDD, AI, APIs, CI/CD, and hands-on courses.

Learn more

What Browser Context Pooling Actually Does to Your BDD Lifecycle

A Playwright BrowserContext is the isolation unit — it maps roughly to a fresh browser profile: separate cookies, separate storage, separate permissions. A Browser instance (Chromium, Firefox, WebKit) is the expensive process. Pooling reuses the Browser across scenarios while creating a new BrowserContext per scenario. Done correctly, this is sound. The failure happens when teams pool the context itself, not just the browser, either explicitly or because their fixture wiring creates one context per worker rather than one context per scenario.

In a BDD scenario lifecycle, the context must be scoped to the scenario boundary — created in a Before hook, torn down in an After hook, never shared across scenario boundaries even within the same feature file. This is the same principle that governs how scenario context passes between hooks and step definitions in any BDD framework: the scenario is the atomic unit of state. When the browser context outlives the scenario, every subsequent scenario inherits whatever the previous one left behind — authenticated sessions, feature flags toggled via localStorage, network intercepts still registered.

Instrumenting and Fixing the Pooling Boundary in Playwright BDD

The fastest way to confirm leakage is to write a canary scenario pair. Scenario A sets a localStorage key; Scenario B asserts it's absent. Run them in order in the same worker. If Scenario B fails, your context is shared.

# features/isolation_canary.feature
Feature: Context isolation canary

  Scenario: A writes to localStorage
    Given I navigate to the app
    When I set localStorage key "canary" to "poisoned"
    Then the key "canary" should exist in localStorage

  Scenario: B should not see A's localStorage
    Given I navigate to the app
    Then the key "canary" should not exist in localStorage

If Scenario B fails, trace the fixture. In a TypeScript Playwright-BDD setup the leak is almost always here — a shared context fixture scoped to "worker" instead of "test":

// fixtures.ts — BROKEN: context outlives the scenario
import { test as base, BrowserContext } from "@playwright/test";

export const test = base.extend<{ context: BrowserContext }>({
  context: [async ({ browser }, use) => {
    const ctx = await browser.newContext();
    await use(ctx);
    // ctx.close() never called between scenarios in worker scope
  }, { scope: "worker" }],  // ← this is the bug
});
// fixtures.ts — CORRECT: context scoped to test (scenario)
export const test = base.extend<{ context: BrowserContext }>({
  context: async ({ browser }, use) => {
    const ctx = await browser.newContext({
      storageState: undefined,   // explicit — no inherited state
    });
    await use(ctx);
    await ctx.close();           // torn down after every scenario
  },
  // default scope is "test" — do not override it
});

The browser fixture stays at "worker" scope — that's the correct pooling boundary. You pay the Chromium launch cost once per worker, not once per scenario. On a 200-scenario suite with 4 workers, this keeps run time near 4 minutes while restoring full isolation. The storageState: undefined line is defensive but worth it: if a previous context somehow persisted a storage snapshot on disk, Playwright will not load it.

In Python/Behave the equivalent pattern lives in environment.py. A common mistake is creating the context in before_feature rather than before_scenario:

# environment.py — CORRECT
from playwright.sync_api import sync_playwright

def before_scenario(context, scenario):
    context._pw = sync_playwright().start()
    context._browser = context._pw.chromium.launch(headless=True)
    context._page_context = context._browser.new_context()
    context.page = context._page_context.new_page()

def after_scenario(context, scenario):
    context._page_context.close()   # wipes cookies, storage, intercepts
    context._browser.close()
    context._pw.stop()

Yes, this launches a new browser per scenario in Behave's single-process model. If that's too slow, move to a persistent browser with per-scenario context close — but never skip context._page_context.close(). The same isolation failure that plagues background steps that silently corrupt scenario isolation is compounded here: Background steps run inside the same leaked context, so their side effects accumulate across the entire feature file.

Pooling Mistakes Senior Engineers Still Ship

The first mistake is trusting framework defaults without reading the scope documentation. Playwright's built-in context fixture is test-scoped and correct. The moment a team writes a custom fixture to inject auth state or base URL configuration, they introduce scope as a decision point — and worker scope is tempting because it looks like an obvious optimization. The result is a suite that passes in serial mode (--workers=1) and fails randomly in parallel, because isolation failures only surface when two scenarios share a worker and execute back-to-back.

The second mistake is relying on page.goto() to reset state. Navigating to a new URL does not clear cookies, does not clear localStorage, and does not cancel pending network intercepts registered via page.route(). Teams that "reset" by navigating to a logout URL are actually depending on application logic to clear state — which is a test that tests the logout flow, not a fixture that guarantees isolation. The correct reset is context.close() followed by browser.newContext(). There is no shortcut.

What Teams Get Wrong About Playwright vs. Selenium for BDD

A persistent myth in the BDD + browser automation space is that Playwright is always the right choice over Selenium 4 for scenario-level testing. The real answer is use-case dependent. Use Playwright when your stack is TypeScript-first, your team can own fixture architecture, and you need network interception or multi-tab scenarios. Use Selenium 4 (with BiDi) when you need real cross-browser coverage on legacy IE-mode targets, when your org already has a mature Selenium Grid, or when your Cucumber-JVM 7 step library is deeply invested in WebDriver APIs. Python/Selenium BDD vs. Playwright TypeScript is not a quality question — it's a team capability and maintenance cost question. Playwright's context model is cleaner, but only if you wire it correctly.

The second misunderstanding is that context isolation is a Playwright-specific concern. It isn't. The same state-bleed problem exists in Cypress 13 (shared browser session across specs unless testIsolation: true is set, which it is by default since Cypress 12 — but teams on older configs still hit this), in Selenium with shared WebDriver instances, and in AI-driven test agents where ambient context corrupts step boundaries across tool calls. The isolation contract is a BDD framework concern first, a browser automation concern second. Fix the fixture boundary and the framework will handle the rest.

If you fix the context scope boundary today, the next thing worth measuring is mean-time-to-detect on flaky tests — specifically whether the flakiness rate drops after the change. Instrument your CI with a retry_count metric per scenario (GitHub Actions supports this via test result annotations; Jenkins via the JUnit XML flaky attribute). A pooling fix that doesn't move the flakiness metric means the leak is elsewhere — and the canary scenario pattern above is still your fastest diagnostic tool.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles