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:
- Commands carry an
id. The reply carries the sameid, so a client can have many commands in flight and match each answer. - Events have no
id. The browser pushes them when something happens, for examplePage.loadEventFiredorNetwork.requestWillBeSent. - A
sessionIdpicks the tab. Withflatten: true, one WebSocket carries messages for every attached tab, and thesessionIdfield 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.1and 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.loadEventFiredor 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.
Related terms
- Puppeteer, the Node.js library built on CDP
- Headless Chrome and chrome-headless-shell
- Chrome flags, including
--remote-debugging-port - WebDriver BiDi
For the full DIY capture walkthrough, read taking screenshots with Puppeteer. To skip the protocol work, get a free API key.