← Back to Index

Lesson 10

Target & Session Management

What You'll Learn

In Lesson 1 we introduced targets and sessions at a high level. Now we'll go deep into the Target domain - the plumbing that manages the relationship between your connection and the browser's debuggable entities.

  1. Discovering and listing targets
  2. Creating and closing tabs programmatically
  3. The two connection models (one WebSocket per target vs multiplexed)
  4. Attaching and detaching from targets
  5. Auto-attach (watching for new targets)
  6. Browser contexts (isolated profiles)
  7. Handling target crashes

Recap: What Is a Target?

A target is anything debuggable in the browser:

Two Connection Models

There are two fundamentally different ways to communicate with targets over CDP. Understanding both is important - they have different tradeoffs and suit different use cases.

Model 1: One WebSocket per target (direct connection)

Each target has its own WebSocket URL. You connect directly to that URL and commands go straight to that target:

// Discover targets via HTTP
const targets = await fetch('http://localhost:9222/json/list').then(r => r.json());
const page = targets.find(t => t.type === 'page');

// Connect directly to this target's WebSocket
const ws = new WebSocket(page.webSocketDebuggerUrl);
// ws://localhost:9222/devtools/page/ABC123

// All commands on this WebSocket go to this one target
// No sessionId needed - the connection IS the session
await send('Page.navigate', { url: 'https://example.com' });

How it works:

Advantages:

Disadvantages:

Model 2: Single connection, multiplexed sessions (browser-level connection)

Connect to the browser-level WebSocket endpoint, then create sessions to individual targets over that single connection:

// Get the browser-level WebSocket URL (different from per-target URLs)
const versionInfo = await fetch('http://localhost:9222/json/version').then(r => r.json());
const ws = new WebSocket(versionInfo.webSocketDebuggerUrl);
// ws://localhost:9222/devtools/browser/BROWSER_UUID

// This connection is NOT attached to any page - it's the browser itself
// You can run browser-level commands directly:
await send('Target.createTarget', { url: 'about:blank' });
await send('Target.createBrowserContext');

// To talk to a specific page, attach to it - creating a session:
const targets = await send('Target.getTargets');
const appTarget = targets.targetInfos.find(
  target => target.type === 'page' && target.url.includes('localhost:4200')
);

const session = await send('Target.attachToTarget', {
  targetId: appTarget.targetId,
  flatten: true
});
// session = { sessionId: 'SESSION_ABC123' }

// Now include sessionId in every command destined for that target:
ws.send(JSON.stringify({
  id: 1,
  method: 'Page.navigate',
  params: { url: 'https://example.com' },
  sessionId: 'SESSION_ABC123'
}));

// You can attach to multiple targets simultaneously:
const session2 = await send('Target.attachToTarget', {
  targetId: anotherTarget.targetId,
  flatten: true
});
// session2 = { sessionId: 'SESSION_DEF456' }

// Same WebSocket, different sessionId → commands go to different targets

How it works:

Advantages:

Disadvantages:

Which should the package use?

Model 2 (browser-level connection with sessions). Here's why:

For most journeys you'll only have one active session (one tab), so the multiplexing complexity is minimal - you just always pass the same sessionId. But the browser-level connection gives you the flexibility to do more when needed.

Comparison at a glance

Model 1 (per-target) Model 2 (browser-level)
Connects to /devtools/page/{id} /devtools/browser/{uuid}
Needs sessionId? No Yes (for target commands)
Can create tabs? No Yes
Can create browser contexts? No Yes
Multi-target? Separate connection each All on one connection
See new targets? No Yes (auto-attach / discover)
Complexity Low Medium

When to use each model

Scenario Use Why
Quick one-off script (screenshot, evaluate) Model 1 Simplest setup, no session management needed
Single-tab journey run Either (Model 2 preferred) Model 1 works, but Model 2 gives you browser context isolation
Journey that opens popups or new tabs Model 2 Need auto-attach to see new targets as they appear
Running multiple journeys in parallel Model 2 One connection, multiple browser contexts, no resource waste
Need clean state per test (no cookie/storage leakage) Model 2 Browser contexts provide isolation without restarting the browser
Attaching to a browser already running with --remote-debugging-port Model 1 Just connect to the tab you care about, no setup overhead
Building a test framework or long-running tool Model 2 Full control over lifecycle, targets, and isolation

Creating and Closing Targets

// Create a new tab
const newTarget = await send('Target.createTarget', {
  url: 'https://example.com',    // Initial URL (can be 'about:blank')
  newWindow: false,              // false = new tab, true = new window
  background: false              // false = focus the new tab
});
// newTarget = { targetId: 'NEW_TARGET_001' }

// Close a target (closes the tab)
await send('Target.closeTarget', {
  targetId: 'NEW_TARGET_001'
});

// Bring a tab to front (focus it)
await send('Target.activateTarget', {
  targetId: 'EXISTING_TARGET_ID'
});

Discovering Targets

Enable target discovery to be notified when targets are created or destroyed:

// Start receiving targetCreated/targetDestroyed events
await send('Target.setDiscoverTargets', {
  discover: true
});

// You'll receive these events:
// Target.targetCreated - a new tab, worker, or iframe appeared
// Target.targetDestroyed - a target was closed
// Target.targetInfoChanged - a target's URL or title changed
// Target.targetCrashed - a target's renderer process crashed

You can also query the current list at any time:

const targets = await send('Target.getTargets');
// targets.targetInfos = [
//   { targetId: '...', type: 'page', title: 'Example', url: 'https://example.com', attached: true },
//   { targetId: '...', type: 'service_worker', url: 'sw.js', attached: false },
//   ...
// ]

Auto-Attach

Instead of manually attaching to each new target, you can ask CDP to automatically attach to new targets as they appear:

// Automatically attach to new pages and workers
await send('Target.setAutoAttach', {
  autoAttach: true,
  waitForDebuggerOnStart: false,   // true = pause new targets until you resume them
  flatten: true                     // flat session mode
});

// Now when a new tab opens (e.g., window.open()), you'll receive:
// Target.attachedToTarget event with the sessionId
// - ready to send commands immediately
waitForDebuggerOnStart: When true, new targets pause before executing any JavaScript - giving you time to set up breakpoints, enable domains, etc. before the page runs. You must call Runtime.runIfWaitingForDebugger to let it continue. This is how Puppeteer ensures it can intercept network requests from the very first load.

Browser Contexts (Incognito-like Isolation)

A browser context is like an incognito window - isolated cookies, storage, and cache. Useful for running tests that don't interfere with each other:

// Create an isolated browser context
const context = await send('Target.createBrowserContext');
// context = { browserContextId: 'CONTEXT_001' }

// Create a tab in that context
const tab = await send('Target.createTarget', {
  url: 'https://example.com',
  browserContextId: 'CONTEXT_001'
});

// ... run a journey in this isolated context ...

// Dispose the context (closes all its tabs and clears all state)
await send('Target.disposeBrowserContext', {
  browserContextId: 'CONTEXT_001'
});

This is the CDP equivalent of Puppeteer's browser.createIncognitoBrowserContext(). Each journey run could get its own context for complete isolation.

For the package: Creating a fresh browser context per journey run gives you clean state (no leftover cookies, localStorage, or cached responses from previous runs) without restarting the browser. This is significantly faster than closing and relaunching Brave for each test.

Handling Target Crashes

Renderer processes can crash (out of memory, illegal access, etc.). CDP tells you when this happens:

// Target.targetCrashed event
{
  "method": "Target.targetCrashed",
  "params": {
    "targetId": "CRASHED_TARGET_001",
    "status": "crashed",     // or 'oom' (out of memory)
    "errorCode": 139
  }
}

A CDP tool should handle this gracefully - log the crash, clean up the session, and report it as a journey failure with diagnostic context ("target crashed during step X").

Detaching

// Detach from a target (session ends, but target stays alive)
await send('Target.detachFromTarget', {
  sessionId: 'SESSION_ABC123'
});

Detaching doesn't close the tab - it just disconnects your debugging session from it. The tab continues running. This is useful if you want to observe a tab temporarily and then let it go.

Practical Pattern: Connection Setup

Here's how a CDP tool would typically initialise:

import WebSocket from 'ws';

async function createCDPConnection(port = 9222) {
  // Connect to the browser-level endpoint
  const versionInfo = await fetch(`http://localhost:${port}/json/version`)
    .then(response => response.json());

  const ws = new WebSocket(versionInfo.webSocketDebuggerUrl);
  await new Promise(resolve => ws.on('open', resolve));

  // Set up command/response infrastructure (from Lesson 2)
  let nextId = 1;
  const pending = new Map();

  function send(method, params = {}, sessionId = undefined) {
    const id = nextId++;
    const message = { id, method, params };
    if (sessionId) message.sessionId = sessionId;

    const promise = new Promise((resolve) => {
      pending.set(id, resolve);
    });
    ws.send(JSON.stringify(message));
    return promise;
  }

  ws.on('message', (data) => {
    const msg = JSON.parse(data.toString());
    if (msg.id && pending.has(msg.id)) {
      const resolvePromise = pending.get(msg.id);
      resolvePromise(msg.result || msg.error);
      pending.delete(msg.id);
      return;
    }
    // Events go to the diagnostic collector, etc.
  });

  // Create an isolated context for this test run
  const context = await send('Target.createBrowserContext');

  // Create a tab in that context
  const target = await send('Target.createTarget', {
    url: 'about:blank',
    browserContextId: context.browserContextId
  });

  // Attach to the tab
  const session = await send('Target.attachToTarget', {
    targetId: target.targetId,
    flatten: true
  });

  // Enable core domains on this session
  const sessionId = session.sessionId;
  await send('Page.enable', {}, sessionId);
  await send('Runtime.enable', {}, sessionId);
  await send('Network.enable', {}, sessionId);
  await send('DOM.enable', {}, sessionId);

  return {
    send: (method, params = {}) => send(method, params, sessionId),
    close: async () => {
      await send('Target.disposeBrowserContext', {
        browserContextId: context.browserContextId
      });
      ws.close();
    }
  };
}

Key Commands Reference

Command Purpose
Target.getTargets List all current targets
Target.createTarget Open a new tab/window
Target.closeTarget Close a tab
Target.attachToTarget Create a session with a target (flat mode)
Target.detachFromTarget End a session (target stays alive)
Target.setDiscoverTargets Enable target created/destroyed notifications
Target.setAutoAttach Auto-attach to new targets as they appear
Target.createBrowserContext Create an isolated profile (incognito-like)
Target.disposeBrowserContext Destroy a browser context (closes its tabs, clears state)
Target.activateTarget Focus/bring a tab to front

For the full list, see the Target section of the complete reference.

Primary Source

Read the Target domain documentation. Focus on attachToTarget with flatten: true - this is the modern approach all tools use.