5Playwright Deep Internals: Architecture, History & Headless
Why Playwright Exists — The Puppeteer Story
To understand Playwright you must understand where it came from. In 2017, Google released Puppeteer — a Node.js library for controlling Chrome via the Chrome DevTools Protocol (CDP). Puppeteer was a library, not a test framework: no built-in assertions, no parallelism, no fixtures, no multi-browser support (Chromium only).
In 2019, the core Puppeteer engineering team (Andrey Lushnikov, Joel Einbinder, and others) left Google and joined Microsoft. There they built Playwright from scratch with lessons learned: multi-browser from day one, BrowserContext isolation as first-class concept, a full test runner, auto-wait, trace viewer, and a proper TypeScript-first API. Playwright is NOT a fork of Puppeteer — it is a ground-up rewrite with shared DNA.
| Feature | Puppeteer | Playwright |
|---|---|---|
| Creator | Google (2017) | Microsoft/ex-Google team (2019) |
| Browsers | Chromium only (originally) | Chromium, Firefox, WebKit |
| Protocol | CDP (Chrome only) | CDP + patched protocols per browser |
| Type | Library (no runner) | Full framework + test runner |
| Isolation | Page per test (manual) | BrowserContext (built-in) |
| Auto-wait | Manual waitForSelector | Built-in actionability checks |
| Fixtures | None | First-class fixture system |
| Trace viewer | None | Built-in HAR + trace |
Chrome DevTools Protocol (CDP) — The Real Engine
CDP is a JSON-over-WebSocket protocol that Chrome exposes for debugging tools (DevTools). It lets you: navigate pages, inspect DOM, intercept network requests, take screenshots, run JavaScript, emulate devices — all programmatically. Playwright's connection to the browser IS a CDP (or CDP-equivalent) WebSocket channel.
Why CDP works for all three browsers — not just Chrome
Playwright ships patched/forked builds of Firefox and WebKit that expose CDP-like protocols:
- Chromium — speaks native CDP
- Firefox — Playwright's fork patches Firefox to expose a Juggler protocol (CDP-compatible subset)
- WebKit — Playwright's fork patches WebKit to expose an inspector protocol over a WebSocket pipe
This is why you cannot just point Playwright at your system Firefox or Safari — it needs its own patched builds (npx playwright install). The W3C WebDriver BiDi standard is converging toward a single protocol all browsers will support natively, but Playwright's patched approach is how it achieves cross-browser parity today.
Chromium vs Chrome — Why Tests Use Chromium, Not Chrome
End users run Google Chrome. Playwright runs Chromium. These are NOT the same:
| Aspect | Chromium (open source) | Google Chrome (branded) |
|---|---|---|
| License | BSD — freely distributable | Google proprietary — cannot redistribute |
| Codecs | Open codecs only (no H.264, no AAC) | Proprietary codecs bundled |
| DRM | No Widevine DRM | Widevine built-in |
| Sandbox | Stripped sandbox for test harness | Full Chrome sandbox |
| Auto-updates | No (pinned to Playwright version) | Auto-updates break reproducibility |
| Rendering engine | Blink (same as Chrome) | Blink (same as Chromium) |
The rendering engine (Blink + V8) is identical. For web app testing, this means Chromium gives you 99.9% parity with Chrome behavior. The differences only matter for DRM video, certain codecs, or Chrome-specific sandbox behaviors — rarely relevant in SDET work.
Why pinned versions matter: Playwright pins each release to a specific Chromium revision. This means your test environment is reproducible across CI, dev machines, and months of time — Chrome auto-updates would silently break tests.
Node.js is Single-Threaded — So How Does Playwright Run Tests in Parallel?
This is one of the most common deep interview questions. The answer requires understanding two levels: Node.js threading model and how Playwright's runner spawns workers.
Node.js threading
Node.js runs JavaScript on a single main thread with an event loop. It can handle many concurrent async operations (I/O, timers, network) via the event loop — but JavaScript execution itself is single-threaded. You cannot run two JS callbacks simultaneously on one Node process.
How Playwright achieves parallelism
Playwright's test runner (@playwright/test) spawns multiple worker processes — each is a completely separate Node.js process with its own event loop, its own memory heap, and its own browser instance:
Main process (playwright test CLI)
├── Worker 1 (separate Node process) → Browser 1 (Chromium)
│ runs test-file-A.spec.ts sequentially
├── Worker 2 (separate Node process) → Browser 2 (Chromium)
│ runs test-file-B.spec.ts sequentially
├── Worker 3 (separate Node process) → Browser 3 (Chromium)
│ runs test-file-C.spec.ts sequentially
└── Worker 4 (separate Node process) → Browser 4 (Chromium)
runs test-file-D.spec.ts sequentially
- Default workers =
Math.ceil(cpuCount / 2)(half your CPU cores) - Each worker uses Node's
child_process/worker_threadsinternally - Within one worker, tests run sequentially — no shared state problems
- Across workers, tests run in parallel — true OS-level parallelism
- The main process collects results and generates reports
BrowserContext — Why It Exists When Browser Already Exists
This question trips up many candidates who think "I have a browser, I can just open tabs." The answer is about isolation.
What a Browser instance isolates
A Browser object represents the entire browser process. Tabs (Pages) within the same browser share: cookies, localStorage, sessionStorage, IndexedDB, service workers, cache, and permissions — exactly as a real browser tab does.
What BrowserContext isolates
A BrowserContext is like an incognito profile in code. Each context has completely isolated: cookies, localStorage, sessionStorage, IndexedDB, service workers, cache, authentication state, permissions, and geolocation. It is the unit of test isolation in Playwright.
// WRONG for parallel tests — tests share auth state, cookies, localStorage:
const browser = await chromium.launch();
const page1 = await browser.newPage(); // same context — shares state!
const page2 = await browser.newPage(); // pollutes page1's session
// RIGHT — each test gets its own isolated context:
const context1 = await browser.newContext({ storageState: 'auth.json' });
const page1 = await context1.newPage(); // isolated auth state
const context2 = await browser.newContext(); // fresh — no auth
const page2 = await context2.newPage();
Cost comparison
| Action | Cost | Use case |
|---|---|---|
| Launch new Browser | ~1-2 seconds | Once per worker, reused |
| Create new BrowserContext | ~5-10ms | Once per test (isolation) |
| Create new Page | ~1-3ms | As many as needed per test |
Playwright fixtures create a new BrowserContext per test automatically. This is why tests don't bleed state into each other even within the same worker process.
Shadow DOM — What It Is and How Playwright Handles It
What is Shadow DOM?
Shadow DOM is a browser feature that lets web components encapsulate their internal HTML structure. The shadow tree is attached to a host element and creates a DOM boundary — styles and JS from the outer document cannot accidentally reach inside, and inner IDs don't collide with outer IDs.
<!-- Light DOM (regular HTML) -->
<my-button>
<!-- Shadow root (shadow DOM) -->
#shadow-root (open)
<button class="inner-btn">Click me</button>
</my-button>
<!-- document.querySelector('.inner-btn') returns NULL -->
<!-- The shadow root hides it from normal DOM queries -->
Open vs Closed shadow roots
- Open (
attachShadow({mode:'open'})) — JavaScript CAN access the shadow root viaelement.shadowRoot. Playwright pierces open roots automatically. - Closed (
attachShadow({mode:'closed'})) — JavaScript CANNOT access the shadow root.element.shadowRootreturns null. Playwright cannot pierce closed roots — they are genuinely inaccessible.
How Playwright handles shadow DOM
Playwright's CSS selector engine automatically pierces open shadow roots by default. You write selectors as if the shadow DOM doesn't exist:
// Playwright pierces shadow DOM automatically for CSS selectors:
await page.click('.inner-btn'); // works even if inside shadow root (open)
await page.getByText('Click me'); // also pierces shadow DOM
// Force light DOM only (stop at shadow boundary):
await page.locator(':light(.inner-btn)');
// XPath does NOT pierce shadow DOM — avoid for web components:
await page.locator('xpath=//button[@class="inner-btn"]'); // fails across shadow boundary
// Explicit pierce selector (legacy syntax):
await page.locator('my-button >> .inner-btn');
await page.locator('pierce/.inner-btn');
:light() restricts the query to the LIGHT DOM only — it does NOT mean "pierce light DOM into shadow DOM." It means "stay in light DOM and don't pierce shadow roots." The name is counterintuitive.Testing web components end-to-end
// Find a custom element's shadow content:
const shadow = await page.locator('my-datepicker');
await shadow.getByLabel('Month').selectOption('June'); // Playwright pierces automatically
await shadow.getByRole('button', { name: 'Next month' }).click();
// If you need to inspect the shadow root via evaluate:
const text = await page.evaluate(() => {
const host = document.querySelector('my-button');
return host?.shadowRoot?.querySelector('button')?.textContent;
});
Headless Mode — How It Takes Screenshots Without Opening a Window
Headless mode ({ headless: true }, the default) runs the browser without a visible window. But if there's no window, how does rendering and screenshots work?
The rendering pipeline still runs
Browsers don't require a screen to render. The rendering engine (Blink for Chromium) performs:
- Parse — HTML → DOM tree, CSS → CSSOM
- Style — Computed styles applied to each node
- Layout — Geometry calculated (positions, sizes)
- Paint — Drawing commands generated
- Composite — Layers merged into final frame
In headless mode, the compositor outputs frames into an in-memory framebuffer — not to a screen buffer that drives a physical display. Screenshots read from this in-memory buffer via CDP's Page.captureScreenshot command.
Native headless vs Xvfb
| Mode | How it works | When used |
|---|---|---|
| Native headless (--headless=new) | Chromium renders to in-memory buffer, no display server needed | Modern CI, fast, default since Chrome 112 |
| Xvfb (virtual framebuffer) | Linux virtual display driver; browser thinks it has a screen | Old headless, WebGL, some video codecs |
| Headed (--headed) | Connects to real display server (X11/Wayland/macOS Quartz) | Local debugging, visual testing |
On CI Linux runners, npx playwright install --with-deps installs the native headless dependencies. No Xvfb needed for modern Playwright + Chromium headless.
What headless mode loses
- Some GPU-dependent features (WebGL shaders may differ slightly)
- Font rendering can differ from a real desktop display
- Some browser dialogs behave differently
- Videos encoded with proprietary codecs won't play (codec issue, not headless)
Full Playwright Architecture: End-to-End from Test Code to DOM
When you write await page.click('#btn'), here is every layer that executes:
YOUR TEST CODE (TypeScript/JS)
└─ await page.click('#btn')
│
▼
PLAYWRIGHT TEST RUNNER (@playwright/test)
• Fixture resolution (page, context, browser fixtures)
• Timeout management, retry logic
• Before/after hooks
│
▼
PLAYWRIGHT DRIVER (Node.js process — playwright-core)
• Translates page.click() into CDP command object
• { method: "DOM.querySelector", params: { selector: "#btn" } }
• Sends JSON over WebSocket/pipe to browser server
│ ◄── WebSocket (ws://) or named pipe ──►
▼
BROWSER SERVER PROCESS (the launched Chromium/Firefox/WebKit process)
• Receives CDP command
• Routes to correct page/frame
• Executes: querySelector → check actionability → scroll into view
→ dispatch mousedown/mouseup/click events
• Emits CDP event back: { method: "DOM.documentUpdated" }
│
▼
BROWSER RENDERING ENGINE (Blink + V8 in Chromium)
• Event listeners fire
• JS executes (React state update, Vue reactivity, etc.)
• Layout recalculates if DOM changed
• New frame rendered to buffer
│
▼
CDP RESPONSE flows back to Driver
• Playwright checks for actionability errors
• Returns Promise resolved in your test code
Auto-wait is part of this flow
Before dispatching the click event, Playwright's driver performs actionability checks via CDP — polling until all pass:
- Element is in DOM (attached)
- Element is visible (not display:none, not opacity:0)
- Element is not covered by another element at the click point
- Element is enabled (not disabled attribute)
- Element is stable (not animating)
This loop runs on the Playwright driver side — it repeatedly sends CDP queries to check state and only fires the actual CDP mouse-event command when all checks pass or timeout is reached.
Free Learning Resources
- Official Playwright Docs —
playwright.dev— architecture overview, API reference, guides - CDP Spec —
chromedevtools.github.io/devtools-protocol/— full CDP command reference - Playwright YouTube — "Playwright from first principles" and "Architecture" talks by Andrey Lushnikov
- MDN Shadow DOM —
developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM - Chromium headless docs —
developer.chrome.com/docs/chromium/new-headless - Puppeteer GitHub —
github.com/puppeteer/puppeteer— study the original to appreciate what changed - WebDriver BiDi spec —
w3c.github.io/webdriver-bidi/— the future unified protocol