4Playwright Architecture & Internals
What you will master here
- The client–server model of Playwright and why it exists
- WebSocket + Chrome DevTools Protocol (CDP) vs Selenium's HTTP/WebDriver
- Why Playwright is faster and less flaky than older tools
- The
Browser → BrowserContext → Pagehierarchy - How auto-wait works under the hood
- Honest comparison: Playwright vs Selenium vs Cypress
Before diving into protocols and diagrams, it helps to understand why Node.js is in the picture at all.
Why Node.js?
Playwright's test runner and driver are both written in Node.js. The browser is a completely separate process — Node.js does not run inside the browser. Instead, Node.js is the coordinator that sits between your test code and the browser, speaking CDP (Chrome DevTools Protocol) over a WebSocket.
Think of it this way:
- Node.js is like a phone.
- Playwright is the app running on that phone.
- CDP / WebSocket is the cellular network.
- The browser is the server you're calling.
Your test code "dials" the browser through Playwright, sends commands, and listens for responses — all through Node.js.
The event loop in plain English
Node.js uses a single-threaded event loop. Imagine a very efficient waiter at a busy restaurant:
- The waiter takes your order (your test calls
page.click()). - The waiter hands it to the kitchen (Playwright sends a command to the browser via CDP).
- The waiter doesn't stand there doing nothing — they take other orders or handle other tasks.
- When the kitchen rings the bell (browser fires an event saying "done"), the waiter brings your food (the Promise resolves).
This is why Playwright is non-blocking and can handle multiple tasks efficiently. The event loop keeps things moving without needing multiple threads.
Why does await exist?
Because Playwright is talking to a browser that takes real time to respond. When you write:
typescriptawait page.click('#submit');
…you are telling Node.js: "Send the click command to the browser, then pause here and wait for the browser to confirm the click happened before you run the next line."
Without await, Node.js would fire off the command and immediately continue to the next line — before the browser has done anything. Your test would read stale state, assert on things that haven't happened yet, and produce wildly unpredictable results.
A simple rule: every Playwright API call that returns a Promise must be awaited.
- Node.js = phone
- Playwright = your app on the phone
- CDP over WebSocket = cellular network
- Browser = the server you're talking to
await= "hold on, wait for them to pick up and answer before saying the next thing"
4.1 The big picture: client and server
Playwright is split into two pieces that talk to each other over a single, persistent connection:
- The Playwright client (your test code) — a Node.js / Python / Java / .NET library that exposes a friendly API (
page.click(),expect(locator).toBeVisible(), etc.). - The Playwright server (the driver) — a Node.js process bundled inside the same install that knows how to drive Chromium, Firefox and WebKit. It speaks each browser's own remote-debugging protocol.
When you write await page.click('#submit'), the client serialises that command and sends it to the server, which translates it into the browser's native protocol and waits for the result.
4.2 WebSocket + CDP vs Selenium's HTTP/JSON Wire
This is the single most important architecture point an interviewer will probe.
Selenium WebDriver
- Communicates with the browser via the W3C WebDriver protocol over HTTP.
- Every command (
click,findElement) is a separate HTTP request/response. - HTTP is stateless and one-way: client asks, server replies. Server cannot push events back without polling.
- A "browser driver" binary (
chromedriver,geckodriver) sits between Selenium and the browser, translating WebDriver to the browser's internal protocol.
Playwright
- Communicates with the browser over a persistent WebSocket, using each browser's native debug protocol (Chrome DevTools Protocol for Chromium, Juggler for Firefox, WebKit Inspector Protocol for WebKit).
- Bidirectional: commands go out, events (network requests, console logs, DOM changes) stream back in real time.
- No extra driver binary — Playwright bundles patched browser builds and talks straight to them.
- Many commands can be batched on the same socket without a new handshake.
Why this matters for speed and flakiness
- Speed: No per-command TCP+HTTP handshake. A WebSocket frame is tiny (often < 100 bytes overhead).
- Real-time events: Playwright knows the instant a network request finishes, a DOM mutates, or a navigation starts — so its auto-wait has perfect information. Selenium has to poll the DOM.
- No mid-tier driver: Fewer moving parts = fewer version-mismatch failures (
chromedrivertoo old, etc.). - Isolation: Playwright creates a fresh BrowserContext per test in milliseconds — Selenium typically launches a whole new browser process, taking seconds.
4.3 The Browser → BrowserContext → Page hierarchy
This three-level model is what makes Playwright fast and isolated.
- Browser — the real OS process (Chromium etc.). You usually launch one per test worker. Heavy to start (~1 s).
- BrowserContext — a lightweight, fully isolated "profile" inside that browser. Like an incognito window: its own cookies, localStorage, cache, permissions, geolocation, viewport. Cheap to create (~10–50 ms) — Playwright spins one per test by default. This is why tests don't bleed into each other.
- Page — a single tab inside a context. A test can have multiple pages (multi-tab, popups). Each page has its own frame tree, console, network events.
Code: see the hierarchy
import { chromium } from '@playwright/test';
(async () => {
// 1. Browser — one OS process, expensive to start
const browser = await chromium.launch({ headless: false });
// 2. Two isolated contexts — cheap, parallel-safe
const userContext = await browser.newContext({ storageState: 'auth/user.json' });
const adminContext = await browser.newContext({ storageState: 'auth/admin.json' });
// 3. Pages inside each context — each is a tab
const userPage = await userContext.newPage();
const adminPage = await adminContext.newPage();
await Promise.all([
userPage.goto('https://app.example.com/dashboard'),
adminPage.goto('https://app.example.com/admin'),
]);
// Cookies / localStorage of the two users never collide.
await browser.close();
})();
4.4 Auto-wait internals
"Playwright auto-waits" is the headline feature. Here is what actually happens when you call await page.click('#buy'):
- Resolve the locator. Playwright re-queries the DOM (not a stale cached element) until exactly one matching element is found.
- Run actionability checks on that element, in a tight loop, until all pass — or the action timeout (default 30 s) expires:
- Element is attached to the DOM
- Element is visible (non-zero size, not
display:noneorvisibility:hidden) - Element is stable (not animating — same bounding box for two consecutive frames)
- Element receives events (no other element on top via hit-testing)
- Element is enabled (no
disabledattribute on form controls)
- Scroll into view if needed.
- Perform the action at the element's center using real input events (CDP
Input.dispatchMouseEvent— not JS click). - Wait for the event loop to settle (no pending navigation triggered by the click).
page.waitForResponse / expect(...).toBeVisible(), not page.waitForTimeout.4.5 Why Playwright is faster and less flaky
| Cause | Effect |
|---|---|
| Persistent WebSocket vs HTTP-per-command | ~5–10× lower per-command overhead |
| Auto-wait with actionability checks | No need for sleep() or explicit waits — fewer race conditions |
| BrowserContext per test (ms to create) | True parallelism without spawning new browsers |
Real input events via CDP, not JS .click() | Triggers real UI side-effects (focus, blur, hover) the browser would actually fire |
| Network events streamed in real time | Can wait for / mock specific responses reliably |
| Built-in retries + trace viewer | When a test does fail, you can replay every step |
4.6 Playwright vs Selenium vs Cypress (honest)
| Dimension | Playwright | Selenium | Cypress |
|---|---|---|---|
| Architecture | Out-of-process driver, WebSocket+CDP | Out-of-process, HTTP+WebDriver | In-browser, runs alongside app |
| Languages | JS/TS, Python, Java, .NET | Almost everything | JS/TS only |
| Browser support | Chromium, Firefox, WebKit | All major browsers | Chromium-family, Firefox (limited WebKit via experimental) |
| Multi-tab / multi-origin | Native (multi-page, multi-context) | Yes | Limited (one tab, same-origin assumed) |
| iframes | frameLocator — first-class | Supported | Workarounds needed |
| Auto-wait | Yes, deep | Manual / explicit waits | Yes, command queue |
| Parallel execution | Per-test contexts, free | Selenium Grid / external | Paid dashboard for parallelism |
| Network mocking | Built-in page.route | Via BiDi / proxies | Built-in cy.intercept |
| Trace / time-travel debug | Trace Viewer (DOM snapshots per action) | None built-in | Time-travel debugger in app |
| API testing | Built-in request fixture | External libs | Built-in cy.request |
4.7 First test — proves the architecture
// tests/architecture.spec.ts
import { test, expect } from '@playwright/test';
test('one browser, two isolated users at once', async ({ browser }) => {
// Two contexts share the same browser process — fast
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
await pageA.goto('https://example.com');
await pageB.goto('https://example.com');
// Set cookies independently — they don't collide
await ctxA.addCookies([{ name: 't', value: 'A', url: 'https://example.com' }]);
await ctxB.addCookies([{ name: 't', value: 'B', url: 'https://example.com' }]);
expect((await ctxA.cookies())[0].value).toBe('A');
expect((await ctxB.cookies())[0].value).toBe('B');
await ctxA.close();
await ctxB.close();
});
Module 1 — Interview Q&A bank
Explain Playwright's architecture in one minute.
Why is Playwright faster than Selenium?
What is CDP and why does Playwright use it?
What is a BrowserContext and why does it matter?
How does auto-wait actually work?
Does auto-wait wait for the page to "fully load"?
page.goto() awaits load by default, but for SPA flows you usually wait on a meaningful element (e.g. expect(page.getByText('Dashboard')).toBeVisible()) or a network response (page.waitForResponse).Why does Playwright not need a separate driver binary?
chromedriver in the middle, so version-mismatch failures effectively go away — Playwright and the browser are released together.Compare Playwright and Cypress at the architecture level.
Can you have multiple pages in one BrowserContext?
Page. They share cookies and storage. To capture a popup you use the promise-first pattern: const [popup] = await Promise.all([context.waitForEvent('page'), page.click('a')]);.How does Playwright simulate user input — JS click() or real events?
Input.dispatchMouseEvent in CDP). This means hover, focus, blur, pointerdown/up all fire as a real user would cause them. Calling element.click() in JS skips most of that and can miss UI side-effects.What happens if two elements match a locator?
.first(), .nth(n), .filter(), or a more specific locator. Selenium would silently pick the first match — a common flakiness source.Is Playwright synchronous or asynchronous?
Promise and you await it. This matches the underlying WebSocket protocol where commands and responses are paired by id. Python bindings additionally expose a sync API that wraps the async one for convenience.Why is creating a new BrowserContext per test better than launching a new Browser?
How do Selenium 4 BiDi and Playwright compare?
What's the difference between a Page and a Frame?
page.mainFrame() gives the top frame; page.frameLocator(selector) gives you a locator scoped inside an iframe. You don't usually need to drop down to frame objects manually.How does Playwright handle navigation safely?
page.waitForURL or pair the action with page.waitForResponse in a Promise.all.One-sentence pitch for choosing Playwright over Selenium.
What happens when you forget await before a Playwright action?
Node.js fires the command and moves on to the next line immediately, before the browser has done anything. This leads to three common failure modes:
- Assertion on stale state — you assert before the click/fill has occurred, so the test either passes incorrectly or fails with a confusing error.
- Unhandled Promise rejection — if the command eventually fails (e.g. element not found), the error gets thrown after the test has already ended, polluting the next test or producing a mysterious crash.
- Race conditions — subsequent awaited actions may run fine, but they'll be operating on an earlier application state than you intended.
TypeScript helps here: if you use the @playwright/test types, the compiler will warn you about unawaited Promises in many cases. Always await every call that returns a Promise.
typescript — wrong vs right// WRONG — no await, test races ahead
page.click('#submit');
expect(page.url()).toBe('/success'); // URL hasn't changed yet!
// CORRECT
await page.click('#submit');
await expect(page).toHaveURL('/success');
Why does Playwright test in Chromium by default when my users use Chrome?
Chromium and Chrome share the same rendering engine (Blink) and JavaScript engine (V8). The difference is that Chrome adds a layer of Google branding, proprietary codecs (MP4, AAC), and DRM on top. For the vast majority of web UI testing purposes, Chromium is functionally identical to Chrome.
Playwright ships its own patched Chromium build specifically so it can speak CDP more reliably and get a deterministic version. If you need to run on the actual installed Chrome binary (for codec/DRM testing, or to match a specific version), you can point Playwright at it:
playwright.config.tsuse: {
channel: 'chrome', // uses installed Google Chrome
// or:
channel: 'msedge', // uses installed Edge
}
For regression and functional testing, Chromium is preferred — it starts faster, has no auto-update surprises, and is guaranteed to match the Playwright version you installed.
What is the difference between page.evaluate() and a regular Playwright locator action?
The key distinction is where the code runs:
page.evaluate() | Locator action (e.g. locator.click()) | |
|---|---|---|
| Runs in | Browser JavaScript context | Node.js via CDP commands |
| Auto-wait? | No — executes immediately | Yes — full actionability checks |
| Use case | Read DOM state, call browser APIs, manipulate JS objects | Simulate real user interactions |
| Flakiness risk | Higher — bypasses auto-wait | Lower — built-in retry loop |
typescript// page.evaluate — runs in the browser, returns a serialisable value
const title = await page.evaluate(() => document.title);
// locator action — runs via CDP, with actionability checks
await page.getByRole('button', { name: 'Submit' }).click();
Use page.evaluate() when you need to read or manipulate the DOM in a way Playwright's API doesn't expose (e.g. reading a custom property on a JS object). Prefer locator actions for everything a user would do — they're safer and more realistic.
Why do Playwright tests sometimes fail on CI but pass locally?
This is one of the most common SDET interview traps. The causes fall into a few buckets:
- Slower machines on CI — animations, fonts, or network requests take longer, causing timeouts that never trigger on a fast laptop. Fix: increase
actionTimeoutin config, or useexpect(...).toBeVisible()instead of hardcoded delays. - Different viewport / resolution — elements that are visible on a 1920-wide monitor may be hidden behind a hamburger menu on the 1280-wide CI default. Fix: set
viewportexplicitly in config. - No GPU / headless differences — some CSS animations, WebGL, or canvas rendering behaves differently in headless mode. Fix: use
--disable-gpuflags or screenshot comparisons with generous thresholds. - Race conditions with async setup — local dev server starts instantly; CI spins it up in a global setup that can be slow. Fix: use
webServerconfig option with awaitUntilprobe. - Missing environment variables or secrets — test relies on a
.envfile that only exists locally. Fix: add secrets to CI environment and usedotenvin config. - Font rendering differences — visual snapshot tests fail because Linux CI uses different fonts. Fix: use Docker with a fixed font set, or avoid pixel-perfect snapshots for text-heavy areas.
The golden debugging approach: run with --headed locally to watch, and attach a Playwright trace on CI failures (trace: 'on-first-retry') so you can replay every step in the Trace Viewer.
What does test.use({ baseURL }) do and why is it useful?
test.use() overrides fixture values for a specific test file or describe block. When used with baseURL, it tells Playwright the root URL to prepend to relative paths passed to page.goto().
playwright.config.ts — global defaultuse: {
baseURL: 'https://staging.example.com',
}
tests/admin.spec.ts — override for this file onlyimport { test, expect } from '@playwright/test';
test.use({ baseURL: 'https://admin.example.com' });
test('admin dashboard loads', async ({ page }) => {
await page.goto('/dashboard'); // resolves to https://admin.example.com/dashboard
await expect(page).toHaveURL(/dashboard/);
});
Why it's useful:
- Environment switching — point your whole suite at staging vs production by changing one config value, without touching test files.
- Multi-domain tests — use
test.use()per describe block to test different sub-domains (API, admin, app) without duplicating the URL in everygoto(). - Cleaner test code — relative paths (
'/login') are more readable and portable than absolute URLs.
Other commonly overridden fixtures with test.use(): viewport, locale, storageState, colorScheme, permissions.
4.9 Playwright versioning, release cadence & what's new (interview-ready)
Senior interviewers love asking "which version are you on and why?" — it surfaces whether you treat Playwright as a black box or as a dependency you actively manage. Be ready to discuss your exact version, your upgrade cadence, and at least 2–3 features tied to specific releases.
4.9.1 Your version — 1.61.0 (Nov 2025)
Across this workspace, every Playwright project resolves @playwright/test to 1.61.0. Some package.json files declare older minimums (^1.43.0, ^1.45.0) but the caret allows minor/patch upgrades, so the lock files all settle on 1.61.0.
# Verify quickly npx playwright --version # Version 1.61.0 npm ls @playwright/test # shows resolved version node -p "require('@playwright/test/package.json').version"
4.9.2 Release cadence
- Minor releases roughly every 4–6 weeks (1.40, 1.41, 1.42 …). Each ships features + browser-binary updates.
- Patch releases (1.61.x) come for bug fixes / security only — safe to take automatically.
- No LTS branch — Playwright doesn't backport. If you find a bug, upgrade to the latest minor or use a workaround.
- Browser binaries are pinned to a Playwright version.
npx playwright installdownloads the exact Chromium/Firefox/WebKit builds the library was tested against.
4.9.3 Feature timeline (memorise the headline features per version)
| Version | Released | Headline features (interview-grade) |
|---|---|---|
| 1.30 | Jan 2023 | page.getByRole/getByLabel/... stable, test.step |
| 1.31 | Feb 2023 | Project dependencies (dependencies: ['setup']) — replaces global setup for many use-cases |
| 1.32 | Mar 2023 | UI Mode (--ui) — the modern dev loop |
| 1.35 | Jun 2023 | Component testing for React/Vue/Svelte (experimental → stable in stages) |
| 1.37 | Aug 2023 | Blob reports + npx playwright merge-reports — proper sharded CI reporting |
| 1.38 | Sep 2023 | UI Mode in --debug, test.fail() on expect.soft |
| 1.40 | Nov 2023 | AI-assisted matcher suggestions in trace viewer |
| 1.41 | Jan 2024 | page.unroute() async, network controls |
| 1.42 | Mar 2024 | page.addLocatorHandler() — auto-dismiss surprise dialogs / cookie banners |
| 1.43 | Apr 2024 | APIRequestContext can route requests; test.step.skip() |
| 1.44 | May 2024 | expect(locator).toHaveAccessibleName/Description/Role |
| 1.45 | Jul 2024 | Tag-based test filtering: --grep "@smoke" as first-class |
| 1.46 | Aug 2024 | testConfig.tsconfig, clock API for time-mocking (page.clock) |
| 1.47 | Sep 2024 | test.step can be marked box: true (hides internals in trace) |
| 1.48 | Oct 2024 | WebSocket route mocking (page.routeWebSocket) |
| 1.49 | Dec 2024 | Aria snapshots — expect(locator).toMatchAriaSnapshot() |
| 1.50 | Feb 2025 | test.describe.configure({ retries: N }) per-block retries |
| 1.51 | Mar 2025 | Stable component testing, improved CDP-relay performance |
| 1.55 | Aug 2025 | Playwright Agents & MCP server — npx playwright init-agents |
| 1.60 | Oct 2025 | Improved trace viewer, faster headless mode |
| 1.61 | Nov 2025 | ← Your version. Latest stable. Mature Agents/MCP, refined aria snapshots, hardened sharding |
4.9.4 What to do before upgrading (the checklist)
- Read the release notes —
github.com/microsoft/playwright/releases. Most upgrades are zero-change; some (1.40 → 1.41) tweaked default timeouts. - Match the Docker tag — bump
mcr.microsoft.com/playwright:v1.61.0-jammyin your CI and the npm version in the same PR. Mismatched versions cause "works locally, breaks in CI" failures. - Pin exact in CI — drop the caret:
"@playwright/test": "1.61.0"inpackage.json. Usenpm ci, notnpm install. - Re-run
npx playwright install --with-deps— browser binaries are version-locked. - Re-record visual baselines if you have
toHaveScreenshotsnapshots — browser binaries changing pixel-shift fonts slightly is the most common silent breakage. - Run on one CI shard first, not all 8 simultaneously — to spot version-incompatibility before you waste a full pipeline.
4.9.5 Common interview prompts about version (and how to answer)
"What Playwright version are you using and why?"
v1.61.0-jammy matches our base image. We upgrade roughly every 2–3 minors after the .0 release has had two weeks in the wild — keeps us current without chasing every release."
"How do you handle the Playwright upgrade process?"
@playwright/test in package.json, the Docker image tag, and re-runs npx playwright install in the workflow. I run the full suite on one shard first to catch breakage early. Visual snapshots get re-baselined intentionally — never blindly. The PR description links the relevant release notes so reviewers can sanity-check the diff."
"You're on 1.61. Pick one feature you genuinely use day-to-day."
page.addLocatorHandler() (added 1.42). We had a cookie banner that sporadically intercepted clicks on production but not staging. Instead of adding if banner is visible guards in every spec, I register a handler once in a fixture — Playwright auto-dismisses the banner whenever a locator action is blocked by it. Removed ~40 lines of guard code and ~12 flaky failures/week."