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.
- Discovering and listing targets
- Creating and closing tabs programmatically
- The two connection models (one WebSocket per target vs multiplexed)
- Attaching and detaching from targets
- Auto-attach (watching for new targets)
- Browser contexts (isolated profiles)
- Handling target crashes
Recap: What Is a Target?
A target is anything debuggable in the browser:
page- a tab or windowiframe- an out-of-process iframeservice_worker- a service workershared_worker- a shared workerworker- a dedicated web workerbrowser- the browser process itselfother- background pages, extensions, etc.
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:
- You open a WebSocket to
ws://localhost:9222/devtools/page/{targetId} - The connection is dedicated to that single target
- No
sessionIdfield needed - every message implicitly targets the connected page - To talk to a second target, you open a second WebSocket connection
Advantages:
- Simple - no session management, no routing logic
- Easy to reason about - one connection, one target, one set of events
- Good for single-tab use cases (most journey runs)
Disadvantages:
- Doesn't scale - 5 tabs = 5 WebSocket connections to manage
- Can't do browser-level operations (creating tabs, browser contexts) - those require the browser-level endpoint
- Can't observe new targets appearing (e.g.,
window.open()popups) from this connection - Each connection has its own state -
nodeIds from one connection are meaningless on another
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 targetsHow it works:
- You open one WebSocket to the browser-level endpoint (
ws://localhost:9222/devtools/browser/...) - Commands without a
sessionIdgo to the browser itself (Target, Browser domains) - Commands with a
sessionIdare routed to the corresponding attached target - Events from targets also carry the
sessionIdso you know which target they came from
Advantages:
- One connection manages everything - browser operations + multiple targets
- Can create/close tabs, manage browser contexts, and auto-attach to new targets
- Can observe targets appearing and disappearing (popups, workers, iframes)
- More efficient - single TCP connection multiplexed across many targets
- This is what Puppeteer and Playwright use internally
Disadvantages:
- More complex - you must route messages by
sessionId - Your message handler needs to demultiplex: "is this for session A, session B, or the browser?"
- Harder to debug - all messages from all targets arrive on the same WebSocket
Which should the package use?
Model 2 (browser-level connection with sessions). Here's why:
- You need
Target.createBrowserContextfor test isolation - only available at the browser level - You need
Target.createTargetto open tabs programmatically - If a journey triggers a popup (
window.open), you can auto-attach to it - Single connection is simpler to manage in the package's lifecycle (one WebSocket to open/close)
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 crashedYou 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 immediatelywaitForDebuggerOnStart: 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.
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.