What is CDP
The Chrome DevTools Protocol is a JSON-RPC-style protocol exposed over WebSocket by Chromium-based browsers. It's the same wire protocol that Chrome DevTools uses internally. When you open the Elements panel, set a breakpoint, or profile a page, the frontend is sending CDP messages to the browser backend.
Anything DevTools can do, you can do programmatically. That includes navigating pages, intercepting network requests, executing JavaScript in page context, capturing screenshots, recording performance traces, setting debugger breakpoints, and injecting input events. The protocol surface is large: over 40 domains, hundreds of commands, and hundreds of events.
CDP is a browser capability, not a headless-specific feature. The protocol works identically whether the browser window is visible on screen or running headless. You can launch Chrome normally, add --remote-debugging-port=9222, and control it programmatically while watching everything happen in the UI. That's actually one of the most useful debugging setups: your script drives the browser while you observe it visually in real time.
Higher-level libraries like Puppeteer and Playwright are built on top of CDP (Playwright also supports its own protocol for Firefox/WebKit). They're convenient, but they abstract away most of the protocol's power. Understanding the raw protocol gives you access to everything the browser exposes, including things no library wraps.
Launching Chromium with CDP
The only flag you need for CDP access is --remote-debugging-port. Everything else is optional. This works in both headed mode (normal browser window) and headless mode (no UI). The protocol, the WebSocket endpoint, the available commands, and the behavior are all identical either way. The choice between headed and headless is purely about whether you want a visible window.
Headed mode (visible browser window)
This is the most useful mode for development and debugging. You can watch your script navigate, click, and interact while inspecting state programmatically at the same time.
# macOS — launch Chrome with a visible window and CDP enabled
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222
# Linux
chromium-browser --remote-debugging-port=9222
# Isolate from your normal browsing profile
chromium --remote-debugging-port=9222 \
--user-data-dir=/tmp/chrome-debug \
--no-first-run
Once launched, the browser window works normally. You can interact with it manually while your script also sends CDP commands. You can even open DevTools in the browser window alongside your programmatic CDP session. Both use the same protocol; they coexist without conflict.
Headless mode (no visible window)
For automation on servers, CI pipelines, containers, or anywhere you don't have (or need) a display. Chrome and Chromium have shipped --headless since 2017. The "new headless" mode (Chrome 112+) runs the full browser engine, sharing the same code path as headed mode. The old headless was a separate, stripped-down implementation that's now deprecated.
# macOS (Chrome)
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--headless=new \
--remote-debugging-port=9222
# Linux
chromium-browser --headless=new --remote-debugging-port=9222
# Specify a user data dir to isolate sessions
chromium --headless=new \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/chrome-debug \
--no-first-run \
--disable-default-apps
--headless=new instead of --headless?
The plain --headless flag still invokes the old headless mode in some Chrome versions. Using --headless=new guarantees you get the modern implementation that supports extensions, DevTools protocol fully, and renders identically to headed mode.
Useful launch flags
| Flag | Purpose |
|---|---|
--remote-debugging-port=9222 | Open CDP WebSocket on this port |
--remote-debugging-address=0.0.0.0 | Bind to all interfaces (default: 127.0.0.1) |
--headless=new | New headless mode (Chrome 112+) |
--disable-gpu | Disable GPU compositing (useful in containers) |
--no-sandbox | Required when running as root (Docker) |
--disable-dev-shm-usage | Use /tmp instead of /dev/shm (Docker fix for small shared mem) |
--user-data-dir=PATH | Isolate profile data per session |
--window-size=1920,1080 | Set viewport dimensions |
--proxy-server=host:port | Route traffic through a proxy |
--ignore-certificate-errors | Accept self-signed certs (testing only) |
--disable-extensions | Skip loading extensions |
--enable-logging=stderr | Print Chrome's internal logs to stderr |
--v=1 | Verbose logging level (higher = more output) |
--remote-allow-origins=* | Allow any origin to connect to CDP (Chrome 111+) |
0.0.0.0 exposes full browser control to the network. Anyone who can reach port 9222 can read cookies, execute JS, and navigate to file:// URLs. In production or shared environments, always bind to 127.0.0.1 and use SSH tunnels for remote access.
Verifying the endpoint
Once Chrome is running with --remote-debugging-port=9222, it exposes a few HTTP endpoints:
# List open targets (pages, service workers, etc.)
curl -s http://localhost:9222/json/list | jq .
# Browser version info
curl -s http://localhost:9222/json/version | jq .
# Open a new tab
curl -s http://localhost:9222/json/new?http://example.com
# Close a tab by target ID
curl -s http://localhost:9222/json/close/TARGET_ID
The /json/version response includes a webSocketDebuggerUrl field. That's the WebSocket endpoint for browser-level CDP commands. Each target in /json/list has its own webSocketDebuggerUrl for page-level commands.
Your first CDP script
Raw WebSocket approach (Node.js)
No libraries. Just a WebSocket, JSON-RPC message IDs, and the CDP spec. This is the most educational way to understand what's happening.
import WebSocket from 'ws';
// Discover the WebSocket URL from the /json/version endpoint
const info = await fetch('http://localhost:9222/json/version')
.then(r => r.json());
const ws = new WebSocket(info.webSocketDebuggerUrl);
let msgId = 0;
const pending = new Map();
function send(method, params = {}) {
return new Promise((resolve, reject) => {
const id = ++msgId;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
}
ws.on('message', (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(msg.error) : resolve(msg.result);
} else if (msg.method) {
// This is an event (e.g., Network.requestWillBeSent)
console.log(`[event]`, msg.method, msg.params);
}
});
ws.on('open', async () => {
// Create a new target (tab)
const { targetId } = await send('Target.createTarget', {
url: 'about:blank'
});
// Attach to it to get a session for page-level commands
const { sessionId } = await send('Target.attachToTarget', {
targetId,
flatten: true
});
// Now send commands scoped to this session
const sendToPage = (method, params = {}) => {
return new Promise((resolve, reject) => {
const id = ++msgId;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params, sessionId }));
});
};
await sendToPage('Page.enable');
await sendToPage('Network.enable');
await sendToPage('Page.navigate', {
url: 'https://example.com'
});
// Wait for load, then screenshot
await new Promise(r => setTimeout(r, 2000));
const { data } = await sendToPage('Page.captureScreenshot', {
format: 'png'
});
const fs = await import('fs');
fs.writeFileSync('screenshot.png', Buffer.from(data, 'base64'));
console.log('Screenshot saved.');
ws.close();
});
The key concepts: every CDP message is JSON with an id (for request/response correlation), a method (domain.command), and params. Events arrive without an id and carry a method field. Sessions (via Target.attachToTarget with flatten: true) multiplex multiple targets over a single WebSocket.
Puppeteer
Puppeteer is the most common high-level wrapper. It manages browser launch, provides a page API, and handles CDP session management. You can still drop down to raw CDP when needed.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: 'new',
args: ['--remote-debugging-port=9222']
});
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle0' });
// High-level API
await page.screenshot({ path: 'screenshot.png', fullPage: true });
// Drop to raw CDP when you need something Puppeteer doesn't wrap
const cdp = await page.createCDPSession();
// Enable the Debugger domain
await cdp.send('Debugger.enable');
// Get a JS heap snapshot
await cdp.send('HeapProfiler.enable');
cdp.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }) => {
process.stdout.write(chunk);
});
await cdp.send('HeapProfiler.takeHeapSnapshot', {
reportProgress: false
});
await browser.close();
Playwright
Playwright has its own protocol for cross-browser support, but exposes CDP for Chromium targets.
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
// Get a CDP session from any Playwright page
const cdp = await page.context().newCDPSession(page);
await cdp.send('Performance.enable');
await page.goto('https://example.com');
const { metrics } = await cdp.send('Performance.getMetrics');
console.log(metrics);
await browser.close();
Python
The pychrome library gives you a clean CDP wrapper. For async workflows, nodriver (successor to undetected-chromedriver) or the cdp package work well.
import pychrome
import base64
browser = pychrome.Browser(url="http://127.0.0.1:9222")
tab = browser.new_tab()
# Start listening for events
tab.start()
tab.call_method("Page.enable")
tab.call_method("Network.enable")
# Navigate
tab.call_method("Page.navigate", url="https://example.com")
tab.wait(3)
# Screenshot
result = tab.call_method("Page.captureScreenshot", format="png")
with open("screenshot.png", "wb") as f:
f.write(base64.b64decode(result["data"]))
# Evaluate JS in page context
result = tab.call_method("Runtime.evaluate",
expression="document.title")
print(result["result"]["value"])
tab.stop()
browser.close_tab(tab)
Reference script: chromium-browse
A working implementation of everything above is available as chromium-browse, installable via Homebrew from the mac-security tap. It controls any Chromium-based browser purely through raw CDP over WebSocket. No Puppeteer, no Playwright, no third-party browser engine. Just the protocol.
What the script does
- Launches the browser in
--headless=newmode with a temporary user data directory (isolated profile, no leftover state) - Reads a list of URLs from
urls.txt, shuffles the order each round - For each URL: navigates via
Page.navigate, waits for load, scrolls the viewport randomly usingRuntime.evaluate - Dwells on each page for a randomized period (configurable min/max)
- Discovers same-domain links using
Runtime.evaluateto query the DOM for<a href>elements - Follows a random subset of those links (up to
--max-depthlevels deep), skipping assets like images, PDFs, fonts, and off-domain URLs - Cleans up the temp profile directory on exit
CDP commands used
The script uses a small but representative slice of the protocol. This is a useful reference for anyone building their own CDP automation from scratch. Every technique is transferable to Chrome, Edge, Brave, Island, or any Chromium fork. Swap the browser binary path and the rest works unchanged.
| Command | Purpose in the script |
|---|---|
Target.createTarget | Open a new tab for each browsing session |
Target.attachToTarget | Get a session ID to send page-scoped commands |
Page.enable | Enable page lifecycle events (needed to detect load completion) |
Page.navigate | Navigate to each URL from the list |
Runtime.evaluate | Execute JavaScript in page context: scroll the viewport, extract <a href> links from the DOM, read document.readyState |
Network.enable | Monitor network activity to confirm page loads complete |
Target.closeTarget | Clean up tabs after each visit |
How it connects
The script follows the same pattern shown in the raw WebSocket example above:
- Launch the browser with
--remote-debugging-port=PORTand a disposable--user-data-dir - Poll
http://localhost:PORT/json/versionuntil the browser is ready (the CDP endpoint takes a moment to come up) - Connect to the
webSocketDebuggerUrlfrom the version response - Send JSON-RPC messages with incrementing IDs, correlate responses by ID, and handle events by method name
- Use
Target.createTarget+Target.attachToTargetwithflatten: trueto scope commands to individual tabs
The Python implementation uses the websockets library for the WebSocket connection and aiohttp for the initial HTTP endpoint check. Everything else is stdlib. Total external dependencies: two packages.
Remote debugging
The debugging port isn't just for headless automation. It's a full remote debugging interface. You can attach Chrome DevTools from another machine, connect multiple clients, and debug browsers running in containers, VMs, or on mobile devices.
Over the network
Launch Chrome on the target machine binding to all interfaces:
chromium --headless=new \
--remote-debugging-port=9222 \
--remote-debugging-address=0.0.0.0 \
--remote-allow-origins=*
On your workstation, open chrome://inspect in Chrome, click "Configure" next to "Discover network targets," and add TARGET_IP:9222. The remote browser's tabs will appear in the list, and you can click "inspect" to get a full DevTools window connected to the remote target.
file:// URLs.
SSH tunnel (the right way)
Keep the debugging port bound to localhost and tunnel through SSH:
# On local workstation: forward local 9222 to remote's localhost:9222
ssh -L 9222:localhost:9222 user@remote-host
# Now access DevTools at chrome://inspect targeting localhost:9222
# Or connect programmatically
curl -s http://localhost:9222/json/version
This gives you encrypted, authenticated access without exposing the port.
Docker containers
# Dockerfile
FROM chromedp/headless-shell:latest
EXPOSE 9222
ENTRYPOINT ["/headless-shell/headless-shell", \
"--remote-debugging-address=0.0.0.0", \
"--remote-debugging-port=9222", \
"--no-sandbox", \
"--disable-dev-shm-usage"]
# Run it
docker run -d -p 9222:9222 --name chrome-headless chromedp/headless-shell
# Connect from host
curl -s http://localhost:9222/json/version
For Docker, chromedp/headless-shell is the minimal image (~100MB). If you need the full browser with font rendering and locales, use browserless/chrome or build your own from the Debian Chromium package.
Android device debugging
Chrome on Android supports USB debugging via ADB:
# Enable USB Debugging in Android Developer Settings first
# Forward the device's debugging port to your machine
adb forward tcp:9222 localabstract:chrome_devtools_remote
# Now localhost:9222 reaches the phone's Chrome
curl -s http://localhost:9222/json/list
# Or use chrome://inspect with USB discovery enabled
This works with the standard Chrome app on Android. Enable "USB web debugging" in Chrome's developer settings (chrome://flags), or launch Chrome with --remote-debugging-port via ADB shell.
Backtraces & stack capture
CDP's Debugger domain gives you deep access to the JavaScript call stack, pause/resume control, and exception handling. This is where the protocol really shines for debugging complex applications.
Pause on exceptions
// Pause on all exceptions (caught and uncaught)
await cdp.send('Debugger.enable');
await cdp.send('Debugger.setPauseOnExceptions', {
state: 'all' // 'none' | 'uncaught' | 'all'
});
// Listen for when execution pauses
cdp.on('Debugger.paused', async (params) => {
console.log('Paused. Reason:', params.reason);
console.log('Hit breakpoints:', params.hitBreakpoints);
// Walk the call frames
for (const frame of params.callFrames) {
console.log(` ${frame.functionName || '(anonymous)'}`
+ ` at ${frame.url}:${frame.location.lineNumber}`);
// Inspect scope variables at this frame
for (const scope of frame.scopeChain) {
if (scope.type === 'local') {
const { result } = await cdp.send(
'Runtime.getProperties', {
objectId: scope.object.objectId,
ownProperties: true
}
);
console.log(' Local vars:',
result.map(p => `${p.name}=${p.value?.description}`));
}
}
}
// Resume execution
await cdp.send('Debugger.resume');
});
Set breakpoints by URL pattern
// Break on a specific line
await cdp.send('Debugger.setBreakpointByUrl', {
lineNumber: 42,
urlRegex: '.*app\\.js$'
});
// Conditional breakpoint
await cdp.send('Debugger.setBreakpointByUrl', {
lineNumber: 100,
url: 'https://example.com/app.js',
condition: 'items.length > 50'
});
Async stack traces
// Enable async call stacks (critical for understanding
// promise chains, setTimeout callbacks, event handlers)
await cdp.send('Debugger.setAsyncCallStackDepth', {
maxDepth: 32
});
// Now when paused, callFrames will include the async parent chain
// params.asyncStackTrace links to the parent async context
cdp.on('Debugger.paused', (params) => {
let asyncTrace = params.asyncStackTrace;
while (asyncTrace) {
console.log(`Async: ${asyncTrace.description}`);
for (const frame of asyncTrace.callFrames) {
console.log(` ${frame.functionName} (${frame.url}:${frame.lineNumber})`);
}
asyncTrace = asyncTrace.parent;
}
});
Runtime stack traces (no pausing needed)
// Get a stack trace from any console message
await cdp.send('Runtime.enable');
cdp.on('Runtime.consoleAPICalled', (params) => {
console.log(`console.${params.type}:`,
params.args.map(a => a.value || a.description));
if (params.stackTrace) {
for (const frame of params.stackTrace.callFrames) {
console.log(` at ${frame.functionName} (${frame.url}:${frame.lineNumber})`);
}
}
});
// Capture stack traces for uncaught exceptions
cdp.on('Runtime.exceptionThrown', (params) => {
const ex = params.exceptionDetails;
console.log('Exception:', ex.text);
if (ex.stackTrace) {
ex.stackTrace.callFrames.forEach(f =>
console.log(` ${f.functionName} ${f.url}:${f.lineNumber}:${f.columnNumber}`)
);
}
});
Console.trace() capture
// Inject a console.trace() call to get a backtrace from
// anywhere in the app without setting breakpoints
await cdp.send('Runtime.evaluate', {
expression: `
const _origFetch = window.fetch;
window.fetch = function(...args) {
console.trace('fetch called with:', args[0]);
return _origFetch.apply(this, args);
};
`
});
// Every fetch() call now emits a full stack trace via
// Runtime.consoleAPICalled with type 'trace'
Profiling & performance
CPU profiling
await cdp.send('Profiler.enable');
await cdp.send('Profiler.start');
// ... do the thing you want to profile ...
const { profile } = await cdp.send('Profiler.stop');
// profile.nodes = array of call tree nodes with hitCount + children
// profile.startTime / endTime in microseconds
// profile.samples = array of node IDs sampled at each interval
// profile.timeDeltas = microsecond deltas between samples
// Save as .cpuprofile format (Chrome DevTools can open this)
fs.writeFileSync('profile.cpuprofile', JSON.stringify(profile));
Heap snapshots
await cdp.send('HeapProfiler.enable');
// Collect chunks as they stream
let snapshotData = '';
cdp.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }) => {
snapshotData += chunk;
});
await cdp.send('HeapProfiler.takeHeapSnapshot', {
reportProgress: true,
treatGlobalObjectsAsRoots: true,
captureNumericValue: true
});
// Save as .heapsnapshot (loadable in DevTools Memory panel)
fs.writeFileSync('heap.heapsnapshot', snapshotData);
Performance timeline traces
// Record a full performance trace (same data as DevTools Performance panel)
await cdp.send('Tracing.start', {
categories: '-*,devtools.timeline,v8.execute,disabled-by-default-devtools.timeline,disabled-by-default-devtools.timeline.frame,toplevel,blink.console,blink.user_timing,latencyInfo,disabled-by-default-devtools.timeline.stack,disabled-by-default-v8.cpu_profiler,disabled-by-default-v8.cpu_profiler.hires',
options: 'sampling-frequency=10000'
});
// ... interact with the page ...
const chunks = [];
cdp.on('Tracing.dataCollected', ({ value }) => chunks.push(...value));
await cdp.send('Tracing.end');
// Wait for tracingComplete event
await new Promise(resolve =>
cdp.on('Tracing.tracingComplete', resolve)
);
// Save as .json trace file (load in chrome://tracing or DevTools)
fs.writeFileSync('trace.json', JSON.stringify(chunks));
Performance metrics (instant readings)
await cdp.send('Performance.enable', {
timeDomain: 'timeTicks'
});
const { metrics } = await cdp.send('Performance.getMetrics');
// Returns an array of { name, value } objects:
// - Timestamp, Documents, Frames, JSEventListeners
// - Nodes, LayoutCount, RecalcStyleCount, LayoutDuration
// - RecalcStyleDuration, ScriptDuration, TaskDuration
// - JSHeapUsedSize, JSHeapTotalSize, FirstMeaningfulPaint
// - DomContentLoaded, NavigationStart
for (const m of metrics) {
console.log(`${m.name}: ${m.value}`);
}
DevTools hacks
Beyond the standard automation use cases, CDP opens up capabilities that are genuinely hard to get any other way.
Network interception and modification
// Intercept and modify requests in flight
await cdp.send('Fetch.enable', {
patterns: [
{ urlPattern: '*', requestStage: 'Request' },
{ urlPattern: '*', requestStage: 'Response' }
]
});
cdp.on('Fetch.requestPaused', async (params) => {
if (params.responseStatusCode) {
// Response stage: modify the response body
const { body, base64Encoded } = await cdp.send(
'Fetch.getResponseBody',
{ requestId: params.requestId }
);
let decoded = base64Encoded
? Buffer.from(body, 'base64').toString()
: body;
// Inject a script into every HTML response
if (params.responseHeaders
.some(h => h.name.toLowerCase() === 'content-type'
&& h.value.includes('text/html'))) {
decoded = decoded.replace(
'</head>',
'<script>console.log("injected")</script></head>'
);
}
await cdp.send('Fetch.fulfillRequest', {
requestId: params.requestId,
responseCode: params.responseStatusCode,
responseHeaders: params.responseHeaders,
body: Buffer.from(decoded).toString('base64')
});
} else {
// Request stage: add/modify headers
const headers = [...params.request.headers];
await cdp.send('Fetch.continueRequest', {
requestId: params.requestId,
headers: [
...Object.entries(params.request.headers)
.map(([name, value]) => ({ name, value })),
{ name: 'X-Custom-Header', value: 'injected' }
]
});
}
});
Coverage analysis
// Find unused CSS and JS on a page
await cdp.send('Profiler.enable');
await cdp.send('Profiler.startPreciseCoverage', {
callCount: true,
detailed: true
});
await cdp.send('CSS.enable');
await cdp.send('CSS.startRuleUsageTracking');
await page.goto('https://example.com');
await page.waitForTimeout(3000);
// JS coverage
const { result: jsCoverage } = await cdp.send(
'Profiler.takePreciseCoverage'
);
for (const script of jsCoverage) {
const total = script.functions
.reduce((sum, fn) => sum + fn.ranges
.reduce((s, r) => s + r.endOffset - r.startOffset, 0), 0);
const used = script.functions
.reduce((sum, fn) => sum + fn.ranges
.filter(r => r.count > 0)
.reduce((s, r) => s + r.endOffset - r.startOffset, 0), 0);
console.log(`${script.url}: ${((used/total)*100).toFixed(1)}% used`);
}
// CSS coverage
const { ruleUsage } = await cdp.send('CSS.stopRuleUsageTracking');
const unusedRules = ruleUsage.filter(r => !r.used);
console.log(`${unusedRules.length} unused CSS rules`);
DOM snapshotting for diffing
// Capture a complete DOM snapshot (useful for visual regression)
const snapshot = await cdp.send('DOMSnapshot.captureSnapshot', {
computedStyles: ['background-color', 'color', 'font-size',
'display', 'visibility', 'opacity']
});
// Returns documents[] with nodes, layout, text, styles
// Great for building custom accessibility auditors or
// structural page comparison tools
Geolocation, device, and sensor emulation
// Fake GPS location
await cdp.send('Emulation.setGeolocationOverride', {
latitude: 37.7749,
longitude: -122.4194,
accuracy: 100
});
// Emulate a device
await cdp.send('Emulation.setDeviceMetricsOverride', {
width: 375,
height: 812,
deviceScaleFactor: 3,
mobile: true
});
// Emulate network conditions
await cdp.send('Network.emulateNetworkConditions', {
offline: false,
latency: 150, // ms
downloadThroughput: 1.6 * 1024 * 1024 / 8, // 1.6 Mbps
uploadThroughput: 750 * 1024 / 8 // 750 Kbps
});
// Override timezone
await cdp.send('Emulation.setTimezoneOverride', {
timezoneId: 'Asia/Tokyo'
});
// Override locale
await cdp.send('Emulation.setLocaleOverride', {
locale: 'ja-JP'
});
// Simulate vision deficiency
await cdp.send('Emulation.setEmulatedVisionDeficiency', {
type: 'deuteranopia' // protanopia, tritanopia, achromatopsia, blurredVision
});
Synthetic input events
// Synthesize mouse clicks, keyboard input, touch events
// These go through the browser's actual input pipeline
// Click at coordinates
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed', x: 100, y: 200,
button: 'left', clickCount: 1
});
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased', x: 100, y: 200,
button: 'left', clickCount: 1
});
// Type text (uses Input.dispatchKeyEvent under the hood)
for (const char of 'hello world') {
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyDown', text: char
});
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp'
});
}
// Touch events for mobile emulation
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: 150, y: 300 }]
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: []
});
Cookie and storage manipulation
// Read all cookies
const { cookies } = await cdp.send('Network.getAllCookies');
// Set a cookie
await cdp.send('Network.setCookie', {
name: 'session',
value: 'abc123',
domain: 'example.com',
path: '/',
httpOnly: true,
secure: true
});
// Clear all cookies
await cdp.send('Network.clearBrowserCookies');
// Clear localStorage/sessionStorage/indexedDB/caches
await cdp.send('Storage.clearDataForOrigin', {
origin: 'https://example.com',
storageTypes: 'all'
});
PDF generation
const { data } = await cdp.send('Page.printToPDF', {
landscape: false,
displayHeaderFooter: true,
headerTemplate: '<div style="font-size:8px;text-align:center;width:100%">Confidential</div>',
footerTemplate: '<div style="font-size:8px;text-align:center;width:100%">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
printBackground: true,
preferCSSPageSize: true
});
fs.writeFileSync('page.pdf', Buffer.from(data, 'base64'));
Accessibility tree inspection
// Get the full accessibility tree
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
// Each node has: role, name, value, description, children
// Build custom a11y auditors that run in CI
for (const node of nodes) {
if (node.role?.value === 'img' && !node.name?.value) {
console.log('Image missing alt text:', node.backendDOMNodeId);
}
}
Intercept file downloads
// Control where downloads go (headless mode)
await cdp.send('Browser.setDownloadBehavior', {
behavior: 'allowAndName',
downloadPath: '/tmp/downloads',
eventsEnabled: true
});
cdp.on('Browser.downloadProgress', (params) => {
console.log(`Download ${params.guid}: ${params.state}`);
if (params.state === 'completed') {
console.log('Saved to:', params.guid);
}
});
Screencast (stream the viewport)
// Stream frames as base64 images
await cdp.send('Page.startScreencast', {
format: 'jpeg',
quality: 60,
maxWidth: 1280,
maxHeight: 720,
everyNthFrame: 2
});
cdp.on('Page.screencastFrame', async (params) => {
// params.data = base64 jpeg
// params.metadata = { offsetTop, pageScaleFactor, ... }
// params.sessionId = acknowledge to receive next frame
// Process the frame (save, stream to websocket, etc.)
processFrame(params.data);
// Acknowledge to get the next frame
await cdp.send('Page.screencastFrameAck', {
sessionId: params.sessionId
});
});
Script injection and monkey-patching
// Run a script on every new document (before any page JS executes)
await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
source: `
// Override navigator properties (stealth)
Object.defineProperty(navigator, 'webdriver', { get: () => false });
// Instrument all XHR calls
const _open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
console.log('[XHR]', method, url);
return _open.apply(this, arguments);
};
// Hook into postMessage
const _postMessage = window.postMessage;
window.postMessage = function(msg, origin) {
console.log('[postMessage]', msg, origin);
return _postMessage.apply(this, arguments);
};
// Performance observer for long tasks
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn('[LongTask]', entry.duration.toFixed(1) + 'ms', entry.name);
}
}
}).observe({ type: 'longtask', buffered: true });
`
});
CDP command reference
The full protocol has 40+ domains and hundreds of commands. This reference covers the ones you'll actually use. Each entry shows the method signature, key parameters, and what it's useful for.
Convention: Domain.method for commands, Domain.eventName for events.
Page domain
Controls page navigation, lifecycle, screenshots, and printing.
Page.enable
Enables Page domain events. Call this before listening for any Page events. Most domains require an explicit enable call before they emit events.
Page.navigate
Navigate to a URL. Returns frameId, loaderId, and optionally errorText if navigation failed at the network level.
url(string) Target URLreferrer(string) Referrer URL to sendtransitionType(string)link,typed,address_bar,auto_bookmark, etc.
Page.reload
Reload the current page. ignoreCache bypasses the disk cache (like Ctrl+Shift+R). scriptToEvaluateOnLoad injects JS that runs before any page scripts.
Page.captureScreenshot
Returns a base64-encoded image of the viewport or a clipped region.
format"jpeg"|"png"|"webp"quality(int, 0-100) Compression quality for jpeg/webpclip{ x, y, width, height, scale }Capture a specific regioncaptureBeyondViewport(bool) Capture the full page, not just the visible viewport
Page.printToPDF
Generate a PDF of the page. Returns base64-encoded PDF data. The header/footer templates support CSS and the special classes date, title, url, pageNumber, totalPages.
Page.addScriptToEvaluateOnNewDocument
Register a script to run on every new document before the page's own scripts. Returns an identifier you can use to remove it later. This is the backbone of stealth techniques, instrumentation, and polyfill injection.
Page.startScreencast / Page.stopScreencast
Stream viewport frames as events. Each Page.screencastFrame event must be acknowledged with Page.screencastFrameAck before the next frame is sent.
Page events
| Event | When it fires |
|---|---|
Page.loadEventFired | Window load event |
Page.domContentEventFired | DOMContentLoaded event |
Page.frameNavigated | After a frame navigates to a new URL |
Page.frameStartedLoading | Frame begins navigation |
Page.frameStoppedLoading | Frame finishes loading |
Page.lifecycleEvent | Lifecycle events: init, firstPaint, firstContentfulPaint, firstMeaningfulPaint, DOMContentLoaded, load, networkAlmostIdle, networkIdle, firstMeaningfulPaintCandidate |
Page.javascriptDialogOpening | alert(), confirm(), prompt(), or beforeunload dialog |
Runtime domain
Execute JavaScript, inspect objects, and subscribe to console output.
Runtime.evaluate
The workhorse command. Evaluates a JS expression in the page context and returns the result.
expression(string) The JS to evaluateawaitPromise(bool) If the expression returns a promise, wait for it to resolve before returningreturnByValue(bool) Return the result serialized as JSON instead of a remote object referenceuserGesture(bool) Whether to treat evaluation as user-initiated (needed for things that require user activation)contextId(int) Execution context to run in (different contexts for different frames/worlds)
Runtime.callFunctionOn
Call a function with a specific this value (via objectId). More structured than evaluate when working with specific DOM nodes or JS objects.
Runtime.getProperties
Inspect an object's properties by its remote object ID. Returns result (array of property descriptors) and internalProperties (like [[Prototype]], [[Scopes]]).
Runtime events
| Event | When it fires |
|---|---|
Runtime.consoleAPICalled | Any console.* call. Includes type (log, warn, error, trace, etc.), args, and stackTrace. |
Runtime.exceptionThrown | Uncaught exception. Includes exceptionDetails with message, stackTrace, lineNumber, columnNumber, scriptId. |
Runtime.executionContextCreated | New execution context (frame loaded, worker started). Includes context id, origin, name. |
Runtime.executionContextDestroyed | Context destroyed (frame navigated away, worker terminated). |
Network domain
Monitor and modify HTTP traffic.
Network.enable
Start receiving Network events. Optionally control buffer sizes for response body capture.
Network.getResponseBody
Get the response body for a completed request. Returns body (string) and base64Encoded (bool). Must be called before the page navigates away, or the data is lost.
Network.setExtraHTTPHeaders
Add extra headers to every outgoing request. headers is an object of name/value pairs.
Network.emulateNetworkConditions
Simulate network conditions. latency in ms, throughput in bytes/sec. connectionType can be none, cellular2g, cellular3g, cellular4g, bluetooth, ethernet, wifi, wimax, other.
Network.setCacheDisabled
Toggle the disk cache. Useful for testing without cached resources.
Network.setCookie / Network.getAllCookies / Network.clearBrowserCookies
Full cookie management. setCookie accepts name, value, domain, path, secure, httpOnly, sameSite, expires, priority, sameParty.
Network events
| Event | What it contains |
|---|---|
Network.requestWillBeSent | Request URL, method, headers, postData, initiator (with stack trace of what triggered it), redirectResponse |
Network.responseReceived | Response status, headers, mimeType, timing info, securityDetails (TLS cert info) |
Network.loadingFinished | Request completed. encodedDataLength gives actual bytes transferred. |
Network.loadingFailed | Request failed. Includes errorText, canceled (bool), blockedReason. |
Network.webSocketCreated | WebSocket opened. URL and initiator. |
Network.webSocketFrameReceived | WebSocket message received. Includes payload data. |
Network.webSocketFrameSent | WebSocket message sent. |
DOM domain
Query and manipulate the DOM tree.
DOM.getDocument
Returns the root DOM node. depth controls how many levels to return (-1 for entire subtree). pierce traverses into shadow DOMs and iframes.
DOM.querySelector / DOM.querySelectorAll
Run a CSS selector on a node. Returns the matching nodeId(s). Use after DOM.getDocument to get the root nodeId.
DOM.getOuterHTML / DOM.setOuterHTML
Read or replace the outer HTML of a node. Useful for extracting markup or live-editing the DOM.
DOM.getBoxModel
Returns the CSS box model (content, padding, border, margin quads in viewport coordinates). Useful for calculating click coordinates or building custom highlighting overlays.
DOM.resolveNode
Bridge between DOM and Runtime domains. Converts a DOM nodeId to a Runtime.RemoteObject, so you can call Runtime.callFunctionOn against it.
Debugger domain
Breakpoints, stepping, call stacks, and source maps.
Debugger.enable
Enable the Debugger domain. Returns debuggerId. Required before setting breakpoints or receiving pause events.
Debugger.setBreakpointByUrl
Set a breakpoint that persists across navigations. urlRegex lets you match patterns (e.g., .*bundle\\.js$). condition is a JS expression that must evaluate to true for the breakpoint to hit.
Debugger.setBreakpoint
Set a breakpoint in a specific script by scriptId. More precise than URL-based, but breaks if the script is reloaded.
Debugger.setPauseOnExceptions
state: "none", "uncaught", or "all". "all" pauses on caught exceptions too, which is great for finding swallowed errors.
Debugger.setAsyncCallStackDepth
Controls how many async stack frames are captured. Set to 0 to disable, or 32+ for deep promise chains. Each paused event's asyncStackTrace field will link through the chain.
Stepping commands
| Command | Behavior |
|---|---|
Debugger.resume | Continue execution |
Debugger.stepOver | Step to next line (skip into function calls) |
Debugger.stepInto | Step into the next function call |
Debugger.stepOut | Step out of the current function |
Debugger.pause | Pause execution immediately |
Debugger events
| Event | Contents |
|---|---|
Debugger.paused | callFrames (full stack with scope chains), reason (breakpoint, exception, debugCommand, XHR, DOM, etc.), data, hitBreakpoints, asyncStackTrace, asyncStackTraceId |
Debugger.resumed | Execution resumed |
Debugger.scriptParsed | Script loaded. scriptId, url, startLine, endLine, sourceMapURL, hasSourceURL, isModule |
Debugger.scriptFailedToParse | Script parse error. Same fields as scriptParsed plus error info. |
Profiler domain
CPU profiling and code coverage.
Profiler.start / Profiler.stop
start begins CPU sampling. stop returns a profile object with nodes (call tree), samples (sampled node IDs), timeDeltas (microsecond intervals), and startTime/endTime. Save as .cpuprofile to open in DevTools.
Profiler.startPreciseCoverage / Profiler.takePreciseCoverage
Tracks which JS functions and byte ranges actually execute. detailed: true gives block-level coverage (not just function-level). takePreciseCoverage returns the coverage data without stopping collection.
Performance domain
Performance.getMetrics
Instant snapshot of browser performance counters. Returns an array of { name, value }:
Timestamp,Documents,Frames,JSEventListeners,NodesLayoutCount,RecalcStyleCount,LayoutDuration,RecalcStyleDurationScriptDuration,TaskDurationJSHeapUsedSize,JSHeapTotalSizeFirstMeaningfulPaint,DomContentLoaded,NavigationStart
Tracing.start / Tracing.end
Record a full performance trace. The categories string controls which trace categories are active. Data arrives via Tracing.dataCollected events. Save the collected data as JSON and load it in chrome://tracing or the DevTools Performance panel.
Key trace categories:
devtools.timelineMain thread timeline eventsv8.executeV8 script executiondisabled-by-default-devtools.timeline.stackStack traces in timelinedisabled-by-default-v8.cpu_profilerV8 CPU profiler samples in traceblink.user_timingperformance.mark() / performance.measure()toplevelTask scheduling
Target domain
Manage browser targets: tabs, workers, service workers, extensions.
Target.createTarget
Open a new tab. browserContextId creates it in an incognito context (via Target.createBrowserContext). Returns targetId.
Target.attachToTarget
Attach to a target to send it commands. flatten: true multiplexes the session over the existing WebSocket. Returns sessionId.
Target.createBrowserContext
Create an incognito-like browser context with its own cookie jar and cache. You can assign a per-context proxy. Tabs created with this browserContextId are isolated.
Target.getTargets
List all targets. Each target has: targetId, type (page, background_page, service_worker, worker, iframe, browser, other), title, url, attached, browserContextId.
Input domain
Synthesize keyboard, mouse, and touch events.
Input.dispatchKeyEvent
type: "keyDown", "keyUp", "rawKeyDown", "char". For typing text, use keyDown with text set to the character, then keyUp. For special keys, set key to the key name (e.g., "Enter", "Tab", "ArrowDown") and code to the physical key (e.g., "Enter", "Tab").
Input.dispatchMouseEvent
type: "mousePressed", "mouseReleased", "mouseMoved", "mouseWheel". Coordinates are in CSS pixels relative to the viewport. deltaX/deltaY for scroll events.
Input.dispatchTouchEvent
type: "touchStart", "touchEnd", "touchMove", "touchCancel". touchPoints is an array of { x, y, radiusX?, radiusY?, rotationAngle?, force?, tangentialPressure?, tiltX?, tiltY?, twist?, id? }. Multi-touch: include multiple points.
Emulation domain
Override device characteristics, location, timezone, vision, media, and more.
Emulation.setDeviceMetricsOverride
Override viewport dimensions and device pixel ratio. Set mobile: true to enable mobile viewport behavior (meta viewport, touch events, scrollbar overlay).
Emulation.setGeolocationOverride
Fake the device's GPS coordinates. Call with no params to clear the override.
Emulation.setUserAgentOverride
Override the User-Agent string, accept-language, and navigator.platform. userAgentMetadata handles Client Hints (brands, fullVersion, platform, mobile, architecture, model).
Other Emulation commands
| Command | What it does |
|---|---|
setTimezoneOverride | Override Intl.DateTimeFormat timezone |
setLocaleOverride | Override navigator.language |
setEmulatedMedia | Override CSS media type (screen/print) and features (prefers-color-scheme, prefers-reduced-motion, etc.) |
setEmulatedVisionDeficiency | Simulate color blindness: protanopia, deuteranopia, tritanopia, achromatopsia, blurredVision |
setCPUThrottlingRate | Throttle CPU by a factor (e.g., 4 = 4x slowdown) |
setTouchEmulationEnabled | Enable touch event emulation |
setScrollbarsHidden | Hide scrollbars (for clean screenshots) |
setDocumentCookieDisabled | Block document.cookie access |
Security domain
Security.enable
Enables the Security domain. Once enabled, you receive Security.securityStateChanged events with detailed cert/TLS information for the current page.
Security.setIgnoreCertificateErrors
Accept all certificates regardless of validity. Equivalent to --ignore-certificate-errors but controllable at runtime. Only for testing environments.
Log domain
Log.enable
Start receiving Log.entryAdded events. These are browser-level log entries, not console.log() calls (which come from Runtime). Includes network errors, security warnings, deprecation notices, and intervention reports.
Each entry has: source (xml, javascript, network, storage, appcache, rendering, security, deprecation, worker, violation, intervention, recommendation, other), level (verbose, info, warning, error), text, url, lineNumber, stackTrace, networkRequestId.
Fetch domain
Request interception with more control than Network.setRequestInterception.
Fetch.enable
patterns is an array of { urlPattern, resourceType?, requestStage }. requestStage is "Request" (before the request is sent) or "Response" (after response headers arrive). If handleAuthRequests is true, you'll get Fetch.authRequired events for HTTP auth challenges.
Fetch.continueRequest
Continue a paused request, optionally modifying URL, method, body, or headers. This is how you redirect, rewrite, or augment requests in flight.
Fetch.fulfillRequest
Provide a complete response to a paused request. body is base64-encoded. This lets you serve mock responses, modify response bodies, or build local overrides for any URL.
Fetch.failRequest
Fail a paused request with a network error. reason: Failed, Aborted, TimedOut, AccessDenied, ConnectionClosed, ConnectionReset, ConnectionRefused, ConnectionAborted, ConnectionFailed, NameNotResolved, InternetDisconnected, AddressUnreachable, BlockedByClient, BlockedByResponse.
Fetch.getResponseBody
Get the response body during response-stage interception. Returns body and base64Encoded. Only works with requestStage: "Response" interception.
Other domains worth knowing
HeapProfiler
Heap snapshots, allocation tracking, and object sampling.
takeHeapSnapshotFull V8 heap snapshot (stream via addHeapSnapshotChunk events)startSampling/stopSamplingAllocation sampling profiler (lighter than full snapshot)startTrackingHeapObjects/stopTrackingHeapObjectsTrack every allocation (heavy, use for leak detection)getObjectByHeapObjectIdResolve a heap object to a Runtime.RemoteObject
Accessibility
getFullAXTreeComplete accessibility tree for the pagegetPartialAXTreeSubtree for a specific nodequeryAXTreeSearch the accessibility tree by role or name
CSS
getMatchedStylesForNodeAll CSS rules matching a node (inherited, matched, pseudo-element)getComputedStyleForNodeFinal computed values for all CSS propertiessetStyleTextsEdit CSS rules livestartRuleUsageTracking/stopRuleUsageTrackingCSS coverageforcePseudoStateForce :hover, :active, :focus, :focus-within, :focus-visible on a node
DOMSnapshot
captureSnapshotFlat arrays of the entire DOM tree with layout, style, and text data. More efficient than walking DOM.getDocument for large pages.
Browser
getVersionBrowser name, version, user agent, V8 versiongetWindowBounds/setWindowBoundsPosition and resize the browser windowsetDownloadBehaviorControl download paths and get download progress eventsgrantPermissionsGrant permissions (geolocation, midi, notifications, camera, microphone, etc.)crashIntentionally crash the browser (testing crash recovery)
Storage
clearDataForOriginWipe specific storage types: cookies, file_systems, indexeddb, local_storage, shader_cache, websql, service_workers, cache_storage, interest_groups, shared_storage, storage_buckets, allgetUsageAndQuotaStorage usage breakdown by type for an origingetCookies/setCookies/clearCookiesCookie operations scoped by browserContextIdtrackCacheStorageForOriginMonitor Cache API operations
ServiceWorker
enableStart receiving service worker lifecycle eventsunregisterForce-unregister a service worker by scope URLskipWaitingForce the waiting worker to activateinspectWorkerAttach DevTools to a specific worker
SystemInfo
getInfoReturns GPU info (driver, vendor), OS name/version, command line argsgetProcessInfoMemory/CPU usage for each Chrome process (browser, renderer, GPU, etc.)getFeatureStateCheck if a Chrome feature is enabled
Further reading
- Chrome DevTools Protocol Viewer (official, auto-generated from Chromium source)
- Puppeteer documentation
- Playwright documentation
- Awesome Chrome DevTools (curated tools and libraries)
chrome://flagsfor experimental features,chrome://inspectfor device discovery,chrome://tracingfor loading trace files