Chrome DevTools Protocol (CDP): What It Is and How It Works

The Chrome DevTools Protocol is the JSON-over-WebSocket API that Puppeteer and Playwright use to drive Chrome. How it works, with a tested raw client.

By Simon Chiu · Updated

The Chrome DevTools Protocol (CDP) is the JSON-over-WebSocket API that Chrome exposes for remote control: a client sends commands such as Page.navigate or Page.captureScreenshot, and the browser sends back results and events.

The DevTools panel you open with F12 uses this protocol to talk to the page. So do Puppeteer, Playwright (for Chromium) and most screenshot services. This entry is part of the screenshot glossary.

What it means in plain terms

Chrome has a control port. When you start it with --remote-debugging-port, it opens a WebSocket server. Anything that connects can tell the browser what to do: open a tab, go to a URL, change the viewport, emulate dark mode, print a PDF, take a screenshot. Every high-level library call is a thin layer over a few of these messages.

The protocol is split into domains, each one a group of related commands and events:

Domain What it controls Example command
Target Tabs, workers, sessions Target.createTarget
Page Navigation, screenshots, PDF Page.captureScreenshot, Page.printToPDF
Emulation Viewport, media features, timezone Emulation.setEmulatedMedia
Network Headers, cookies, request blocking Network.setExtraHTTPHeaders
Runtime Running JavaScript in the page Runtime.evaluate

How a CDP conversation works

sequenceDiagram
  participant C as Client (your code or a library)
  participant B as Chrome (browser endpoint)
  participant T as Tab (page target)
  C->>B: GET /json/version (HTTP) returns webSocketDebuggerUrl
  C->>B: {"id":1,"method":"Target.createTarget"}
  B-->>C: {"id":1,"result":{"targetId":"..."}}
  C->>B: {"id":2,"method":"Target.attachToTarget","params":{"flatten":true}}
  B-->>C: {"id":2,"result":{"sessionId":"S1"}}
  C->>T: {"id":3,"method":"Page.navigate","sessionId":"S1"}
  T-->>C: event Page.loadEventFired
  C->>T: {"id":4,"method":"Page.captureScreenshot","sessionId":"S1"}
  T-->>C: {"id":4,"result":{"data":"<base64 PNG>"}}

Three rules explain almost everything:

  1. Commands carry an id. The reply carries the same id, so a client can have many commands in flight and match each answer.
  2. Events have no id. The browser pushes them when something happens, for example Page.loadEventFired or Network.requestWillBeSent.
  3. A sessionId picks the tab. With flatten: true, one WebSocket carries messages for every attached tab, and the sessionId field routes each one.

To prove there is no magic in the libraries, here is a complete client with no dependencies. It uses the WebSocket class built into Node 22:

// cdp-raw.mjs: talk to Chrome with no library.
// Start Chrome first:  chrome --headless --remote-debugging-port=9222
const base = 'http://127.0.0.1:9222';
const { webSocketDebuggerUrl } = await (await fetch(`${base}/json/version`)).json();
const ws = new WebSocket(webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });

let nextId = 0;
const pending = new Map();
ws.onmessage = ({ data }) => {
  const msg = JSON.parse(data);
  if (msg.id && pending.has(msg.id)) {
    const { resolve, reject } = pending.get(msg.id);
    pending.delete(msg.id);
    msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result);
  }
};
// Every command is { id, method, params }, plus sessionId when it targets one tab.
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
  const id = ++nextId;
  pending.set(id, { resolve, reject });
  ws.send(JSON.stringify({ id, method, params, sessionId }));
});

try {
  const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
  const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
  await send('Page.enable', {}, sessionId);
  await send('Page.navigate', { url: 'https://example.com' }, sessionId);
  await new Promise((r) => setTimeout(r, 1000)); // simple wait; a real client listens for Page.loadEventFired
  const { data } = await send('Page.captureScreenshot', { format: 'png' }, sessionId);
  console.log('PNG bytes:', Buffer.from(data, 'base64').length);
  await send('Target.closeTarget', { targetId });
} finally {
  ws.close();
}

Against chrome-headless-shell 148, /json/version reported "Protocol-Version": "1.3" and the script printed PNG bytes: 61741.

Sending CDP commands from Puppeteer and Playwright

Both libraries let you drop below their API when they have no wrapper for a feature. Here we emulate dark mode and read the full document size, which is what a full-page capture needs.

Puppeteer:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  const cdp = await page.createCDPSession();
  await cdp.send('Emulation.setEmulatedMedia', {
    features: [{ name: 'prefers-color-scheme', value: 'dark' }],
  });
  await page.goto('https://example.com');
  const { cssContentSize } = await cdp.send('Page.getLayoutMetrics');
  console.log('document size:', Math.round(cssContentSize.width), 'x', Math.round(cssContentSize.height));
} finally {
  await browser.close();
}

Playwright (Chromium only):

import { chromium } from 'playwright';

const browser = await chromium.launch();
try {
  const context = await browser.newContext();
  const page = await context.newPage();
  const cdp = await context.newCDPSession(page);
  await cdp.send('Emulation.setEmulatedMedia', {
    features: [{ name: 'prefers-color-scheme', value: 'dark' }],
  });
  await page.goto('https://example.com');
  const { cssContentSize } = await cdp.send('Page.getLayoutMetrics');
  console.log('document size:', Math.round(cssContentSize.width), 'x', Math.round(cssContentSize.height));
} finally {
  await browser.close();
}

The Puppeteer run printed 800 x 745 and the Playwright run 1280 x 775. Same page, same command: the difference is only the default viewport (800×600 in Puppeteer, 1280×720 in Playwright). Always set the viewport yourself.

RenderScreenshot does not give you a CDP connection. You describe the result, and the API runs the browser work for you:

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "viewport": {"width": 1280, "height": 800}, "browser": {"dark_mode": true}, "capture": {"mode": "full_page"}}' \
  --output dark-full.png
What you would do over CDP RenderScreenshot parameter
Emulation.setDeviceMetricsOverride viewport.width, viewport.height, viewport.scale
Emulation.setEmulatedMedia browser.dark_mode, browser.media
Emulation.setTimezoneOverride browser.timezone
Network.setExtraHTTPHeaders network.headers
Page.setBypassCSP network.bypass_csp
Page.printToPDF output.format: "pdf" with the PDF options

The full list is in the parameter reference.

Tested on 2026-10-15 on macOS 27 (arm64) with Node 22.18.0, [email protected] (Chrome 148.0.7778.97) and [email protected] (Chromium 156.0.8078.4). The curl request was not run for this page.

Common mistakes

  • Leaving the debugging port open on a network interface. Anyone who reaches the port controls the browser, including its cookies and local files. Bind it to 127.0.0.1 and never expose it from a container.
  • Building on experimental domains. Commands marked experimental in the protocol viewer can change or disappear in a Chrome update. Pin the browser version you tested.
  • Fixed sleeps instead of events. The raw client above sleeps for one second to stay short. Real code waits for Page.loadEventFired or a network-idle signal; see wait strategies.
  • Assuming CDP works everywhere. Firefox and Safari do not speak it. For cross-browser automation, look at WebDriver BiDi.

For the full DIY capture walkthrough, read taking screenshots with Puppeteer. To skip the protocol work, get a free API key.

Frequently asked questions

Is the Chrome DevTools Protocol a standard?
No. It is Chrome's own protocol, versioned with the browser. Domains marked experimental can change between Chrome releases. WebDriver BiDi is the cross-browser standard that covers some of the same ground.
Do I need Puppeteer to use CDP?
No. Any language that can open a WebSocket and send JSON can speak CDP. Libraries save you the work of matching replies to requests, handling events and managing sessions per tab.
Does Playwright use CDP?
For Chromium, yes. Playwright also lets you open a raw CDP session with context.newCDPSession(page), but only in Chromium.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.