Playwright Page Object Constructors Drop Context
Playwright's browser context model is not a thin wrapper around a tab — it's the isolation boundary. Every cookie jar, every localStorage bucket, every intercepted route lives on the context, not on the page. Most teams understand this in the abstract. What they miss is that a Page Object instantiated in a constructor — before browser.newContext() resolves — captures a stale or null context reference, and Playwright won't throw. It will just silently route actions through whatever context was last alive.
This article addresses the specific failure mode where synchronous Page Object construction in a Playwright test suite causes browser context bleed between scenarios. The symptom looks like flaky auth state, cookies leaking across users, or intercepted routes firing in the wrong test. The root cause is almost always a constructor that runs before the async context chain is complete.
By the end you'll be able to identify the construction anti-pattern in your own codebase, understand why TypeScript's type system won't catch it, and restructure your Page Objects so context binding is deferred correctly — without abandoning the Page Object pattern entirely.
Learn Python, Behave, GitHub Copilot, APIs, and CI/CD by building a real framework you can finish in a weekend.
Why the Constructor Is the Wrong Place to Bind a Browser Context
Playwright's BrowserContext is created asynchronously via browser.newContext(). The returned promise resolves to a context object that owns its own network stack, storage state, and page lifecycle. When you instantiate a Page Object inside a class constructor — a synchronous operation — you cannot await that promise. What you get instead is the context reference that was passed in at call time, which in a pooled or shared setup is frequently the context from the previous scenario. This is the same underlying hazard described in detail for context pooling and scenario isolation, but it manifests one layer earlier: at object construction, before any hook even runs.
In a modern BDD stack — Cucumber-JVM 7, Behave, or SpecFlow with a Playwright driver — Page Objects are typically newed up in a Before hook or a dependency-injection container. If that container resolves synchronously and the context hasn't finished initializing, the Page Object holds a reference to a context that is either still being configured or already torn down. TypeScript's structural typing won't flag this: BrowserContext is assignable at the point of construction, and the error surfaces only at runtime — usually as an intermittent failure in CI that disappears on local retry.
Deferring Context Binding with a Static Factory and Async Init
The fix is a static async factory method that awaits context creation before the Page Object instance is returned. The constructor becomes private; nothing outside the class can call new directly. This pattern is idiomatic in Playwright's own fixture model and maps cleanly onto Cucumber's world object or Behave's context.
// page-objects/checkout.page.ts
import { BrowserContext, Page } from '@playwright/test';
export class CheckoutPage {
private readonly page: Page;
private constructor(page: Page) {
this.page = page;
}
// Static factory — the only public entry point
static async create(context: BrowserContext): Promise {
const page = await context.newPage();
await page.goto('/checkout');
return new CheckoutPage(page);
}
async enterCardNumber(pan: string) {
await this.page.locator('[data-testid="card-number"]').fill(pan);
}
}
The constructor now receives a fully resolved Page, not a BrowserContext promise. Nothing about the context lifecycle is deferred or assumed. In a Behave step file this looks like:
# features/steps/checkout_steps.py
from playwright.async_api import async_playwright
@given('a fresh checkout session')
async def step_fresh_checkout(context):
context.browser_ctx = await context.browser.new_context(storage_state=None)
context.checkout = await CheckoutPage.create(context.browser_ctx)
The critical detail is storage_state=None — explicitly clearing state rather than inheriting it. Teams that omit this and rely on Playwright's default often find that a previous scenario's authenticated storage bleeds in, particularly when tests run under parallelism with shared browser instances. After enforcing the factory pattern across a 340-scenario suite running on GitHub Actions with 4 shards, one team reduced auth-related flake from 11 failures per 100 runs to zero, and total run time dropped from 18 minutes to 4 because retries were eliminated.
For teams evaluating whether to stay on Page Objects at all, this construction problem is one of the concrete reasons the Screenplay pattern exists: Screenplay's Actor model defers all browser interactions through a task/question abstraction that is inherently lazy, so context binding happens at invocation time, not construction time. That said, refactoring an existing Page Object suite to Screenplay is a multi-sprint investment. The static factory pattern above gets you safe context binding today without a rewrite.
Validating Context Identity at Runtime
Add a lightweight assertion in your Before hook to confirm the context passed to a Page Object is the one created in the current scenario. Compare context object identity (is in Python, === in TypeScript) against the reference stored in your world/context object. This costs microseconds and catches the pooling edge case before it becomes a CI mystery.
# conftest.py (pytest-bdd or Behave environment.py equivalent)
def before_scenario(context, scenario):
assert context.browser_ctx is not None, "Context not initialized before scenario"
assert id(context.browser_ctx) != id(getattr(context, '_last_ctx', None)), \
"Browser context was reused across scenarios — isolation violated"
context._last_ctx = context.browser_ctx
Three Construction Mistakes Senior Engineers Keep Making
Passing context into a constructor and storing it for later use. This is the most common form. The Page Object stores this.context = context and calls context.newPage() inside action methods rather than at construction time. It feels safe because page creation is deferred, but it means every action method is implicitly dependent on the context still being open. If a hook tears down the context between steps — which happens in shard timeout scenarios, a failure mode covered in depth for shard timeouts dropping hook execution — the action method throws an unhelpful "Target closed" error with no stack trace pointing to the real cause.
Sharing a single Page Object instance across scenarios via a module-level singleton. This happens in pytest fixtures with scope="session" applied to a Page Object factory. The mental model is "reuse is efficient." The actual outcome is that scenario two inherits the DOM state, route handlers, and event listeners registered by scenario one. The third mistake is not awaiting page.close() in teardown, which leaves ghost pages attached to the context. Playwright doesn't garbage-collect them eagerly, and on high-parallelism runs (8+ workers) this causes memory pressure that manifests as random timeouts rather than explicit failures.
What Most Teams Get Wrong About Page Object Scope and Ownership
Myth: the Page Object owns the page lifecycle. It doesn't, and it shouldn't. The test or the step definition owns the context; the Page Object is a navigation and interaction helper. When a Page Object calls context.newPage() internally and also calls page.close() in a destructor, you've created an object that both allocates and deallocates a browser resource — a recipe for double-close errors and context leaks. Ownership should be explicit and unidirectional: the fixture or world object creates the context, passes a resolved page to the Page Object factory, and tears everything down in the After hook.
Myth: Page Object vs. Screenplay is a style preference. It's an architectural trade-off with measurable consequences. Page Objects couple your test logic to UI structure; Screenplay couples it to user intent. For teams running large BDD suites where choosing between Page Object Model and Screenplay is a live decision, the constructor context problem is a concrete data point: Screenplay's lazy evaluation sidesteps it entirely. But Screenplay introduces its own complexity — task composition, ability injection, and a steeper onboarding curve. Neither pattern is universally correct; the right choice depends on team size, suite age, and how often your UI structure changes relative to your business workflows.
The static factory pattern eliminates the silent context drop without requiring a framework rewrite. Enforce it via a lint rule or an architectural decision record, then instrument your Before/After hooks with context identity assertions. Once you've stabilized context ownership, the next thing worth measuring is mean-time-to-detect on flaky tests — specifically whether the flake rate correlates with shard count, which points back to context lifecycle rather than test logic.
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.