Lesson 2
Your First CDP Connection
What You'll Do
In this lesson you'll:
- Launch Brave with remote debugging enabled
- Discover available targets via the HTTP endpoint
- Connect to a page target over WebSocket
- Send your first CDP command and receive a response
- Subscribe to events and watch them stream in
No libraries beyond the ws WebSocket package. Just you and the raw protocol.
Prerequisites
- Node.js installed (you already have this)
- Brave Browser installed (or any Chromium-based browser)
- A terminal
Step 1: Launch Brave with Remote Debugging
Open a terminal and run:
/Applications/Brave\ Browser.app/Contents/MacOS/Brave\ Browser \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/cdp-lesson--user-data-dir? This tells Brave to use a fresh, disposable profile directory. Without it, Brave would try to use an existing profile - and since only one instance can use a profile at a time, it would either fail to launch or interfere with your normal browsing session. Using /tmp/cdp-lesson gives you an isolated, throwaway browser instance.
You should see Brave open with an empty tab. The browser is now listening for CDP connections on port 9222.
Step 2: Discover Targets
In a second terminal, query the HTTP discovery endpoint:
curl http://localhost:9222/json/listYou'll get back a JSON array like this:
[
{
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/ABC123",
"id": "ABC123",
"title": "New Tab",
"type": "page",
"url": "chrome://newtab/",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/ABC123"
}
]Key fields:
id- unique identifier for this targettype- "page", "service_worker", "browser", etc.webSocketDebuggerUrl- the WebSocket URL you'll connect to
There are other useful HTTP endpoints too:
/json/version- browser version info and the browser-level WebSocket URL/json/protocol- the full protocol schema as JSON (this is what we used to generate the complete reference)/json/new?url=https://example.com- create a new tab and navigate it/json/close/{id}- close a target by ID
Step 3: Set Up a Node.js Script
Create a working directory and install the ws package:
mkdir cdp-playground && cd cdp-playground
npm init -y
npm install wsCreate a file called first-connection.mjs:
.mjs? The .mjs extension tells Node.js to treat the file as an ES module, enabling import syntax and top-level await (using await outside an async function - the whole module is an implicit async context). Alternatively, you could use .js and add "type": "module" to your package.json - same effect.
import WebSocket from 'ws';
// Step 1: Discover targets
const response = await fetch('http://localhost:9222/json/list');
const targets = await response.json();
// Find the first page target
const page = targets.find(t => t.type === 'page');
if (!page) {
console.error('No page target found. Is Brave running with --remote-debugging-port?');
process.exit(1);
}
console.log(`Connecting to: ${page.title} (${page.url})`);
console.log(`WebSocket URL: ${page.webSocketDebuggerUrl}`);
// Step 2: Connect via WebSocket
const ws = new WebSocket(page.webSocketDebuggerUrl);
// Track command IDs
let nextId = 1;
function send(method, params = {}) {
const id = nextId++;
const message = JSON.stringify({ id, method, params });
console.log(`\n→ SEND [${id}]: ${method}`);
console.log(` ${JSON.stringify(params)}`);
ws.send(message);
return id;
}
ws.on('open', () => {
console.log('\n✓ Connected to browser!\n');
// Step 3: Send our first command - get browser version info
send('Browser.getVersion');
});
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.id) {
// This is a response to a command we sent
console.log(`\n← RESPONSE [${msg.id}]:`);
console.log(JSON.stringify(msg.result || msg.error, null, 2));
} else if (msg.method) {
// This is an event pushed by the browser
console.log(`\n← EVENT: ${msg.method}`);
console.log(JSON.stringify(msg.params, null, 2));
}
});
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
});
ws.on('close', () => {
console.log('\nConnection closed.');
});Run it:
node first-connection.mjsYou should see output like:
Connecting to: New Tab (chrome://newtab/)
WebSocket URL: ws://localhost:9222/devtools/page/ABC123
✓ Connected to browser!
→ SEND [1]: Browser.getVersion
{}
← RESPONSE [1]:
{
"protocolVersion": "1.3",
"product": "Chrome/125.0.6422.76",
"revision": "@some-hash",
"userAgent": "Mozilla/5.0 ...",
"jsVersion": "12.5.xxx"
}Step 4: Navigate a Page
Let's do something visible. Modify the ws.on('open') handler to navigate:
ws.on('open', () => {
console.log('\n✓ Connected to browser!\n');
// Enable Page events first
send('Page.enable');
// Navigate to a real page
send('Page.navigate', { url: 'https://example.com' });
});Run it again. You'll see Brave navigate to example.com, and the response will include a frameId and loaderId - identifiers you'll use in later lessons for tracking navigation state.
Step 5: Subscribe to Events
Here's where CDP becomes powerful for automation and debugging. Let's enable the Network domain and watch requests fly:
ws.on('open', () => {
console.log('\n✓ Connected to browser!\n');
// Enable domains we want events from
send('Page.enable');
send('Network.enable');
// Navigate - this will trigger network events
send('Page.navigate', { url: 'https://example.com' });
});Now when you run it, you'll see a stream of events:
← EVENT: Network.requestWillBeSent
{
"requestId": "req-1",
"request": {
"url": "https://example.com/",
"method": "GET"
},
...
}
← EVENT: Network.responseReceived
{
"requestId": "req-1",
"response": {
"status": 200,
"headers": { ... }
},
...
}
← EVENT: Page.loadEventFired
{
"timestamp": 12345.678
}Runtime and Network to catch console errors and failed requests, but leave DOM disabled until you actively need to inspect the tree.
Step 6: Evaluate JavaScript in the Page
One of the most powerful commands is Runtime.evaluate - it runs arbitrary JavaScript in the page context and returns the result. To use it after navigation, we need to wait for the page to actually load. Rather than guessing with a timeout, we listen for the Page.loadEventFired event - reacting to what the browser tells us happened:
ws.on('open', () => {
send('Page.enable');
send('Runtime.enable');
send('Page.navigate', { url: 'https://example.com' });
});
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
// The browser tells us the page has loaded - now it's safe to evaluate
if (msg.method === 'Page.loadEventFired') {
send('Runtime.evaluate', {
expression: 'document.title',
returnByValue: true
});
send('Runtime.evaluate', {
expression: 'document.querySelectorAll("p").length',
returnByValue: true
});
}
// Handle responses
if (msg.id) {
console.log(`\n← RESPONSE [${msg.id}]:`);
console.log(JSON.stringify(msg.result || msg.error, null, 2));
}
});This is event-driven - you react to what the browser tells you, not arbitrary timing. This pattern scales; timeouts don't.
The response comes back with the evaluated value:
← RESPONSE [5]:
{
"result": {
"type": "string",
"value": "Example Domain"
}
}
← RESPONSE [6]:
{
"result": {
"type": "number",
"value": 2
}
}returnByValue. Without this flag, CDP returns a remote object reference (an ID pointing to the object in the browser's memory) rather than the actual value. For simple values (strings, numbers, booleans), set returnByValue: true to get the data directly. For complex objects, you'll work with remote references - we'll cover that in a later lesson.
Step 7: Take a Screenshot
Let's close the loop with something visual - capturing what the browser sees:
import { writeFileSync } from 'fs';
import WebSocket from 'ws';
const response = await fetch('http://localhost:9222/json/list');
const targets = await response.json();
const page = targets.find(t => t.type === 'page');
const ws = new WebSocket(page.webSocketDebuggerUrl);
let nextId = 1;
const pending = new Map(); // Maps command IDs → their Promise resolve functions
function send(method, params = {}) {
const id = nextId++;
return new Promise((resolve) => {
// Smuggle `resolve` out of this Promise by storing it in the Map.
// The message handler below will retrieve it and call it when
// the browser responds with this command's ID.
pending.set(id, resolve);
ws.send(JSON.stringify({ id, method, params }));
});
}
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (!msg.id || !pending.has(msg.id)) return;
// Retrieve the resolve function we stored when we sent this command
const resolvePromise = pending.get(msg.id);
// Determine the response payload (result on success, error on failure)
const responseData = msg.result || msg.error;
// Resolve the promise - this unblocks the `await send(...)` call
resolvePromise(responseData);
// Clean up - we don't need this entry anymore
pending.delete(msg.id);
});
ws.on('open', async () => {
await send('Page.enable');
await send('Page.navigate', { url: 'https://example.com' });
// Wait for load
await new Promise(resolve => setTimeout(resolve, 2000));
// Capture screenshot
const result = await send('Page.captureScreenshot', { format: 'png' });
// result.data is base64-encoded PNG
const buffer = Buffer.from(result.data, 'base64');
writeFileSync('screenshot.png', buffer);
console.log('Screenshot saved to screenshot.png');
ws.close();
});Run it, and you'll find screenshot.png in your directory - a pixel-perfect capture of what the browser rendered.
The Promise-Based Pattern
The screenshot example introduced a pattern that can feel confusing because it involves several layers of indirection. Let's build up to it step by step.
The core idea: smuggling resolve out of a Promise
Normally, you resolve a promise from inside its constructor. But resolve is just a regular function that JavaScript hands you as an argument. You can assign it to a variable outside the Promise - "smuggling" it out - and call it later from anywhere:
let resolveFromOutside;
const myPromise = new Promise((resolve) => {
// THIS is the smuggling - we take `resolve` (which is local to this callback)
// and assign it to a variable that exists OUTSIDE the Promise constructor.
// Now code elsewhere can resolve this promise.
resolveFromOutside = resolve;
});
// Much later, from a completely different part of the code:
resolveFromOutside('hello!'); // ← calling this resolves myPromise
const result = await myPromise;
console.log(result); // 'hello!'That's the whole trick. The Promise doesn't care where or when resolve gets called - only that it eventually does.
Why CDP needs this
With WebSocket communication, you send a message in one place and receive the response in a completely different callback. There's no way to return the response directly - the send and receive happen at different times, in different parts of the code. You need something to reconnect them.
The solution: when you send a command, create a Promise and stash its resolve somewhere. When a response arrives later, find the matching resolve and call it. The Map (keyed by command ID) is what reconnects the two sides.
The full pattern, step by step
// A Map to hold resolve functions, keyed by command ID
const pending = new Map();
function send(method, params = {}) {
const id = nextId++;
const promise = new Promise((resolve) => {
// HERE is the smuggling - `resolve` is local to this callback,
// but we store it in the outer Map so that the message handler
// (a completely separate function) can call it later.
pending.set(id, resolve);
});
// Send the command over the wire
ws.send(JSON.stringify({ id, method, params }));
// Return the promise - the caller can `await` it
return promise;
}
// This handler fires every time the browser sends us ANY message
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
// Ignore messages that aren't responses to our commands
if (!msg.id || !pending.has(msg.id)) return;
// Retrieve the resolve function we stored when we sent this command
const resolvePromise = pending.get(msg.id);
// Determine the response data
const responseData = msg.result || msg.error;
// Call resolve - this unblocks the `await send(...)` elsewhere
resolvePromise(responseData);
// Clean up
pending.delete(msg.id);
});Now when you write await send('Page.navigate', { url: '...' }), here's what happens:
sendcreates a Promise, stores its resolve in the Map under ID 4 (say), sends the JSON, returns the Promiseawaitpauses your code - waiting for that Promise to resolve- Time passes… the browser processes the command…
- A WebSocket message arrives with
id: 4and aresult - The message handler finds ID 4 in the Map, pulls out the resolve function, calls it with the result
- The Promise resolves -
awaitunblocks - your code continues with the response data
This is the fundamental building block for any CDP client library. Puppeteer does exactly this internally. You've just written the core of a CDP client in ~25 lines.
Launching the Browser Programmatically
The lesson above assumes you've already launched Brave in a separate terminal. In practice, you'd automate this too - spawn the browser from your script using child_process:
import { spawn } from 'child_process';
const browserPath = '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser';
const browser = spawn(browserPath, [
'--remote-debugging-port=9222',
'--user-data-dir=/tmp/cdp-lesson',
'--no-first-run', // skip "Welcome to Brave" dialogs
'--no-default-browser-check'
], {
// Detach so the browser doesn't die when your script exits (optional)
detached: true,
stdio: 'ignore'
});
// Give it a moment to start the debugging server
await new Promise(r => setTimeout(r, 1500));
// Now connect as before...
const response = await fetch('http://localhost:9222/json/list');
// ...puppeteer.launch(), it spawns the browser binary with these flags, waits for port 9222 (or a random port) to become available, then connects via WebSocket. There's no magic - it's child_process.spawn + CDP.
For learning purposes, we'll keep the "launch manually" approach in lessons so you can observe the browser separately. But when you build an AI debugging agent, you'll want the fully programmatic version.
Troubleshooting
"Connection refused" on port 9222
- Make sure Brave is running with
--remote-debugging-port=9222 - Check you don't have another instance already using that port
- Try
curl http://localhost:9222/json/versionto verify the server is up
"No page target found"
- The browser might only have a
chrome://newtabtarget - that's fine, it's still a page - Try
curl http://localhost:9222/json/listmanually to see what's available
Events not arriving
- Did you call
Domain.enablefirst? Most domains are silent until explicitly enabled - Check you're connected to the right target (a page, not the browser endpoint)
Primary Source
Read the Page domain documentation - browse the commands (navigate, captureScreenshot, reload) and events (loadEventFired, domContentEventFired). You've now used several of these yourself.