06 · Playwright Architecture Internals¶
Knowing why Playwright behaves the way it does — one process, a single WebSocket-driven protocol, auto-waiting baked into the client rather than the browser — makes its error messages and edge cases far less mysterious.
The out-of-process browser model¶
┌─────────────────┐ WebSocket / pipe ┌──────────────────┐
│ Python process │ ───────────────────────────────▶│ Browser process │
│ (your test) │◀─────────────────────────────── │ (Chromium/FF/WK) │
│ playwright client │ CDP / BiDi-like protocol │ actual rendering │
└─────────────────┘ └──────────────────┘
# unlike Selenium's WebDriver (HTTP request/response per
# command), Playwright keeps one persistent connection to a
# separate browser process and communicates over a
# message-based protocol — closer to how Chrome DevTools
# itself talks to the browser than to a REST API
Chromium and WebKit are driven through a Playwright-patched build with
extra automation hooks baked in; Firefox uses a similar patched build via a
juggler protocol layer. This is why playwright install downloads its own
browser binaries rather than reusing your system Chrome — the automation
hooks aren't present in a stock browser release.
Why auto-waiting lives in the client, not the browser¶
# before performing the click, the Playwright client itself:
# 1. resolves the locator to a live DOM node reference
# 2. re-checks the actionability conditions (attached,
# visible, stable, receives events, enabled) in a polling
# loop — this loop runs in the CLIENT, re-querying the
# browser each iteration, not as a single browser-side wait
# 3. only dispatches the actual click once all conditions
# hold on the same iteration
This is why a Locator is a description that gets re-resolved every time
you act on it — page.locator("...") alone does not query the DOM at all;
only calling .click(), .fill(), or an expect() on it does. This is also
why chaining a Locator (row.get_by_role(...)) is cheap and doesn't fail
even if row doesn't exist yet at the time you write that line — the actual
query happens lazily, at the moment of action.
Contexts vs. pages vs. browsers¶
Browser — one actual browser process (chromium.launch())
BrowserContext — an isolated "incognito-like" session: its own
cookies, localStorage, cache — cheap to create,
the recommended unit of test isolation
Page — one tab within that context
browser = playwright.chromium.launch() # expensive: ~200-500ms
context = browser.new_context() # cheap: a few ms
page = context.new_page() # cheap: a few ms
# this is why the standard advice is "one browser per test
# session (or worker), one fresh context per test" rather than
# launching a whole new browser per test — a browser process
# is the expensive resource; a context is deliberately cheap
# because it's the unit meant to be thrown away constantly
Strict mode: a deliberate design choice, not a limitation¶
# all "action" methods (click, fill, check, etc.) enforce
# strict mode: they throw if the locator resolves to more than
# one element, rather than silently acting on the first match —
# this traces back to the same underlying design principle as
# auto-waiting: prefer a loud, immediate, specific failure
# over a test that "usually" does the right thing
.first, .last, and .nth() exist specifically to opt out of strict mode
when multiplicity is genuinely expected — using them as a reflex fix for
every strict-mode error defeats the feature.
The protocol underneath: CDP, and why new_cdp_session exists¶
# Playwright's own driver already speaks CDP to Chromium
# internally for standard actions — new_cdp_session exposes a
# raw channel to that same protocol for capabilities Playwright's
# high-level API doesn't wrap (like network/CPU throttling,
# covered in Level 3) — you're not going around Playwright here,
# you're using the same transport it already relies on
Why headless and headed can behave subtly differently¶
# headless mode historically ran a different code path inside
# the browser (no real compositor/GPU pipeline by default) —
# modern Chromium's "new headless" mode (Playwright's default
# since v1.9x-era Chromium) closes most of this gap, but GPU-
# dependent rendering (WebGL, some video codecs, certain font
# hinting) can still differ — this is why a visual regression
# test flaking only in CI's headless run, never locally headed,
# is a real and known category of difference, not always a bug
# in the test
How It Actually Works¶
Going one layer deeper than the diagram above: the "Python process" box is
itself two processes, not one. sync_playwright() spawns a bundled Node.js
executable (shipped inside the playwright pip package) as a subprocess
and communicates with it over stdio pipes, using a lightweight
length-prefixed JSON-RPC-style framing Playwright defines for itself — this
inner channel is Playwright's own protocol, separate from CDP. That Node
process is the actual CDP client: it owns the WebSocket connection(s) to
the browser process(es) and does the real protocol translation. Every
Python call you make is serialized once (Python → Node, over stdio) and
then a second time if it results in a browser command (Node → browser, over
the CDP WebSocket) — two hops, not one, which is why the driver process
shows up in your OS process list alongside the browser whenever a
Playwright script is running.
This two-hop design is also why new_cdp_session is such a clean escape
hatch rather than a hack: the Node driver already maintains the CDP
WebSocket and already multiplexes commands from multiple logical callers
onto it (one for each page/context Playwright itself is managing) — asking
for a raw CDP session just registers your commands on that same already-
open connection with their own session ID, rather than opening a
competing, separate connection to the browser. The browser-side CDP server
itself has no concept of "Playwright's official API" versus "a raw CDP
call you made yourself" — both are ordinary commands on the same protocol,
which is exactly what "you're using the same transport it already relies
on" means concretely.
Exercise¶
- Explain in your own words (a short comment block is fine) why
page.locator("#foo")doesn't throw immediately even if#foodoesn't exist on the page yet, butpage.locator("#foo").click()will wait then throw. - Open a CDP session with
new_cdp_sessionand call any raw CDP command not exposed by Playwright's high-level API (e.g.Page.printToPDForNetwork.getCookies), confirming you get a real response back. - Create a
BrowserContext, time how longnew_context()takes versuschromium.launch(), and confirm the order-of-magnitude difference that justifies reusing one browser across many contexts. - Trigger a genuine strict-mode violation, then instead of reaching for
.first, rewrite the locator to be unambiguous — and write a comment explaining a case where.firstgenuinely would be the correct choice.