Deep Internals: CDP, Workers, Headless

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.

FeaturePuppeteerPlaywright
CreatorGoogle (2017)Microsoft/ex-Google team (2019)
BrowsersChromium only (originally)Chromium, Firefox, WebKit
ProtocolCDP (Chrome only)CDP + patched protocols per browser
TypeLibrary (no runner)Full framework + test runner
IsolationPage per test (manual)BrowserContext (built-in)
Auto-waitManual waitForSelectorBuilt-in actionability checks
FixturesNoneFirst-class fixture system
Trace viewerNoneBuilt-in HAR + trace
Interview Answer When asked "What is Puppeteer?" say: "Puppeteer is Google's Chrome automation library (2017) built on CDP. The team that built it moved to Microsoft and created Playwright (2019) — a full framework supporting Chromium, Firefox, and WebKit with built-in isolation, auto-wait, and a test runner. Playwright supersedes Puppeteer for testing."

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:

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.

Key Concept CDP is bidirectional and event-driven. When the browser fires a DOM event, it pushes a CDP event to Playwright's driver. This is fundamentally different from WebDriver (HTTP request/response), which is why Playwright is faster and more reliable for event-based interactions.

Chromium vs Chrome — Why Tests Use Chromium, Not Chrome

End users run Google Chrome. Playwright runs Chromium. These are NOT the same:

AspectChromium (open source)Google Chrome (branded)
LicenseBSD — freely distributableGoogle proprietary — cannot redistribute
CodecsOpen codecs only (no H.264, no AAC)Proprietary codecs bundled
DRMNo Widevine DRMWidevine built-in
SandboxStripped sandbox for test harnessFull Chrome sandbox
Auto-updatesNo (pinned to Playwright version)Auto-updates break reproducibility
Rendering engineBlink (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.

Interview Answer "Playwright uses Chromium (not Chrome) because Chrome cannot be legally redistributed, has proprietary codecs/DRM, and auto-updates. Chromium uses the same Blink rendering engine, giving identical behavior for web testing while remaining freely distributable and version-pinned for reproducibility."

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
The nuance Within a single worker, Playwright uses async/await + the event loop for all browser communication (it's non-blocking I/O). This means one worker can drive a browser efficiently even though JS is single-threaded — it awaits network calls while the event loop handles other microtasks.
Interview Answer "Node.js is single-threaded on its event loop, but Playwright's runner spawns multiple worker processes using child_process — each is a separate Node.js process with its own event loop and browser instance. So four workers on a four-core machine run four test files simultaneously at the OS level. Within each worker, Playwright uses async/await efficiently since all browser I/O is non-blocking."

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

ActionCostUse case
Launch new Browser~1-2 secondsOnce per worker, reused
Create new BrowserContext~5-10msOnce per test (isolation)
Create new Page~1-3msAs 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.

Interview Answer "BrowserContext exists because tabs within the same browser share cookies, localStorage, and auth state — exactly like real browser tabs. BrowserContext is an isolated profile: separate cookies, storage, auth, and permissions. It costs ~5ms to create vs ~1-2s for a full browser launch, so Playwright creates a new context per test for isolation while reusing the browser process across tests in the same worker."

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

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');
Gotcha :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;
});
Interview Answer "Shadow DOM creates a DOM boundary so web components can encapsulate their internals. Open shadow roots allow JS access; closed ones don't. Playwright automatically pierces open shadow roots in CSS selectors — you write selectors as if the shadow boundary doesn't exist. XPath doesn't pierce shadow DOM. Closed shadow roots are unreachable by any external selector."

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:

  1. Parse — HTML → DOM tree, CSS → CSSOM
  2. Style — Computed styles applied to each node
  3. Layout — Geometry calculated (positions, sizes)
  4. Paint — Drawing commands generated
  5. 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

ModeHow it worksWhen used
Native headless (--headless=new)Chromium renders to in-memory buffer, no display server neededModern CI, fast, default since Chrome 112
Xvfb (virtual framebuffer)Linux virtual display driver; browser thinks it has a screenOld 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

Interview Answer "In headless mode the full rendering pipeline (parse → layout → paint → composite) still runs, but the compositor writes frames to an in-memory buffer instead of driving a display. Screenshots are captured from this buffer via CDP's Page.captureScreenshot. Modern Chromium uses native headless (--headless=new) which needs no virtual display. On older setups, Xvfb (a virtual framebuffer display driver) was used so the browser believed it had a real screen."

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:

  1. Element is in DOM (attached)
  2. Element is visible (not display:none, not opacity:0)
  3. Element is not covered by another element at the click point
  4. Element is enabled (not disabled attribute)
  5. 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 Docsplaywright.dev — architecture overview, API reference, guides
  • CDP Specchromedevtools.github.io/devtools-protocol/ — full CDP command reference
  • Playwright YouTube — "Playwright from first principles" and "Architecture" talks by Andrey Lushnikov
  • MDN Shadow DOMdeveloper.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM
  • Chromium headless docsdeveloper.chrome.com/docs/chromium/new-headless
  • Puppeteer GitHubgithub.com/puppeteer/puppeteer — study the original to appreciate what changed
  • WebDriver BiDi specw3c.github.io/webdriver-bidi/ — the future unified protocol