Architecture & Internals

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 → Page hierarchy
  • How auto-wait works under the hood
  • Honest comparison: Playwright vs Selenium vs Cypress
Node.js Connection — Read this first

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:

  1. The waiter takes your order (your test calls page.click()).
  2. The waiter hands it to the kitchen (Playwright sends a command to the browser via CDP).
  3. The waiter doesn't stand there doing nothing — they take other orders or handle other tasks.
  4. 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.

Analogy recap
  • 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:

  1. The Playwright client (your test code) — a Node.js / Python / Java / .NET library that exposes a friendly API (page.click(), expect(locator).toBeVisible(), etc.).
  2. 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.

Test Code page.click('#go') (client library) Playwright Driver (Node.js server) protocol translator Browser Chromium / Firefox / WebKit single WebSocket (bi-directional, persistent) CDP / Juggler / WK (bi-directional) One persistent connection, many commands and events stream both ways
Figure 1 — Playwright sends commands to the driver, which speaks each browser's native debug protocol.

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

4.3 The Browser → BrowserContext → Page hierarchy

This three-level model is what makes Playwright fast and isolated.

Browser (1 OS process) BrowserContext A (incognito-like) own cookies · own cache · own storage Page (tab) login.html Page (tab) dashboard.html user-a@test.com sessionToken: ABC123 BrowserContext B (incognito-like) own cookies · own cache · own storage Page (tab) checkout.html user-b@test.com sessionToken: XYZ789
Figure 2 — One browser process hosts many isolated contexts; each context can host many pages (tabs).
Mental modelOne Chrome on your laptop with two incognito windows open, each window has tabs. The window is a context, the tab is a page.

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'):

  1. Resolve the locator. Playwright re-queries the DOM (not a stale cached element) until exactly one matching element is found.
  2. 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:none or visibility: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 disabled attribute on form controls)
  3. Scroll into view if needed.
  4. Perform the action at the element's center using real input events (CDP Input.dispatchMouseEvent — not JS click).
  5. Wait for the event loop to settle (no pending navigation triggered by the click).
attached? in DOM visible? size > 0 stable? 2 frames same enabled? not disabled receives events? hit-test CLICK Actionability loop — retries every ~50 ms until all pass or timeout Failure produces a single clear error: "element is not visible" / "element intercepts pointer events"
Figure 3 — Five actionability checks done in a retry loop before any action fires.
Common myth"Auto-wait waits for the page to load." No — it waits for actionability of the specific element you targeted. If you wait for a network response or specific state, use page.waitForResponse / expect(...).toBeVisible(), not page.waitForTimeout.

4.5 Why Playwright is faster and less flaky

CauseEffect
Persistent WebSocket vs HTTP-per-command~5–10× lower per-command overhead
Auto-wait with actionability checksNo 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 timeCan wait for / mock specific responses reliably
Built-in retries + trace viewerWhen a test does fail, you can replay every step

4.6 Playwright vs Selenium vs Cypress (honest)

DimensionPlaywrightSeleniumCypress
ArchitectureOut-of-process driver, WebSocket+CDPOut-of-process, HTTP+WebDriverIn-browser, runs alongside app
LanguagesJS/TS, Python, Java, .NETAlmost everythingJS/TS only
Browser supportChromium, Firefox, WebKitAll major browsersChromium-family, Firefox (limited WebKit via experimental)
Multi-tab / multi-originNative (multi-page, multi-context)YesLimited (one tab, same-origin assumed)
iframesframeLocator — first-classSupportedWorkarounds needed
Auto-waitYes, deepManual / explicit waitsYes, command queue
Parallel executionPer-test contexts, freeSelenium Grid / externalPaid dashboard for parallelism
Network mockingBuilt-in page.routeVia BiDi / proxiesBuilt-in cy.intercept
Trace / time-travel debugTrace Viewer (DOM snapshots per action)None built-inTime-travel debugger in app
API testingBuilt-in request fixtureExternal libsBuilt-in cy.request
One-line interview answer"Playwright runs out-of-process like Selenium, but speaks each browser's native debug protocol over a persistent WebSocket, so commands are cheaper and events stream back in real time — that's how auto-wait can be precise. Cypress runs inside the browser, which is fast but constrains it to one tab and one origin."

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.
Playwright is a client–server library. Your test code uses the client (a thin language binding). Commands are sent over a persistent WebSocket to a Node.js driver, which in turn talks to each browser using its native debug protocol — Chrome DevTools Protocol for Chromium, Juggler for Firefox, the WebKit Inspector Protocol for WebKit. Because the connection is persistent and bidirectional, commands have low overhead and browser events stream back in real time, which is what lets auto-wait be precise.
Why is Playwright faster than Selenium?
Three big reasons. (1) Persistent WebSocket vs HTTP-per-command — Selenium opens a new HTTP request for every action; Playwright reuses one socket. (2) Real-time events — Playwright is told when a request finishes or DOM mutates, so it doesn't poll. (3) BrowserContext isolation — creating an isolated session takes ~10 ms vs spawning a whole new browser in Selenium.
What is CDP and why does Playwright use it?
Chrome DevTools Protocol — the same protocol Chrome's DevTools panel speaks to its browser engine. It exposes deep capabilities (DOM mutation events, network interception, input synthesis, JS evaluation in any frame) that the W3C WebDriver protocol doesn't. Playwright uses CDP directly for Chromium, and equivalent protocols for Firefox (Juggler) and WebKit.
What is a BrowserContext and why does it matter?
It's an isolated "incognito profile" inside a single browser process. Each context has its own cookies, localStorage, cache, permissions and storage. Creating one is fast (~10–50 ms), so Playwright uses one context per test — that's how tests stay isolated without paying the cost of launching a new browser. Multiple contexts in the same browser also let one test simulate multiple users (e.g. seller and buyer) at the same time.
How does auto-wait actually work?
For every action, Playwright runs an actionability loop on the target element until checks pass or the timeout expires: attached, visible, stable (not animating), enabled, and receives pointer events (hit-test). Each check re-queries the DOM, so the element reference is never stale. If timeout hits, you get a precise error like "element is not visible" — you don't have to guess.
Does auto-wait wait for the page to "fully load"?
No — it waits for the specific element you're acting on to be actionable. Page load is a separate concept: 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?
It ships its own patched browser builds and talks to them directly over each browser's debug protocol. There's no 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.
Cypress runs inside the browser, alongside the application, using a Node.js helper for things the browser can't do (like file system access). That makes it very fast and gives a time-travel debugger, but it constrains it to a single tab and historically the same origin. Playwright runs outside the browser, driving it remotely — slightly higher per-command cost, but it can drive multiple tabs, multiple origins, multiple browser engines, all in parallel.
Can you have multiple pages in one BrowserContext?
Yes — every tab or popup window opened inside a context becomes a 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?
Real input events, dispatched via the browser's input API (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?
Playwright is in strict mode by default: it throws a clear error listing the matches and asking you to narrow with .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?
Asynchronous — every command returns a 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?
Speed and isolation together. Launching a browser is ~1 s (process startup, GPU init). Creating a context is ~10 ms — just allocating a fresh storage partition inside the running browser. You get full isolation (no cookies/localStorage bleed) without paying the cost on every test.
How do Selenium 4 BiDi and Playwright compare?
Selenium 4 added BiDi (Bidirectional) support, which is conceptually similar to what Playwright already does — a persistent WebSocket with event streaming. It closes part of the gap but BiDi support is still maturing and not all WebDriver clients use it by default. Playwright was built around that model from day one and integrates it with auto-wait, trace viewer, and built-in test runner.
What's the difference between a Page and a Frame?
A Page is a tab. A Frame is any browsing context inside a page — the main frame plus any iframes. 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?
Actions that may trigger navigation (e.g. clicking a link) automatically wait for the navigation to complete to a stable state before resolving. If you need to wait for a specific URL or event, use page.waitForURL or pair the action with page.waitForResponse in a Promise.all.
One-sentence pitch for choosing Playwright over Selenium.
"It's faster per command, more reliable per assertion, and the same code drives Chromium, Firefox and WebKit with built-in parallelism, network mocking, and a time-travel trace viewer — features that in Selenium-land are separate libraries you have to glue together yourself."
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:

  1. Assertion on stale state — you assert before the click/fill has occurred, so the test either passes incorrectly or fails with a confusing error.
  2. 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.
  3. 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 inBrowser JavaScript contextNode.js via CDP commands
Auto-wait?No — executes immediatelyYes — full actionability checks
Use caseRead DOM state, call browser APIs, manipulate JS objectsSimulate real user interactions
Flakiness riskHigher — bypasses auto-waitLower — 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:

  1. Slower machines on CI — animations, fonts, or network requests take longer, causing timeouts that never trigger on a fast laptop. Fix: increase actionTimeout in config, or use expect(...).toBeVisible() instead of hardcoded delays.
  2. 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 viewport explicitly in config.
  3. No GPU / headless differences — some CSS animations, WebGL, or canvas rendering behaves differently in headless mode. Fix: use --disable-gpu flags or screenshot comparisons with generous thresholds.
  4. Race conditions with async setup — local dev server starts instantly; CI spins it up in a global setup that can be slow. Fix: use webServer config option with a waitUntil probe.
  5. Missing environment variables or secrets — test relies on a .env file that only exists locally. Fix: add secrets to CI environment and use dotenv in config.
  6. 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 every goto().
  • 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)

Why this section matters for interviews

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

4.9.3 Feature timeline (memorise the headline features per version)

VersionReleasedHeadline features (interview-grade)
1.30Jan 2023page.getByRole/getByLabel/... stable, test.step
1.31Feb 2023Project dependencies (dependencies: ['setup']) — replaces global setup for many use-cases
1.32Mar 2023UI Mode (--ui) — the modern dev loop
1.35Jun 2023Component testing for React/Vue/Svelte (experimental → stable in stages)
1.37Aug 2023Blob reports + npx playwright merge-reports — proper sharded CI reporting
1.38Sep 2023UI Mode in --debug, test.fail() on expect.soft
1.40Nov 2023AI-assisted matcher suggestions in trace viewer
1.41Jan 2024page.unroute() async, network controls
1.42Mar 2024page.addLocatorHandler() — auto-dismiss surprise dialogs / cookie banners
1.43Apr 2024APIRequestContext can route requests; test.step.skip()
1.44May 2024expect(locator).toHaveAccessibleName/Description/Role
1.45Jul 2024Tag-based test filtering: --grep "@smoke" as first-class
1.46Aug 2024testConfig.tsconfig, clock API for time-mocking (page.clock)
1.47Sep 2024test.step can be marked box: true (hides internals in trace)
1.48Oct 2024WebSocket route mocking (page.routeWebSocket)
1.49Dec 2024Aria snapshotsexpect(locator).toMatchAriaSnapshot()
1.50Feb 2025test.describe.configure({ retries: N }) per-block retries
1.51Mar 2025Stable component testing, improved CDP-relay performance
1.55Aug 2025Playwright Agents & MCP servernpx playwright init-agents
1.60Oct 2025Improved trace viewer, faster headless mode
1.61Nov 2025← Your version. Latest stable. Mature Agents/MCP, refined aria snapshots, hardened sharding

4.9.4 What to do before upgrading (the checklist)

  1. Read the release notesgithub.com/microsoft/playwright/releases. Most upgrades are zero-change; some (1.40 → 1.41) tweaked default timeouts.
  2. Match the Docker tag — bump mcr.microsoft.com/playwright:v1.61.0-jammy in your CI and the npm version in the same PR. Mismatched versions cause "works locally, breaks in CI" failures.
  3. Pin exact in CI — drop the caret: "@playwright/test": "1.61.0" in package.json. Use npm ci, not npm install.
  4. Re-run npx playwright install --with-deps — browser binaries are version-locked.
  5. Re-record visual baselines if you have toHaveScreenshot snapshots — browser binaries changing pixel-shift fonts slightly is the most common silent breakage.
  6. 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?"
"1.61.0, the November 2025 stable. We pin it exactly in CI so browser binaries and library code never drift. We chose to stay on the current latest because (a) Playwright doesn't backport fixes so older versions accrue known issues, (b) features like aria snapshots and the Agents MCP server fit our roadmap, and (c) the Docker image 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?"
"A PR that bumps three things together: @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."
"Why not use Playwright 2.0 / the newest release?"
"There is no Playwright 2.x — the project has stayed on 1.x semver for years to avoid unnecessary breakage. The team treats minors as the upgrade unit. As for the absolute latest minor: we wait two weeks after the .0 release so the .1 patch has shipped, which is typically when any regressions surface."
"How do you know which features need a specific version?"
"Two habits. First, every Playwright doc page has a 'Since version' badge — I scan that before adopting an API. Second, the release notes are tagged on GitHub by version. For team consumption I keep a short internal doc that maps the features we use to their minimum version, so a junior bumping the lock file knows what's at risk."