Multi-Tab Management¶
cdpwave uses flatten sessions — a single WebSocket connection that multiplexes multiple targets (tabs, workers, iframes) via session IDs. This means you can control many tabs simultaneously without opening multiple WebSocket connections.
How flatten sessions work¶
┌─────────────────────────────────────┐
│ CDPClient │
│ ┌─────────────────────────────┐ │
│ │ Single WebSocket │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐│ │
│ │ │Tab 1 │ │Tab 2 │ │Worker││ │
│ │ │sess A│ │sess B│ │sess C││ │
│ │ └──────┘ └──────┘ └──────┘│ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
Each CDPSession has a unique sessionId. When you send a command,
cdpwave attaches the session ID to the message. When the browser sends
an event, cdpwave routes it to the correct session based on the
sessionId field.
Benefits of flatten sessions:
- Single connection — one WebSocket handles all targets.
- Lightweight — creating a new session is a cheap round-trip.
- Isolated events — each session has its own event handlers.
- Concurrent commands — commands to different sessions can be sent concurrently.
Create a new page¶
session1 = await client.new_page("https://example.com")
session2 = await client.new_page("https://www.python.org")
new_page() creates a new browser tab, attaches a session to it, and
returns a CDPSession. You can optionally pass a URL to navigate
immediately.
Each CDPSession has its own domain properties (page, runtime,
network, etc.) and event dispatcher. Commands and events are
isolated per session.
List existing pages¶
pages = await client.get_pages()
for target in pages:
print(f"{target.target_id} | {target.title} | {target.url}")
get_pages() returns all page-type targets in the browser. Each target
has:
target_id— unique identifier for the target.title— page title.url— current URL.type— target type ("page","background_page","service_worker","shared_worker","browser","other").
Connect to an existing page¶
If a tab already exists (e.g., the browser was launched with a start URL), you can attach a session to it:
pages = await client.get_pages()
if pages:
session = await client.connect_to_page(pages[0].target_id)
result = await session.runtime.evaluate("document.title", return_by_value=True)
print(result["result"]["value"])
await session.close()
Session isolation¶
Each CDPSession is completely independent:
- Separate domain state — enabling
Pageon one session doesn't affect another. - Separate event handlers — events from one session don't trigger handlers on another.
- Separate command IDs — commands are correlated per session.
tab1 = await client.new_page("https://example.com")
tab2 = await client.new_page("https://example.org")
# Each has its own event handlers
async def on_load1(_: dict) -> None:
print("Tab 1 loaded")
async def on_load2(_: dict) -> None:
print("Tab 2 loaded")
tab1.on("Page.loadEventFired", on_load1)
tab2.on("Page.loadEventFired", on_load2)
await tab1.page.enable()
await tab2.page.enable()
Concurrent tabs¶
Run operations on multiple tabs simultaneously using asyncio.gather:
import asyncio
from cdpwave import CDPClient, CDPSession
async def fetch_title(client: CDPClient, url: str) -> str:
session = await client.new_page(url)
result = await session.runtime.evaluate("document.title", return_by_value=True)
title = result["result"]["value"]
await session.close()
return title
async def main() -> None:
urls = ["https://example.com", "https://www.python.org"]
async with await CDPClient.launch(headless=True) as client:
tasks = [fetch_title(client, url) for url in urls]
titles = await asyncio.gather(*tasks)
for url, title in zip(urls, titles, strict=True):
print(f"{url} -> {title}")
asyncio.run(main())
Concurrency best practices¶
- Limit concurrent tabs — each tab consumes memory. For large batches, use a semaphore to limit concurrent tabs (e.g., 5-10).
- Use timeouts per tab — a slow page shouldn't block the entire
batch. Wrap each tab's operations in
asyncio.wait_for. - Close sessions when done — unclosed sessions leak resources.
Use
session.close()or the context manager.
sem = asyncio.Semaphore(5)
async def fetch_title_limited(client: CDPClient, url: str) -> str:
async with sem:
return await fetch_title(client, url)
Close a page¶
This detaches the session and closes the target. The session.is_closed
property returns True after close. After closing, the session can no
longer be used — commands will raise SessionClosedError.
Close via target¶
The session's is_closed property will become True when the browser
sends Target.detachedFromTarget.
Close one tab, keep others¶
session1 = await client.new_page("https://example.com")
session2 = await client.new_page("https://example.com")
await session1.close()
assert session1.is_closed
# session2 still works
result = await session2.runtime.evaluate("document.title", return_by_value=True)
print(result["result"]["value"]) # "Example Domain"
await session2.close()
Target discovery¶
Enable target discovery to receive events when new targets are created or destroyed:
await client.target.set_discover_targets(discover=True)
async def on_created(params: dict) -> None:
info = params["targetInfo"]
print(f"Created: {info['type']} {info['url']}")
async def on_destroyed(params: dict) -> None:
print(f"Destroyed: {params['targetId']}")
client.on("Target.targetCreated", on_created)
client.on("Target.targetDestroyed", on_destroyed)
This is useful for monitoring browser activity — for example, detecting when a page opens a popup or creates a service worker.
Auto-attach to child targets¶
Automatically attach to new child targets (iframes, workers):
await client.target.set_auto_attach(
auto_attach=True,
wait_for_debugger_on_start=True,
flatten=True,
)
With flatten=True, new child targets get flatten sessions
automatically. Listen for Target.attachedToTarget to get the session:
async def on_attached(params: dict) -> None:
target_info = params["targetInfo"]
session_id = params["sessionId"]
print(f"Attached to {target_info['type']}: {target_info['url']}")
client.on("Target.attachedToTarget", on_attached)
Cleanup¶
When client.close() is called, all open sessions are closed
automatically. Using async with ensures this happens even on errors.
Full example¶
import asyncio
from cdpwave import CDPClient
async def main() -> None:
async with await CDPClient.launch(headless=True) as client:
# Create multiple tabs
tab1 = await client.new_page("https://example.com")
tab2 = await client.new_page("https://example.org")
await tab1.page.enable()
await tab2.page.enable()
# Wait for both to load
loaded1 = asyncio.Event()
loaded2 = asyncio.Event()
async def on_load1(_: dict) -> None:
loaded1.set()
async def on_load2(_: dict) -> None:
loaded2.set()
tab1.on("Page.loadEventFired", on_load1)
tab2.on("Page.loadEventFired", on_load2)
await asyncio.wait_for(loaded1.wait(), timeout=10.0)
await asyncio.wait_for(loaded2.wait(), timeout=10.0)
# Get titles concurrently
async def get_title(session) -> str:
result = await session.runtime.evaluate(
"document.title", return_by_value=True
)
return result["result"]["value"]
title1, title2 = await asyncio.gather(
get_title(tab1),
get_title(tab2),
)
print(f"Tab 1: {title1}")
print(f"Tab 2: {title2}")
# List all pages
pages = await client.get_pages()
print(f"\n{len(pages)} pages:")
for p in pages:
print(f" {p.target_id} | {p.title} | {p.url}")
await tab1.close()
await tab2.close()
asyncio.run(main())