# The complete guide to the Chrome DevTools Protocol

> Learn to drive Chrome with the DevTools Protocol from Node.js with zero dependencies: pages, clicks, screenshots, PDFs, network, and replacing Puppeteer.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-11-05 | Updated: 2026-11-05 | Topics: [Node.js](https://flaviocopes.com/tags/node/) | Canonical: https://flaviocopes.com/chrome-devtools-protocol/

Every time you use [Puppeteer](https://flaviocopes.com/puppeteer/), Playwright with Chrome, or an AI agent that drives a browser, the same thing happens underneath. A program opens a WebSocket to Chrome and sends it JSON messages like `{"id":1,"method":"Page.navigate","params":{"url":"https://flaviocopes.com"}}`.

That's the **Chrome DevTools Protocol**, or CDP. It's the protocol the DevTools panel itself uses to inspect a page, and Chrome exposes it to anyone who asks.

You can use it directly. With Node.js 22 you don't even need a WebSocket package, because `WebSocket` is now a global. In this guide we'll write a CDP client in about 80 lines, then use it to navigate pages, click and type, take screenshots, print PDFs, watch and block network requests, read console messages, and launch Chrome ourselves.

Then we'll compare it with Puppeteer: what Puppeteer sends over the wire, which of its methods map to which CDP commands, and when it's still the better choice.

All the code in this guide runs on Node.js 22.23 against Chrome 156 on macOS, and every output block is a real run.

## What the Chrome DevTools Protocol is

Chrome can act as a server. When you start it with a debugging port, it listens for WebSocket connections and accepts commands grouped in **domains**: `Page` for navigation, `Runtime` for running JavaScript, `Network` for requests, `Input` for mouse and keyboard, and so on.

The protocol has three kinds of messages:

- **commands** you send, with an `id`, a `method` and `params`
- **responses** Chrome sends back, with the same `id` and a `result` or an `error`
- **events** Chrome sends on its own, with a `method` and `params` but no `id`

```mermaid
flowchart LR
  accTitle: How a script talks to Chrome over CDP
  accDescr: A Node.js script opens one WebSocket to Chrome's browser endpoint. Over it, it sends commands and receives responses and events, for the browser itself and for each page through sessions.
  A[Your script] -- "commands (id, method, params)" --> B[WebSocket]
  B --> C[Chrome browser target]
  C -- "responses and events" --> B
  B --> A
  C --> D[Page target 1]
  C --> E[Page target 2]
  C --> F[Service worker target]
```

The Chrome I used exposes 59 domains, with 674 commands and 232 events. You'll use maybe 30 commands for most automation work.

| Domain | What it does | Commands | Events |
| --- | --- | --- | --- |
| `Page` | Navigation, screenshots, PDFs, lifecycle | 65 | 28 |
| `Runtime` | Run JavaScript, console messages, exceptions | 23 | 8 |
| `DOM` | Query nodes, read their position | 57 | 19 |
| `Input` | Mouse, keyboard, touch | 13 | 1 |
| `Network` | Watch requests, cookies, cache, headers | 35 | 43 |
| `Fetch` | Pause, block, modify or mock requests | 9 | 2 |
| `Emulation` | Viewport, device, media features, CPU speed | 49 | 2 |
| `Target` | Open, attach to and close tabs | 19 | 7 |
| `Browser` | Version, close, downloads, permissions | 22 | 2 |
| `Performance` | Runtime metrics | 4 | 1 |

All of these are marked stable. Many other domains, like `Storage`, `CSS` or `Accessibility`, are marked experimental, which means their commands can change between Chrome versions.

## Start Chrome with remote debugging

On macOS, the Chrome binary lives inside the app bundle. We start it headless, with a debugging port and a separate profile folder:

```bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless \
  --remote-debugging-port=9333 \
  --user-data-dir=/tmp/cdp-profile \
  --no-first-run \
  --no-default-browser-check
```

Chrome prints the WebSocket address it's listening on:

```txt
DevTools listening on ws://127.0.0.1:9333/devtools/browser/b743a921-069a-408e-afc9-85903bcc4340
```

On Linux the binary is usually `google-chrome` or `chromium`, and on Windows it's `chrome.exe` in `Program Files\Google\Chrome\Application`, with the same flags.

The `--user-data-dir` flag is required. Since Chrome 136, the debugging port is ignored if you use your default profile, because any program that connects could read your cookies and saved sessions.

Drop `--headless` if you want to watch what happens. You get a normal Chrome window that your script controls.

I used port 9333 instead of the usual 9222. The first time I ran this command, another Chrome on my Mac was already listening on `127.0.0.1:9222`. The new one printed `bind() failed: Address already in use`, then listened only on the IPv6 address `[::1]:9222`. So a request to `http://127.0.0.1:9222` would have reached the *other* browser.

Pick an unusual port, or use `--remote-debugging-port=0` to let Chrome choose a free one. We'll do that later when we launch Chrome from Node.js.

## The HTTP endpoints

Besides the WebSocket, the debugging port answers a few plain HTTP requests:

| Endpoint | What it returns |
| --- | --- |
| `/json/version` | Browser version and the browser WebSocket URL |
| `/json/list` | All targets: tabs, iframes, workers, extensions |
| `/json/new?url` | Opens a new tab (needs a `PUT` request) |
| `/json/close/<id>` | Closes a target |
| `/json/protocol` | The full protocol definition as JSON |

Let's ask for the version:

```bash
curl http://127.0.0.1:9333/json/version
```

```json
{
   "Browser": "Chrome/156.0.8078.81",
   "Protocol-Version": "1.3",
   "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36",
   "V8-Version": "15.6.75.27",
   "webSocketDebuggerUrl": "ws://127.0.0.1:9333/devtools/browser/b743a921-069a-408e-afc9-85903bcc4340"
}
```

Notice `HeadlessChrome` in the user agent. Sites can see it, and we'll change it later.

`/json/list` was more surprising. Even with a brand new profile, it listed seven targets: the `about:blank` page I asked for, plus extension background pages, service workers and two `chrome://omnibox-popup` pages. Filter by `"type": "page"` when you look for tabs.

## Send your first command

The `webSocketDebuggerUrl` is all we need. We connect to it, send `Browser.getVersion`, and print what comes back:

```js
const res = await fetch('http://127.0.0.1:9333/json/version')
const { webSocketDebuggerUrl } = await res.json()

const ws = new WebSocket(webSocketDebuggerUrl)

ws.addEventListener('open', () => {
  ws.send(JSON.stringify({ id: 1, method: 'Browser.getVersion' }))
})

ws.addEventListener('message', (event) => {
  console.log(event.data)
  ws.close()
})
```

Save it as `first.mjs` and run `node first.mjs`:

```json
{"id":1,"result":{"protocolVersion":"1.3","product":"Chrome/156.0.8078.81","revision":"@060bedfb508f73250e93aeb9ca8149b3958995d8","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36","jsVersion":"15.6.75.27"}}
```

That's the whole protocol. The response has the `id` we sent, so we know which command it answers.

A command that fails returns an `error` instead of a `result`. I sent `Page.navigate` on this same connection, plus a typo:

```json
{"id":1,"error":{"code":-32601,"message":"'Page.navigate' wasn't found"}}
{"id":2,"error":{"code":-32601,"message":"'Browser.getVersions' wasn't found"}}
```

The typo makes sense. But `Page.navigate` is a real command, so why wasn't it found? Because we're talking to the browser, and the browser has no page to navigate.

## Targets and sessions

Chrome organizes everything as **targets**. The browser is a target, every tab is a target, and so are iframes from other sites, service workers and extension pages.

Each target supports different domains. The browser target knows `Browser` and `Target`. A page target knows `Page`, `Runtime`, `DOM`, `Network` and the rest.

To control a page, we create one with `Target.createTarget`, then attach to it with `Target.attachToTarget`. Attaching gives us a **session ID**. From then on, any message that includes that `sessionId` goes to the page instead of the browser.

```mermaid
sequenceDiagram
  accTitle: Opening a page and navigating it over CDP
  accDescr: The script asks the browser to create a target, attaches to it to get a session ID, and then sends page commands with that session ID. Chrome answers each command and also sends events like loadEventFired.
  participant S as Script
  participant B as Chrome
  S->>B: Target.createTarget
  B-->>S: targetId
  S->>B: Target.attachToTarget (flatten: true)
  B-->>S: sessionId
  S->>B: Page.enable (sessionId)
  B-->>S: {}
  S->>B: Page.navigate (sessionId)
  B-->>S: frameId, loaderId
  B-->>S: event Page.loadEventFired (sessionId)
```

The `flatten: true` option matters. Without it, page messages travel wrapped inside `Target.sendMessageToTarget` commands, as JSON strings inside JSON. With it, they go straight over the same WebSocket with a `sessionId` field. Puppeteer and Playwright use flat mode, and so will we.

You can also open a separate WebSocket to each page using the `webSocketDebuggerUrl` from `/json/list`. That works for quick scripts, but one connection with sessions is easier to manage once you have several pages.

## A tiny CDP client in Node.js

Sending raw JSON gets old quickly. We need three things:

1. a `send()` that assigns an `id` and returns a promise that resolves with the response
2. an `on()` to listen for events, filtered by session
3. a `newPage()` helper that creates a target and attaches to it

Here's the whole thing. Save it as `cdp.mjs`:

```js
export async function connect(url = 'http://127.0.0.1:9333') {
  if (url.startsWith('http')) {
    const res = await fetch(`${url}/json/version`)
    const { webSocketDebuggerUrl } = await res.json()
    url = webSocketDebuggerUrl
  }

  const ws = new WebSocket(url)
  await new Promise((resolve, reject) => {
    ws.addEventListener('open', resolve, { once: true })
    ws.addEventListener('error', reject, { once: true })
  })

  let lastId = 0
  const pending = new Map()
  const listeners = new Map()

  ws.addEventListener('message', (event) => {
    const message = JSON.parse(event.data)

    if (message.id) {
      const { resolve, reject, method } = pending.get(message.id)
      pending.delete(message.id)
      if (message.error) {
        reject(new Error(`${method}: ${message.error.message}`))
      } else {
        resolve(message.result)
      }
      return
    }

    const key = `${message.sessionId ?? ''}:${message.method}`
    for (const listener of listeners.get(key) ?? []) {
      listener(message.params)
    }
  })

  function send(method, params = {}, sessionId) {
    const id = ++lastId
    ws.send(JSON.stringify({ id, method, params, sessionId }))
    return new Promise((resolve, reject) => {
      pending.set(id, { resolve, reject, method })
    })
  }

  function on(method, listener, sessionId) {
    const key = `${sessionId ?? ''}:${method}`
    if (!listeners.has(key)) listeners.set(key, new Set())
    listeners.get(key).add(listener)
    return () => listeners.get(key).delete(listener)
  }

  function once(method, sessionId) {
    return new Promise((resolve) => {
      const off = on(method, (params) => {
        off()
        resolve(params)
      }, sessionId)
    })
  }

  return { send, on, once, close: () => ws.close() }
}

export async function newPage(browser) {
  const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank' })
  const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true })

  return {
    targetId,
    sessionId,
    send: (method, params) => browser.send(method, params, sessionId),
    on: (method, listener) => browser.on(method, listener, sessionId),
    once: (method) => browser.once(method, sessionId),
    close: () => browser.send('Target.closeTarget', { targetId }),
  }
}
```

`connect()` accepts either the HTTP address of the debugging port or a WebSocket URL. It keeps a `pending` map from `id` to the promise waiting for it. When a message arrives with an `id`, it settles that promise. When a message has no `id`, it's an event, and it goes to the listeners registered for that method and session.

`JSON.stringify` drops `sessionId` when it's `undefined`, so the same `send()` works for browser commands and page commands.

`newPage()` returns an object with the session already bound. That's what Puppeteer calls a `Page`, minus a few thousand lines.

## Open a page and read its title

Now the client is useful. We open a page, navigate it, wait for the `load` event and read `document.title`:

```js
import { connect, newPage } from './cdp.mjs'

const browser = await connect()
const page = await newPage(browser)

await page.send('Page.enable')
const loaded = page.once('Page.loadEventFired')
await page.send('Page.navigate', { url: 'https://flaviocopes.com' })
await loaded

const { result } = await page.send('Runtime.evaluate', {
  expression: 'document.title',
})
console.log(result)

await page.close()
browser.close()
```

```txt
{ type: 'string', value: 'Flavio Copes' }
```

**Domains are off until you enable them.** Chrome sends `Page` events only after `Page.enable`, `Network` events only after `Network.enable`, and so on. If you wait for `Page.loadEventFired` without enabling `Page`, your script hangs forever.

**Listen before you act.** I create the `loaded` promise *before* calling `Page.navigate`. A fast page can fire `load` before `Page.navigate` even returns, and a listener added after that would miss it.

The whole script took 0.57 seconds, including the network round trip to my site.

## Wait for the page to load

The `load` event isn't always what you want. A page can fire `load` and then fetch half its content with JavaScript.

`Page.setLifecycleEventsEnabled` gives you every loading milestone as a `Page.lifecycleEvent`. Let's print them with their timing:

```js
import { connect, newPage } from './cdp.mjs'

const browser = await connect()
const page = await newPage(browser)

await page.send('Page.enable')
await page.send('Page.setLifecycleEventsEnabled', { enabled: true })

const start = Date.now()
const idle = new Promise((resolve) => {
  page.on('Page.lifecycleEvent', ({ name, frameId }) => {
    if (frameId !== page.targetId) return
    console.log(`${Date.now() - start}ms`, name)
    if (name === 'networkIdle') resolve()
  })
})

await page.send('Page.navigate', { url: 'https://flaviocopes.com' })
await idle

await page.close()
browser.close()
```

```txt
78ms init
101ms DOMContentLoaded
120ms firstMeaningfulPaintCandidate
120ms firstContentfulPaint
120ms firstPaint
120ms firstImagePaint
181ms load
782ms networkAlmostIdle
782ms firstMeaningfulPaint
783ms networkIdle
```

The `frameId` check filters out iframes. My homepage has a Cloudflare Turnstile iframe, and before I added the check, I got two `init` events. For a page target, the main frame ID is the same as the target ID.

`networkIdle` means no network connections for 500ms, and `networkAlmostIdle` means at most two. These are what Puppeteer's `waitUntil: 'networkidle0'` and `'networkidle2'` wait for.

Here `load` came at 181ms and `networkIdle` 600ms later. So waiting for network idle costs time on every navigation. Use it when the page loads data after `load`, and stick to `load` when it doesn't.

## Run JavaScript in the page

`Runtime.evaluate` runs an expression in the page and returns the result. By default, objects come back as a *reference*, not as data:

```js
const a = await page.send('Runtime.evaluate', {
  expression: `[...document.querySelectorAll('main li a')].slice(0, 3).map((a) => a.textContent)`,
})
console.log(a.result)
```

```txt
{
  type: 'object',
  subtype: 'array',
  className: 'Array',
  description: 'Array(3)',
  objectId: '614966600231285712.2.1'
}
```

The `objectId` points to the array living inside the page. You can pass it to other commands like `Runtime.callFunctionOn` or `DOM.requestNode`. To get the actual values, add `returnByValue: true`:

```js
const b = await page.send('Runtime.evaluate', {
  expression: `[...document.querySelectorAll('main li a')].slice(0, 3).map((a) => a.textContent)`,
  returnByValue: true,
})
console.log(b.result)
```

```txt
{
  type: 'object',
  value: [
    'LiveKit tutorial: how to build a voice AI agent',
    'I want my coding agent to do what I ask',
    'Cloudflare Zero Wire: what it is and how to use it'
  ]
}
```

The value is serialized as JSON, so DOM nodes, functions and other things that don't serialize come back empty.

If the expression returns a promise, `awaitPromise: true` waits for it. Here the page fetches my RSS feed and returns its length:

```js
const c = await page.send('Runtime.evaluate', {
  expression: `fetch('/rss.xml').then((res) => res.text()).then((text) => text.length)`,
  awaitPromise: true,
})
console.log(c.result)
```

```txt
{ type: 'number', value: 13234, description: '13234' }
```

Be careful with errors. A JavaScript exception in the page *doesn't* reject the command. The response is successful, and the error is inside `exceptionDetails`:

```js
const d = await page.send('Runtime.evaluate', {
  expression: `document.querySelector('.missing').textContent`,
})
console.log(d.exceptionDetails.exception.description.split('\n')[0])
```

```txt
TypeError: Cannot read properties of null (reading 'textContent')
```

Check `exceptionDetails` after every evaluation whose result you rely on, or you'll read `undefined` and wonder why.

## Wait for an element

Puppeteer has `waitForSelector` and locators that wait on their own. With CDP we write that part ourselves. The simplest version evaluates an expression every 100ms until it returns something truthy:

```js
export async function waitFor(page, expression, timeout = 5000) {
  const start = Date.now()
  while (Date.now() - start < timeout) {
    const { result } = await page.send('Runtime.evaluate', {
      expression,
      returnByValue: true,
    })
    if (result.value) return result.value
    await new Promise((resolve) => setTimeout(resolve, 100))
  }
  throw new Error(`Timed out waiting for: ${expression}`)
}
```

Save it as `wait.mjs`. The hard part is choosing *what* to wait for, and that's true with Puppeteer too.

I tested it on the search box of my homepage, which uses Pagefind. My first try waited for the results message to exist. It returned right away with `Searching for puppeteer...`, the loading text. My second try waited for any result link, and got 4, because Pagefind renders results in batches. What worked was waiting for 10 links, the number Pagefind shows on the first page.

## Click and type

Puppeteer's `page.click()` doesn't call `element.click()`. It finds where the element is on screen and sends real mouse events there. We can do the same:

1. `DOM.getDocument` and `DOM.querySelector` find the element's node
2. `DOM.getBoxModel` returns its position as quads, four x/y points per box
3. `Input.dispatchMouseEvent` presses and releases the mouse at its center

Then `Input.insertText` types text into whatever has focus. Here's the full search on my homepage:

```js
import { writeFile } from 'node:fs/promises'
import { connect, newPage } from './cdp.mjs'
import { waitFor } from './wait.mjs'

const browser = await connect()
const page = await newPage(browser)

await page.send('Emulation.setDeviceMetricsOverride', {
  width: 1280,
  height: 800,
  deviceScaleFactor: 1,
  mobile: false,
})
await page.send('Page.enable')
const loaded = page.once('Page.loadEventFired')
await page.send('Page.navigate', { url: 'https://flaviocopes.com' })
await loaded

const { root } = await page.send('DOM.getDocument')
const { nodeId } = await page.send('DOM.querySelector', {
  nodeId: root.nodeId,
  selector: '.pagefind-ui__search-input',
})
const { model } = await page.send('DOM.getBoxModel', { nodeId })

const [x1, y1, , , x3, y3] = model.content
const x = (x1 + x3) / 2
const y = (y1 + y3) / 2

await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
await page.send('Input.insertText', { text: 'puppeteer' })

await waitFor(page, `document.querySelectorAll('.pagefind-ui__result-link').length >= 10`)

const { data } = await page.send('Page.captureScreenshot', { format: 'webp', quality: 80 })
await writeFile('out/search.webp', Buffer.from(data, 'base64'))

await page.close()
browser.close()
```

The box model for the search input was `[53, 169, 1007, 169, 1007, 211, 53, 211]`: top-left, top-right, bottom-right, bottom-left. So the click landed at 530, 190. Here's the screenshot the script saved:

![The flaviocopes.com homepage after the script typed puppeteer in the search box, showing 12 results](https://flaviocopes.com/images/chrome-devtools-protocol/search.webp)

The coordinates are relative to the viewport. If the element is below the fold, scroll it into view first with `DOM.scrollIntoViewIfNeeded`, then read the box model again. Puppeteer does the same before every click.

`Input.insertText` inserts text in one go, like a paste. It's marked experimental, but Puppeteer uses it too. For keys that do something, like Enter or Tab, use `Input.dispatchKeyEvent` with a `keyDown` and a `keyUp`:

```js
const enter = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' }
await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...enter })
await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...enter })
```

I tested it on a small form whose submit handler copies the input into `document.title`, and the title became `chrome devtools protocol`, the text typed with `insertText`. Without `text: '\r'`, the key event fires but the form doesn't submit.

This per-key detail is one thing Puppeteer saves you from. It ships a table with the `key`, `code`, `keyCode` and `text` for every key on a US keyboard.

## Take screenshots

`Page.captureScreenshot` returns the image as base64. It supports `png`, `jpeg` and `webp`, so you can skip a conversion step. Set the viewport size first with `Emulation.setDeviceMetricsOverride`:

```js
import { writeFile } from 'node:fs/promises'
import { connect, newPage } from './cdp.mjs'

const browser = await connect()
const page = await newPage(browser)

await page.send('Emulation.setDeviceMetricsOverride', {
  width: 1280,
  height: 800,
  deviceScaleFactor: 1,
  mobile: false,
})

await page.send('Page.enable')
const loaded = page.once('Page.loadEventFired')
await page.send('Page.navigate', { url: 'https://flaviocopes.com' })
await loaded

const { data } = await page.send('Page.captureScreenshot', { format: 'webp', quality: 80 })
await writeFile('out/homepage.webp', Buffer.from(data, 'base64'))

await page.close()
browser.close()
```

This produced a 1280×800 image of 89 KB:

![The flaviocopes.com homepage captured at 1280 by 800 pixels with Page.captureScreenshot](https://flaviocopes.com/images/chrome-devtools-protocol/homepage.webp)

For a full page screenshot, ask `Page.getLayoutMetrics` for the content size, then capture a clip of that size with `captureBeyondViewport`:

```js
const { cssContentSize } = await page.send('Page.getLayoutMetrics')
console.log(cssContentSize)

const { data } = await page.send('Page.captureScreenshot', {
  format: 'webp',
  captureBeyondViewport: true,
  clip: { x: 0, y: 0, width: cssContentSize.width, height: cssContentSize.height, scale: 1 },
})
```

```txt
{ x: 0, y: 0, width: 1280, height: 9846 }
```

On my [Puppeteer introduction](https://flaviocopes.com/puppeteer/) that gave a 1280×9846 image of 451 KB.

To capture a single element, use its box model from `DOM.getBoxModel` as the `clip`.

## Emulate a phone and dark mode

The `Emulation` domain changes how the page sees the device. Here we set an iPhone-sized viewport at 3x density, enable touch events, and tell the page the user prefers dark mode:

```js
import { writeFile } from 'node:fs/promises'
import { connect, newPage } from './cdp.mjs'

const browser = await connect()
const page = await newPage(browser)

await page.send('Emulation.setDeviceMetricsOverride', {
  width: 390,
  height: 844,
  deviceScaleFactor: 3,
  mobile: true,
})
await page.send('Emulation.setTouchEmulationEnabled', { enabled: true })
await page.send('Emulation.setEmulatedMedia', {
  features: [{ name: 'prefers-color-scheme', value: 'dark' }],
})

await page.send('Page.enable')
const loaded = page.once('Page.loadEventFired')
await page.send('Page.navigate', { url: 'https://flaviocopes.com/puppeteer/' })
await loaded

const { data } = await page.send('Page.captureScreenshot', { format: 'webp', quality: 80 })
await writeFile('out/mobile-dark.webp', Buffer.from(data, 'base64'))

await page.close()
browser.close()
```

The screenshot came out at 1170×2532, three times 390×844. Here's the top of it, resized:

![The Introduction to Puppeteer post rendered at a phone viewport in dark mode](https://flaviocopes.com/images/chrome-devtools-protocol/mobile-dark.webp)

Other useful commands in this domain:

- `Emulation.setUserAgentOverride` changes the user agent, so you can drop `HeadlessChrome`
- `Emulation.setGeolocationOverride` sets the GPS position
- `Emulation.setTimezoneOverride` and `Emulation.setLocaleOverride` change the timezone and language
- `Emulation.setEmulatedMedia` with `media: 'print'` shows the print stylesheet
- `Emulation.setCPUThrottlingRate` slows the CPU down

## Generate a PDF

`Page.printToPDF` prints the page like the browser's print dialog does. Sizes are in inches, and the header and footer are HTML templates with special classes like `pageNumber` and `totalPages`:

```js
import { writeFile } from 'node:fs/promises'
import { connect, newPage } from './cdp.mjs'

const browser = await connect()
const page = await newPage(browser)

await page.send('Page.enable')
const loaded = page.once('Page.loadEventFired')
await page.send('Page.navigate', { url: 'https://flaviocopes.com/puppeteer/' })
await loaded

const { data } = await page.send('Page.printToPDF', {
  paperWidth: 8.27,
  paperHeight: 11.69,
  printBackground: true,
  displayHeaderFooter: true,
  headerTemplate: '<span></span>',
  footerTemplate: '<div style="font-size:9px;width:100%;text-align:center"><span class="pageNumber"></span> / <span class="totalPages"></span></div>',
})
await writeFile('out/puppeteer.pdf', Buffer.from(data, 'base64'))

await page.close()
browser.close()
```

8.27×11.69 inches is A4. The result was a 10-page PDF of 409 KB. Here's the first page, with the page counter at the bottom:

![First page of the PDF generated with Page.printToPDF, with a 1 / 10 page counter in the footer](https://flaviocopes.com/images/chrome-devtools-protocol/pdf-first-page.webp)

I set `headerTemplate` to an empty `<span>` because with `displayHeaderFooter` on, Chrome otherwise adds its default header with the date and page title. The footer template needs an explicit `font-size`, since the default is too small to read.

## Watch network traffic

After `Network.enable`, Chrome reports every request in three steps:

- `Network.requestWillBeSent` when the request starts, with its URL and resource type
- `Network.responseReceived` when the headers arrive, with the status code
- `Network.loadingFinished` when the body is done, with the bytes transferred

All three share a `requestId`, so we collect them in a map:

```js
import { connect, newPage } from './cdp.mjs'

const browser = await connect()
const page = await newPage(browser)

const requests = new Map()

page.on('Network.requestWillBeSent', ({ requestId, request, type }) => {
  requests.set(requestId, { url: request.url, type, bytes: 0 })
})
page.on('Network.responseReceived', ({ requestId, response }) => {
  requests.get(requestId).status = response.status
})
page.on('Network.loadingFinished', ({ requestId, encodedDataLength }) => {
  requests.get(requestId).bytes = encodedDataLength
})

await page.send('Network.enable')
await page.send('Network.setCacheDisabled', { cacheDisabled: true })
await page.send('Page.enable')
await page.send('Page.setLifecycleEventsEnabled', { enabled: true })

const idle = new Promise((resolve) => {
  page.on('Page.lifecycleEvent', ({ name }) => {
    if (name === 'networkIdle') resolve()
  })
})
await page.send('Page.navigate', { url: 'https://flaviocopes.com' })
await idle

for (const { status, type, bytes, url } of requests.values()) {
  console.log(status, type.padEnd(10), String(bytes).padStart(6), url.slice(0, 60))
}

await page.close()
browser.close()
```

```txt
200 Document    14073 https://flaviocopes.com/
200 Stylesheet   3178 https://flaviocopes.com/pagefind/pagefind-ui.css
200 Stylesheet  19056 https://flaviocopes.com/_astro/Sponsors.CO4ocJaI.css
200 Image        8603 https://flaviocopes.com/img/favicon-96x96.png
200 Script      31444 https://flaviocopes.com/pagefind/pagefind-ui.js
200 Script        992 https://flaviocopes.com/cdn-cgi/scripts/5c5dd728/cloudflare-
200 Script       6774 https://b.flaviocopes.com/js/pa-t-Y2_teFHacUlqaVa-545.js
200 Script      10439 https://static.cloudflareinsights.com/beacon.min.js
200 Font        23653 https://flaviocopes.com/_astro/geist-mono-latin-wght-normal.
200 Script      28325 https://challenges.cloudflare.com/turnstile/v0/b/8340802ab0b
200 Document        0 https://challenges.cloudflare.com/cdn-cgi/challenge-platform
202 Fetch         523 https://b.flaviocopes.com/api/event
204 XHR            36 https://cloudflareinsights.com/cdn-cgi/rum
200 Other        1377 https://flaviocopes.com/favicon.ico
200 Other           0 https://cloudflareinsights.com/cdn-cgi/rum
```

I added `Network.setCacheDisabled` after my first run, which reported 0 bytes for everything except the HTML, because the profile already had those files in its cache from earlier tests.

To read a response body, call `Network.getResponseBody` with the `requestId` after `loadingFinished`. It returns the body as text, or as base64 for binary files.

## Block and modify requests

The `Network` domain only watches. To change requests, use the `Fetch` domain. After `Fetch.enable`, Chrome pauses every matching request and sends a `Fetch.requestPaused` event. Each paused request waits until you answer with one of these:

- `Fetch.continueRequest` lets it through, optionally with a different URL, method, headers or body
- `Fetch.failRequest` blocks it with a network error
- `Fetch.fulfillRequest` answers it yourself with a status, headers and body, without hitting the network

Here we block images, fonts and anything that isn't on `flaviocopes.com`, and count the requests and bytes with and without blocking:

```js
import { connect, newPage } from './cdp.mjs'

const block = process.argv[2] === 'block'

const browser = await connect()
const page = await newPage(browser)

let requests = 0
let bytes = 0
let blocked = 0
page.on('Network.requestWillBeSent', () => requests++)
page.on('Network.loadingFinished', ({ encodedDataLength }) => { bytes += encodedDataLength })

await page.send('Network.enable')
await page.send('Network.setCacheDisabled', { cacheDisabled: true })

if (block) {
  page.on('Fetch.requestPaused', ({ requestId, request, resourceType }) => {
    const thirdParty = !new URL(request.url).hostname.endsWith('flaviocopes.com')
    if (thirdParty || resourceType === 'Image' || resourceType === 'Font') {
      blocked++
      page.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' })
    } else {
      page.send('Fetch.continueRequest', { requestId })
    }
  })
  await page.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] })
}

await page.send('Page.enable')
await page.send('Page.setLifecycleEventsEnabled', { enabled: true })
const start = Date.now()
const idle = new Promise((resolve) => {
  page.on('Page.lifecycleEvent', ({ name, frameId }) => {
    if (name === 'networkIdle' && frameId === page.targetId) resolve()
  })
})
await page.send('Page.navigate', { url: 'https://flaviocopes.com' })
await idle

console.log({ block, requests, blocked, kb: Math.round(bytes / 1024), ms: Date.now() - start })
await page.close()
browser.close()
```

I ran `node block.mjs` and `node block.mjs block` three times each:

```txt
{ block: false, requests: 16, blocked: 0, kb: 145, ms: 851 }
{ block: true, requests: 12, blocked: 4, kb: 76, ms: 1110 }
{ block: false, requests: 16, blocked: 0, kb: 145, ms: 912 }
{ block: true, requests: 12, blocked: 4, kb: 76, ms: 730 }
{ block: false, requests: 16, blocked: 0, kb: 145, ms: 823 }
{ block: true, requests: 12, blocked: 4, kb: 76, ms: 734 }
```

| | Without blocking | With blocking |
| --- | --- | --- |
| Requests started | 16 | 12 |
| Requests blocked | 0 | 4 |
| Data transferred | 145 KB | 76 KB |
| Time to network idle | 823 to 912 ms | 730 to 1110 ms |

The 4 blocked requests still count as started. They also kept 4 others from ever starting, like the Turnstile iframe and the Cloudflare analytics calls the blocked scripts would have made. The transferred data almost halved. The timing barely changed, because `networkIdle` always waits 500ms of silence and my site is small. On a page full of ads and trackers, the difference is much bigger.

Every paused request needs an answer. If an exception skips the `continueRequest` call, that request hangs and the page never finishes loading. To pause only some requests, put a narrower `urlPattern` or a `resourceType` in `patterns`.

## Cookies

`Network.setCookie` sets a cookie before you navigate, and `Network.getCookies` reads the cookies for the current page:

```js
import { connect, newPage } from './cdp.mjs'

const browser = await connect()
const page = await newPage(browser)

await page.send('Network.setCookie', {
  name: 'theme',
  value: 'dark',
  domain: 'flaviocopes.com',
  path: '/',
})

await page.send('Page.enable')
const loaded = page.once('Page.loadEventFired')
await page.send('Page.navigate', { url: 'https://flaviocopes.com' })
await loaded

const { cookies } = await page.send('Network.getCookies')
console.log(cookies.map(({ name, domain }) => `${name} (${domain})`))

await page.close()
browser.close()
```

```txt
[ 'cf_clearance (.flaviocopes.com)', 'theme (flaviocopes.com)' ]
```

The `cf_clearance` cookie came from Cloudflare Turnstile during earlier runs. The profile folder keeps cookies between runs, like a normal Chrome profile. Use a fresh `--user-data-dir` when you need a clean state, or `Storage.clearCookies` to wipe them.

Saving cookies from `Network.getCookies` to a file and loading them back with `Network.setCookies` is how you keep a login between runs.

## Console messages and errors

After `Runtime.enable`, Chrome sends `Runtime.consoleAPICalled` for every `console` call in the page, and `Runtime.exceptionThrown` for uncaught errors:

```js
import { connect, newPage } from './cdp.mjs'

const browser = await connect()
const page = await newPage(browser)

page.on('Runtime.consoleAPICalled', ({ type, args }) => {
  console.log(`[console.${type}]`, args.map((arg) => arg.value ?? arg.description).join(' '))
})
page.on('Runtime.exceptionThrown', ({ exceptionDetails }) => {
  console.log('[exception]', exceptionDetails.exception.description.split('\n')[0])
})

await page.send('Runtime.enable')
await page.send('Runtime.evaluate', {
  expression: `
    console.log('page loaded', 42)
    console.warn('this is a warning')
    setTimeout(() => { undefinedFunction() }, 0)
  `,
})

await new Promise((resolve) => setTimeout(resolve, 200))
await page.close()
browser.close()
```

```txt
[console.log] page loaded 42
[console.warning] this is a warning
[exception] ReferenceError: undefinedFunction is not defined
```

Notice `console.warning`. CDP calls it `warning`, not `warn`, so a filter on `type === 'warn'` never matches.

The arguments are remote objects, like the `Runtime.evaluate` results. Strings and numbers have a `value`, and objects only have a `description` unless you fetch their properties. Browser-level messages, like a failed resource load or a deprecation warning, come through the `Log` domain as `Log.entryAdded` after `Log.enable`.

Note that `Runtime.evaluate` worked earlier without `Runtime.enable`. You only need to enable the domain for its events.

## Performance metrics and CPU throttling

`Performance.getMetrics` returns counters from the page's renderer. Here are a few of them after loading my homepage:

```js
import { connect, newPage } from './cdp.mjs'

const browser = await connect()
const page = await newPage(browser)

await page.send('Performance.enable')
await page.send('Page.enable')
const loaded = page.once('Page.loadEventFired')
await page.send('Page.navigate', { url: 'https://flaviocopes.com' })
await loaded

const { metrics } = await page.send('Performance.getMetrics')
for (const { name, value } of metrics) {
  if (['Nodes', 'LayoutCount', 'RecalcStyleCount', 'ScriptDuration', 'TaskDuration', 'JSHeapUsedSize'].includes(name)) {
    console.log(name.padEnd(17), value)
  }
}

await page.close()
browser.close()
```

```txt
Nodes             947
LayoutCount       6
RecalcStyleCount  7
ScriptDuration    0.006223
TaskDuration      0.029736
JSHeapUsedSize    1723128
```

Durations are in seconds. So the page ran 6ms of JavaScript and 30ms of main thread work to load, and used 1.7 MB of JavaScript heap.

To test how a page behaves on a slow phone, slow down the CPU with `Emulation.setCPUThrottlingRate`. A rate of 4 makes it four times slower. We'll see it in action in the Puppeteer section.

For a full performance trace, the kind the DevTools Performance panel shows, use the `Tracing` domain. It streams trace events you can save as JSON and open in DevTools.

## Launch Chrome from your script

So far we started Chrome by hand. Puppeteer's `launch()` starts it for you, and we can do the same with `child_process.spawn`.

With `--remote-debugging-port=0`, Chrome picks a free port and prints the WebSocket URL on stderr. We read it from there, and we never collide with another browser:

```js
import { spawn } from 'node:child_process'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { connect } from './cdp.mjs'

const chromePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'

export async function launch() {
  const userDataDir = await mkdtemp(join(tmpdir(), 'cdp-profile-'))

  const chrome = spawn(chromePath, [
    '--headless',
    '--remote-debugging-port=0',
    `--user-data-dir=${userDataDir}`,
    '--no-first-run',
    '--no-default-browser-check',
  ])

  const url = await new Promise((resolve, reject) => {
    chrome.stderr.on('data', (chunk) => {
      const match = chunk.toString().match(/DevTools listening on (ws:\/\/\S+)/)
      if (match) resolve(match[1])
    })
    chrome.on('exit', (code) => reject(new Error(`Chrome exited with code ${code}`)))
  })

  console.log(url)
  return connect(url)
}
```

Save it as `launch.mjs`. `Browser.close` shuts Chrome down when we're done:

```js
import { launch } from './launch.mjs'

const browser = await launch()
const { product } = await browser.send('Browser.getVersion')
console.log(product)

await browser.send('Browser.close')
```

```txt
ws://127.0.0.1:65160/devtools/browser/5510c155-a469-4a9c-8049-f142abdc40f2
Chrome/156.0.8078.81
```

The whole run, from spawning Chrome to closing it, took 0.53 seconds.

In a real tool you'd also kill the process if your script crashes, delete the temporary profile, and find Chrome on Linux and Windows. That's what `puppeteer.launch()` does, plus downloading a known Chrome version.

Chrome also supports `--remote-debugging-pipe`, which talks CDP over file descriptors 3 and 4 instead of a port. No other program on the machine can connect to it. Puppeteer uses it when you pass `pipe: true` to `launch()`.

## Replacing Puppeteer with CDP

Puppeteer is a CDP client. A large, careful one, but every method ends up as the same JSON messages we've been sending.

You can see them. Puppeteer logs its protocol traffic through Node's `util.debuglog`, so the `NODE_DEBUG` variable turns it on. Here's a Puppeteer version of our title script, connected to the same Chrome:

```js
import puppeteer from 'puppeteer-core'

const browser = await puppeteer.connect({ browserURL: 'http://127.0.0.1:9333' })
const page = await browser.newPage()

await page.goto('https://flaviocopes.com')
console.log(await page.title())

await page.close()
await browser.disconnect()
```

```bash
NODE_DEBUG='puppeteer:protocol:*' node title.mjs 2> protocol.log
```

The log had 253 lines. These are the first ones:

```txt
PUPPETEER:PROTOCOL:SEND ► 77409: {"method":"Target.getBrowserContexts","id":1}
PUPPETEER:PROTOCOL:RECV ◀ 77409: {"id":1,"result":{"browserContextIds":[],"defaultBrowserContextId":"6ACD9EC3F08A01BD43715504A0FDBFCF"}}
PUPPETEER:PROTOCOL:SEND ► 77409: {"method":"Target.setDiscoverTargets","params":{"discover":true,"filter":[{}]},"id":2}
```

Older Puppeteer versions used the `debug` package and the `DEBUG` variable. With Puppeteer 25, `DEBUG` printed nothing and `NODE_DEBUG` worked.

To open a page, navigate and read the title, Puppeteer sent 42 commands. After attaching to every target it found, it sent this for the new page:

```txt
Network.enable
Page.enable
Page.getFrameTree
Page.setLifecycleEventsEnabled
Runtime.enable
Audits.enable
Performance.enable
Log.enable
WebMCP.enable
Page.addScriptToEvaluateOnNewDocument
Page.createIsolatedWorld
Emulation.setDeviceMetricsOverride
Emulation.setTouchEmulationEnabled
Page.navigate
```

It enables every domain it might need, creates an isolated JavaScript world so its own helpers don't clash with the page's code, and sets a default 800×600 viewport. `page.title()` became a `Runtime.callFunctionOn`, and `page.close()` a `Target.closeTarget`.

Our script sent 6 commands for the same job. I counted the messages in both, and timed 5 runs of each:

| | Raw CDP client | `puppeteer-core` 25.16.0 |
| --- | --- | --- |
| npm packages installed | 0 | 25 |
| `node_modules` size | 0 | 32 MB |
| Commands sent | 6 | 42 |
| Messages received | 20 | 211 |
| Time per run | 0.23 to 0.27 s | 0.29 to 0.35 s |
| Downloads a browser | No | No (`puppeteer` does) |

The time difference doesn't matter much in practice, since the page load dominates. The dependency difference matters more if you ship a CLI or a small serverless function.

The full `puppeteer` package also downloads Chrome on install. My `~/.cache/puppeteer` folder had grown to 751 MB with three browser builds: two versions of Chrome for Testing (242 MB and 325 MB) and a `chrome-headless-shell` (184 MB). With raw CDP you use the Chrome you already have.

### Puppeteer methods and their CDP commands

Most of what you use in Puppeteer has a direct CDP equivalent:

| Puppeteer | CDP |
| --- | --- |
| `puppeteer.launch()` | Spawn Chrome with `--remote-debugging-port=0` |
| `puppeteer.connect()` | `GET /json/version`, then a WebSocket |
| `browser.newPage()` | `Target.createTarget` + `Target.attachToTarget` |
| `page.goto(url)` | `Page.navigate` + wait for a lifecycle event |
| `page.evaluate(fn)` | `Runtime.evaluate` or `Runtime.callFunctionOn` |
| `page.$(selector)` | `DOM.querySelector` |
| `page.click(selector)` | `DOM.getBoxModel` + `Input.dispatchMouseEvent` |
| `page.type(selector, text)` | `Input.insertText` or `Input.dispatchKeyEvent` |
| `page.screenshot()` | `Page.captureScreenshot` |
| `page.pdf()` | `Page.printToPDF` |
| `page.setViewport()` | `Emulation.setDeviceMetricsOverride` |
| `page.emulateMediaFeatures()` | `Emulation.setEmulatedMedia` |
| `page.setUserAgent()` | `Emulation.setUserAgentOverride` |
| `page.setRequestInterception()` | `Fetch.enable` + `Fetch.requestPaused` |
| `page.on('console')` | `Runtime.consoleAPICalled` |
| `page.on('response')` | `Network.responseReceived` |
| `page.cookies()` | `Network.getCookies` |
| `page.metrics()` | `Performance.getMetrics` |
| `page.close()` | `Target.closeTarget` |
| `browser.close()` | `Browser.close` |

### When Puppeteer is still the better choice

Each line in that table is easy. What Puppeteer adds is the work around them, and you'd have to write it yourself:

- **Waiting.** Locators retry until the element exists, is visible, is stable and isn't covered by another element. Our `waitFor()` only polls an expression.
- **Iframes from other sites.** Each one runs in its own target. Puppeteer attaches to them automatically with `Target.setAutoAttach` and routes commands to the right session.
- **Keyboard.** `page.keyboard.press('ArrowDown')` knows the `key`, `code` and key codes for every key. With CDP you look them up.
- **Dialogs, downloads and file uploads.** They work with `Page.handleJavaScriptDialog`, `Browser.setDownloadBehavior` and `DOM.setFileInputFiles`, but you have to handle each case.
- **Browser versions.** Puppeteer is tested against a specific Chrome, and it handles protocol changes between versions for you.
- **Firefox.** Puppeteer can also drive Firefox through WebDriver BiDi. CDP is Chromium only.

My advice: use raw CDP for focused jobs, like taking screenshots, printing PDFs, grabbing a value from a page, recording network traffic or checking console errors. Each of those needs only a handful of commands.

For test suites or scrapers that click through many pages, use Puppeteer or [Playwright](https://flaviocopes.com/playwright-e2e-testing/). Their waiting logic alone saves you days of flaky failures.

### Mix both with createCDPSession

You don't have to choose. `page.createCDPSession()` gives you a raw CDP session for a Puppeteer page, so you can call anything Puppeteer doesn't wrap.

Here we slow the CPU down four times and time a loop in the page:

```js
import puppeteer from 'puppeteer-core'

const browser = await puppeteer.connect({ browserURL: 'http://127.0.0.1:9333' })
const page = await browser.newPage()

const session = await page.createCDPSession()
await session.send('Emulation.setCPUThrottlingRate', { rate: 4 })

const start = Date.now()
await page.evaluate(() => {
  let n = 0
  for (let i = 0; i < 50_000_000; i++) n += i
  return n
})
console.log(`${Date.now() - start}ms`)

await page.close()
await browser.disconnect()
```

```txt
140ms
```

With `rate: 1`, the same loop took 34ms.

Playwright has the same escape hatch, `page.context().newCDPSession(page)`, and it can attach to an existing Chrome with `chromium.connectOverCDP()`. That's also how hosted browsers work. Services like Cloudflare's [Kitesurf](https://flaviocopes.com/kitesurf-agentic-browser/), Browserbase and Browserless give you a CDP WebSocket URL, and you point your client at it.

## CDP beyond Chrome

The protocol isn't only for Chrome tabs.

Every Chromium-based browser speaks it, including Edge, Brave and Opera, and so do Electron apps started with `--remote-debugging-port`.

Node.js speaks a subset of it too. When you run `node --inspect`, Node opens a CDP endpoint with domains like `Runtime`, `Debugger`, `Profiler` and `HeapProfiler`. That's how [debugging Node.js in Chrome DevTools](https://flaviocopes.com/node-debug-devtools/) works.

I started a small script that increments a counter every 100ms:

```js
let visits = 0
setInterval(() => visits++, 100)
globalThis.getVisits = () => visits
```

```bash
node --inspect=9229 server.mjs
```

```txt
Debugger listening on ws://127.0.0.1:9229/9918442d-5b7d-4527-a207-206a91ac8dd0
```

Node has no `/json/version` browser endpoint, so we take the first target from `/json/list` and evaluate an expression inside the running process:

```js
const res = await fetch('http://127.0.0.1:9229/json/list')
const [target] = await res.json()

const ws = new WebSocket(target.webSocketDebuggerUrl)
ws.addEventListener('open', () => {
  ws.send(JSON.stringify({
    id: 1,
    method: 'Runtime.evaluate',
    params: { expression: 'process.version + " " + getVisits()' },
  }))
})
ws.addEventListener('message', (event) => {
  console.log(event.data)
  ws.close()
})
```

```json
{"id":1,"result":{"result":{"type":"string","value":"v22.23.4 20"}}}
```

The counter was at 20, two seconds after the process started. That's a way to inspect a live Node.js process without stopping it.

AI agents use CDP as well. The `chrome-devtools-mcp` server from the Chrome team gives coding agents tools to navigate, click, read console messages and record performance traces, and it sends CDP commands to Chrome under the hood. Playwright MCP does the same with Playwright. I wrote about its [isolated contexts](https://flaviocopes.com/playwright-mcp-isolated-contexts/), which let one browser keep two accounts signed in.

## Keep the debugging port private

Anything that can reach the debugging port controls the browser completely. It can read every cookie, run JavaScript on every open site, and read files the browser can access. There's no authentication.

Chrome listens on `127.0.0.1` by default, not on your network. It also rejects WebSocket connections that come from web pages, which send an `Origin` header. Here's what happened when I tried to connect with `Origin: https://flaviocopes.com`:

```txt
HTTP/1.1 403 Forbidden

Rejected an incoming WebSocket connection from the https://flaviocopes.com origin. Use the command line flag --remote-allow-origins=https://flaviocopes.com to allow connections from this origin or --remote-allow-origins=* to allow all origins.
```

Node.js scripts don't send an `Origin` header, which is why our client connected without extra flags.

That still leaves every program running on your machine. So:

- never start Chrome with a debugging port on your everyday profile (Chrome 136 and later refuse anyway)
- don't add `--remote-allow-origins=*` unless you know which page needs to connect
- don't expose the port on a network. If you need remote access, use an SSH tunnel
- close the browser when the script is done

## Find the right command

The protocol is big, and the hardest part is finding the command you need. Here's where to look:

- **The protocol viewer** at [chromedevtools.github.io/devtools-protocol](https://chromedevtools.github.io/devtools-protocol/) documents every domain, command, event and type, with experimental and deprecated ones marked.
- **`/json/protocol`** on your own debugging port returns the definition for the exact Chrome you're running. That's where my 59 domains and 674 commands came from.
- **The `devtools-protocol` npm package** has TypeScript types for every command, generated from the same definition. Puppeteer depends on it, and it's 3.7 MB.
- **Puppeteer's protocol log.** When you know how to do something in Puppeteer, run it with `NODE_DEBUG='puppeteer:protocol:*'` and copy the commands it sends.
- **The Protocol Monitor in DevTools.** Enable it under Settings, Experiments, then open it from the More tools menu. It shows every CDP message DevTools sends while you click around, and lets you send your own.

When you already know how to do something in Puppeteer, start with its log. Puppeteer has worked out the right order of commands for most tasks, and the log shows them in plain JSON.
