← Back to Index

Lesson 2

Your First CDP Connection

What You'll Do

In this lesson you'll:

  1. Launch Brave with remote debugging enabled
  2. Discover available targets via the HTTP endpoint
  3. Connect to a page target over WebSocket
  4. Send your first CDP command and receive a response
  5. Subscribe to events and watch them stream in

No libraries beyond the ws WebSocket package. Just you and the raw protocol.

Prerequisites

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
Why --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/list

You'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:

There are other useful HTTP endpoints too:

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 ws

Create a file called first-connection.mjs:

Why .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.mjs

You 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
}
Pattern: Enable a domain → receive its events. Disable it (or disconnect) → silence. This is how you control the firehose. In an AI debugging agent, you'd enable 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
  }
}
Key detail: 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.

When would an AI agent use screenshots vs DOM data? For most debugging tasks, structured text (DOM tree, console output, network responses) is more useful to an AI - it's cheaper to process, directly searchable, and precise. Screenshots are the complement for visual bugs: layout issues, overlapping elements, CSS regressions - things where the DOM is technically correct but the appearance isn't. Think of CDP as giving your agent both senses: the DOM for reading the page's structure, and screenshots for seeing what the user actually sees.

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:

  1. send creates a Promise, stores its resolve in the Map under ID 4 (say), sends the JSON, returns the Promise
  2. await pauses your code - waiting for that Promise to resolve
  3. Time passes… the browser processes the command…
  4. A WebSocket message arrives with id: 4 and a result
  5. The message handler finds ID 4 in the Map, pulls out the resolve function, calls it with the result
  6. The Promise resolves - await unblocks - 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');
// ...
This is what Puppeteer does internally. When you call 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

"No page target found"

Events not arriving

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.