The shift: browsers as cloud infrastructure
For twenty years, browser automation meant running Selenium on a CI server or Puppeteer on your laptop. The browser was a process on a machine you managed. You dealt with Chrome version mismatches, zombie processes, memory leaks, display server configuration, and the fundamental limit that one machine could only run so many browsers before it fell over.
In 2025, three things converged to change this:
- LLMs got good enough to reason about web pages. Claude's computer use, GPT-4o's vision, Gemini's multimodal capabilities — suddenly an AI agent could look at a screenshot and decide what to click next. The browser went from a testing tool to an agent's window to the entire internet.
- Container infrastructure matured. cloud containers, Fly.io Machines, and Firecracker microVMs made it possible to spin up a browser in seconds and tear it down when done. No servers to manage. No Chrome versions to update. No zombie processes to kill.
- The economics flipped. A cloud browser costs $0.007 for 10 minutes. That's less than the electricity your laptop uses running Chrome. When browsers cost nothing, you stop thinking about "how many can I run?" and start thinking about "how many do I need?"
This guide teaches you the mechanics of this new world. By the end, you'll be able to launch browsers on demand, control them from any programming language, and tear them down when you're done — all through API calls.
You know Playwright or Puppeteer. You've automated browsers locally. You want to move that automation to the cloud so you can run more browsers, isolate sessions, and let AI agents drive them. This guide gives you the plumbing. The AI parts come in later guides.
Understanding CDP: the protocol that makes this work
The Chrome DevTools Protocol (CDP) is the WebSocket API that Chrome exposes for programmatic control. When you open Chrome's DevTools (F12), you're using CDP. When Playwright calls page.goto(), it's sending a CDP command over a WebSocket.
The key insight: CDP works over the network. The WebSocket doesn't have to connect to a local browser. It can connect to any Chrome instance that exposes a CDP port — including one running in a container thousands of miles away. The latency is a few milliseconds per command, which is imperceptible for automation workflows.
What CDP can do
CDP isn't just "navigate to a URL." It's a comprehensive browser control protocol with domains for:
| CDP Domain | What It Controls | Agent Use Case |
|---|---|---|
Page | Navigation, lifecycle events, screenshots | Navigate pages, take screenshots for vision models |
DOM | Read and modify the document tree | Extract text, find elements, read page structure |
Input | Mouse, keyboard, touch events | Click buttons, fill forms, type text |
Network | Request/response interception | Monitor API calls, block ads, inject headers |
Runtime | JavaScript execution in page context | Run extraction scripts, modify page behavior |
Emulation | Device metrics, geolocation, user agent | Mobile emulation, geo-specific content |
Storage | Cookies, localStorage, sessionStorage | Manage authentication state, persist sessions |
Target | Browser contexts, tabs, pages | Multi-tab workflows, iframe handling |
Playwright and Puppeteer abstract these domains into friendly APIs (page.click(), page.fill(), etc.), but understanding that CDP is underneath helps you debug issues and build advanced patterns.
Launching a cloud browser
A single API call creates an isolated Chrome container. The container runs as isolated cloud compute — no servers for you to manage. Each browser gets its own IP address, its own cookies, its own session state. Nothing is shared between browsers.
Using curl
# Launch a Chrome browser in the cloud curl -s -X POST "https://sandbox.dev.ab0t.com/api/browsers" \ -H "Authorization: Bearer $SANDBOX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "browser_type": "chrome", "homepage_url": "https://example.com", "idle_timeout_minutes": 30, "enable_debug_port": true }' | jq .
The response
{
"container_id": "ctr-7f3a2b1c",
"browser_type": "chrome",
"status": "running",
"state": "provisioning",
"cdp_url": "ws://54.186.23.117:9222/devtools/browser/a1b2c3d4-e5f6-...",
"access_url": "https://ctr-7f3a2b1c.browser.ab0t.com?token=tok_9x8y7z...",
"debug_port": 9222,
"session_token": "tok_9x8y7z...",
"created_at": "2026-03-27T14:30:00Z",
"from_pool": false,
"mode": "interactive+agent"
}
Two URLs matter:
cdp_url— The WebSocket endpoint for Playwright/Puppeteer programmatic control. This is what your code connects to. It speaks CDP natively.access_url— An HTTPS URL you can open in your own browser to see the remote browser in real time via noVNC. This is invaluable for debugging. You can literally watch your agent click through pages, see what it sees, and spot when it goes wrong.
from_pool field
If warm pools are enabled, from_pool: true means the browser was claimed from a pre-provisioned pool — it's ready in under 1 second instead of the 15-30 second cold start. See the warm pools guide for setup.
Request parameters explained
| Parameter | Type | Default | What It Does |
|---|---|---|---|
browser_type | string | firefox | Which browser engine. Options: chrome, firefox, chromium-headless, selenium-chrome, headless-shell, lightpanda |
homepage_url | string | blank | URL to navigate to on launch. Sets the startup page so the browser is already where you need it when you connect. |
idle_timeout_minutes | integer | 30 | Auto-terminate after N minutes with no CDP connections or page activity. Safety net against forgotten browsers. |
enable_debug_port | boolean | true | Expose CDP on port 9222. Set false if you only need the access_url for visual monitoring. |
cpu | string | 1024 | vCPU allocation (0.25, 0.5, 1, 2, or 4 vCPU). More CPU = faster page loads and JavaScript execution. |
memory | string | 2048 | Memory in MB. Chrome is hungry — 2048 is minimum for complex sites. Use 4096 for pages with heavy JavaScript. |
environment | object | {} | Environment variables passed to the container. Useful for proxy configuration or custom Chrome flags. |
metadata | object | {} | Arbitrary key-value pairs for your own tracking. Shows up in listings and billing reports. |
Connecting Playwright
Playwright has native support for connecting to remote browsers via CDP. The key method is chromium.connect_over_cdp() instead of the usual chromium.launch().
Python (async)
# pip install playwright httpx # playwright install chromium (only needed for type stubs, not the actual browser) import asyncio import httpx from playwright.async_api import async_playwright SANDBOX_URL = "https://sandbox.dev.ab0t.com" API_KEY = "ab0t_sk_live_YOUR_KEY_HERE" async def launch_cloud_browser( browser_type: str = "chrome", homepage: str = "", ) -> dict: """Launch a cloud browser and return the connection details.""" async with httpx.AsyncClient(timeout=60) as http: resp = await http.post( f"{SANDBOX_URL}/api/browsers", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "browser_type": browser_type, "homepage_url": homepage, "idle_timeout_minutes": 30, }, ) resp.raise_for_status() return resp.json() async def wait_for_ready(container_id: str, max_wait: int = 60) -> None: """Poll until the container is ready (CDP available).""" async with httpx.AsyncClient(timeout=10) as http: for _ in range(max_wait // 2): resp = await http.get( f"{SANDBOX_URL}/api/containers/{container_id}/status", headers={"Authorization": f"Bearer {API_KEY}"}, ) data = resp.json() state = data.get("state", "") if state in ("ready", "assigned"): return await asyncio.sleep(2) raise TimeoutError(f"Container {container_id} didn't become ready in {max_wait}s") async def terminate_browser(container_id: str) -> None: """Terminate a cloud browser. Billing stops immediately.""" async with httpx.AsyncClient(timeout=10) as http: await http.delete( f"{SANDBOX_URL}/api/containers/{container_id}", headers={"Authorization": f"Bearer {API_KEY}"}, ) async def main(): # 1. Launch browser_info = await launch_cloud_browser("chrome", "https://news.ycombinator.com") container_id = browser_info["container_id"] cdp_url = browser_info["cdp_url"] print(f"Launched: {container_id}") print(f"Watch live: {browser_info['access_url']}") # 2. Wait for ready await wait_for_ready(container_id) print("Browser ready.") # 3. Connect Playwright async with async_playwright() as pw: browser = await pw.chromium.connect_over_cdp(cdp_url) context = browser.contexts[0] page = context.pages[0] if context.pages else await context.new_page() # 4. The page is already on Hacker News (set by homepage_url) await page.wait_for_load_state("networkidle") title = await page.title() print(f"Page title: {title}") # 5. Extract the top 30 stories stories = await page.evaluate("""() => { return Array.from(document.querySelectorAll('.athing')).map(row => { const titleEl = row.querySelector('.titleline > a'); const subtext = row.nextElementSibling; const scoreEl = subtext?.querySelector('.score'); const ageEl = subtext?.querySelector('.age'); return { rank: row.querySelector('.rank')?.textContent?.trim(), title: titleEl?.textContent?.trim(), url: titleEl?.href, points: scoreEl?.textContent?.trim() || '0 points', age: ageEl?.textContent?.trim() || '', }; }); }""") print(f"\nTop {len(stories)} stories:") for s in stories[:10]: print(f" {s['rank']:>3s} {s['points']:>12s} {s['title']}") # 6. Take a screenshot await page.screenshot(path="hn-frontpage.png", full_page=True) print(f"\nScreenshot saved: hn-frontpage.png") # 7. Click into the top story and extract content if stories: first_url = stories[0]["url"] if first_url and first_url.startswith("http"): await page.goto(first_url) await page.wait_for_load_state("domcontentloaded") body_text = await page.evaluate("() => document.body.innerText.slice(0, 500)") print(f"\nFirst story preview:\n{body_text}...") await browser.close() # 8. Terminate await terminate_browser(container_id) print(f"\nBrowser {container_id} terminated. Billing stopped.") asyncio.run(main())
You launched a Chrome browser in the cloud, connected to it over the internet via CDP, navigated Hacker News, extracted structured data from 30 stories, took a full-page screenshot, clicked into an article, extracted its text, and terminated the browser. Total cost: about $0.003. Total time: about 30 seconds (15 for container start, 15 for automation).
Node.js with Puppeteer
Puppeteer uses the same CDP protocol. The API is nearly identical:
// npm install puppeteer-core const puppeteer = require('puppeteer-core'); const CDP_URL = 'ws://54.186.23.117:9222/devtools/browser/a1b2c3d4...'; (async () => { const browser = await puppeteer.connect({ browserWSEndpoint: CDP_URL }); const pages = await browser.pages(); const page = pages[0] || await browser.newPage(); await page.goto('https://news.ycombinator.com', { waitUntil: 'networkidle2' }); // Extract stories const stories = await page.evaluate(() => { return Array.from(document.querySelectorAll('.athing')).slice(0, 10).map(row => ({ title: row.querySelector('.titleline > a')?.textContent, url: row.querySelector('.titleline > a')?.href, })); }); stories.forEach((s, i) => console.log(`${i+1}. ${s.title}`)); await page.screenshot({ path: 'hn.png', fullPage: true }); await browser.close(); })();
Python (sync, for simpler scripts)
If you don't need async, Playwright's sync API works too:
from playwright.sync_api import sync_playwright cdp_url = "ws://54.186.23.117:9222/devtools/browser/a1b2c3d4..." with sync_playwright() as pw: browser = pw.chromium.connect_over_cdp(cdp_url) page = browser.contexts[0].pages[0] page.goto("https://example.com") print(page.title()) page.screenshot(path="example.png") browser.close()
Choosing the right browser type
Six browser types are available, each optimized for a different use case. Picking the right one affects cost, performance, and compatibility.
| Type | Engine | Headless | CDP | Resources | Cost/hr | Best For |
|---|---|---|---|---|---|---|
chrome |
Chromium 124+ | No (visible) | Full | 1 vCPU, 2 GB | $0.04 | General automation. Most compatible. Use when you need screenshots or access_url for live viewing. The default choice. |
headless-shell |
Chrome headless shell | Yes | Full | 0.5 vCPU, 1 GB | $0.02 | Pure scraping. No visual output. 50% cheaper than chrome. Fastest startup. Use when you only need DOM access and network interception. |
chromium-headless |
Chromium (headless mode) | Yes | Full | 1 vCPU, 2 GB | $0.04 | Same as chrome but in headless mode. Screenshots work but no live viewing. For when you need full Chrome features without the display overhead. |
firefox |
Gecko (Firefox) | No | Limited | 1 vCPU, 2 GB | $0.04 | Cross-browser testing. Some sites behave differently in Firefox. CDP support is limited — use Playwright's Firefox protocol instead. |
selenium-chrome |
Chromium + WebDriver | No | Full + WebDriver | 1 vCPU, 2 GB | $0.04 | Selenium Grid compatibility. Use when you have existing Selenium tests you want to run in the cloud without rewriting. |
lightpanda |
Lightweight engine | Yes | Partial | 0.25 vCPU, 512 MB | $0.01 | Ultra-fast scraping of simple pages. Minimal JavaScript support. 75% cheaper than chrome. For static content extraction at massive scale. |
Need to see the browser? Use chrome.
Pure data extraction? Use headless-shell.
100+ browsers in parallel? Use lightpanda if the sites are simple, headless-shell if they're JavaScript-heavy.
Existing Selenium tests? Use selenium-chrome.
Cross-browser testing? Add firefox.
Automation patterns
These are the building blocks. Every browser agent combines some subset of these patterns.
Pattern 1: Extract structured data from a page
The most common pattern. Navigate to a page, run a JavaScript extraction function, get back structured JSON.
# Extract product listings from an e-commerce page await page.goto("https://store.example.com/products") await page.wait_for_selector(".product-card") products = await page.evaluate("""() => { return Array.from(document.querySelectorAll('.product-card')).map(card => ({ name: card.querySelector('.product-name')?.textContent?.trim(), price: card.querySelector('.price')?.textContent?.trim(), rating: card.querySelector('.stars')?.getAttribute('data-rating'), url: card.querySelector('a.product-link')?.href, in_stock: !card.querySelector('.out-of-stock'), image_url: card.querySelector('img')?.src, })); }""") print(f"Found {len(products)} products") for p in products[:5]: print(f" {p['name']:40s} {p['price']:>10s} {'In stock' if p['in_stock'] else 'OUT'}")
Pattern 2: Fill and submit a form
Log into a portal, navigate a multi-step form, handle dropdowns and file uploads.
# Log into a vendor portal await page.goto("https://vendor-portal.example.com/login") await page.fill('input[name="email"]', "finance@yourcompany.com") await page.fill('input[name="password"]', "secure-password-from-vault") await page.click('button[type="submit"]') # Wait for redirect to dashboard await page.wait_for_url("**/dashboard**", timeout=15000) print("Logged in.") # Navigate to the invoice section await page.click('a[href="/invoices"]') await page.wait_for_selector(".invoice-table") # Download the latest invoice PDF async with page.expect_download() as download_info: await page.click('.invoice-row:first-child .download-btn') download = await download_info.value await download.save_as(f"invoices/{download.suggested_filename}") print(f"Downloaded: {download.suggested_filename}")
Pattern 3: Screenshots for vision models
Take a screenshot, encode it as base64, send it to Claude or GPT-4o for visual reasoning. This is how computer-use agents work.
import base64 import anthropic # pip install anthropic # Take a screenshot of the current page screenshot_bytes = await page.screenshot(full_page=False) b64_image = base64.b64encode(screenshot_bytes).decode() # Send to Claude's vision model client = anthropic.Anthropic() response = client.messages.create( model="claude-sonnet-4-6", max_tokens=1024, messages=[{ "role": "user", "content": [ { "type": "image", "source": {"type": "base64", "media_type": "image/png", "data": b64_image}, }, { "type": "text", "text": "What products are shown on this page? List them with prices.", }, ], }], ) print(response.content[0].text)
This screenshot-and-reason pattern is the core loop of every vision-based browser agent: take screenshot → send to LLM → get action (click coordinates, text to type) → execute action → repeat. The vision model browser agent guide builds a full agent on top of this pattern.
Pattern 4: Intercept network requests
Monitor, modify, or block network requests. Useful for capturing API responses, blocking analytics, or injecting authentication headers.
# Capture all API responses the page makes api_responses = [] async def capture_api(response): if "/api/" in response.url and response.status == 200: try: body = await response.json() api_responses.append({ "url": response.url, "method": response.request.method, "data": body, }) except: pass page.on("response", capture_api) # Navigate — the page's JavaScript will make API calls await page.goto("https://app.example.com/dashboard") await page.wait_for_timeout(5000) # Let it load fully print(f"Captured {len(api_responses)} API responses") for r in api_responses: print(f" {r['method']:>4s} {r['url']}") # The api_responses list now contains the actual JSON data # that the page's frontend fetched — often more structured than # what's visible in the DOM. This is the scraping shortcut for # single-page apps.
Pattern 5: Multi-page navigation
Follow links, paginate through results, navigate complex site structures.
# Paginate through search results and collect all items all_results = [] page_num = 1 await page.goto("https://search.example.com?q=ai+agents") while True: await page.wait_for_selector(".result-item") # Extract results from this page results = await page.evaluate("""() => Array.from(document.querySelectorAll('.result-item')).map(el => ({ title: el.querySelector('.title')?.textContent?.trim(), url: el.querySelector('a')?.href, snippet: el.querySelector('.snippet')?.textContent?.trim(), })) """) all_results.extend(results) print(f"Page {page_num}: {len(results)} results (total: {len(all_results)})") # Check for next page next_btn = await page.query_selector('a.next-page:not(.disabled)') if not next_btn or page_num >= 10: break await next_btn.click() await page.wait_for_load_state("networkidle") page_num += 1 print(f"\nCollected {len(all_results)} total results across {page_num} pages")
Pattern 6: Persistent authentication
Log in once, save cookies, reuse them in future sessions. Avoids repeating login flows for recurring tasks.
import json # After logging in successfully, save cookies cookies = await context.cookies() with open("vendor-cookies.json", "w") as f: json.dump(cookies, f) print(f"Saved {len(cookies)} cookies") # In a later session, restore cookies before navigating with open("vendor-cookies.json") as f: saved_cookies = json.load(f) await context.add_cookies(saved_cookies) # Now navigate directly to the protected page — no login needed await page.goto("https://vendor-portal.example.com/invoices") # If cookies are valid, you're already logged in
Container lifecycle and state machine
Browser containers go through a defined set of states. Understanding these helps you write reliable automation that handles startup delays, warm pool claims, and graceful cleanup.
| State | What's Happening | Duration | Billing |
|---|---|---|---|
provisioning | Platform is provisioning your container | 10-30 seconds (cold) or <1 second (from pool) | Starts at provisioning |
waiting_callback | Container is starting, browser is initializing | 5-15 seconds | Active |
ready | Browser is running, CDP port is listening, ready for connections | Until idle timeout or explicit termination | Active |
assigned | Claimed from warm pool and assigned to a user | Same as ready | Active |
releasing | Being returned to the pool (user data flushed) | ~5 seconds | Active |
terminating | Container shutting down | ~5 seconds | Stops at termination request |
terminated | Gone. Resources freed. | Final | None |
Don't try to connect Playwright the instant you get the response back. The cdp_url is in the response immediately, but the container isn't actually listening yet. Poll GET /api/containers/{id}/status until state is ready or assigned, or simply sleep 15-20 seconds. Connecting too early produces a "WebSocket connection refused" error.
What it costs
Browser containers bill per second of active time, from provisioning to termination.
| Browser | Resources | Per Hour | Per 10 Min | Per 1 Min |
|---|---|---|---|---|
chrome | 1 vCPU, 2 GB | $0.04 | $0.007 | $0.0007 |
headless-shell | 0.5 vCPU, 1 GB | $0.02 | $0.003 | $0.0003 |
lightpanda | 0.25 vCPU, 512 MB | $0.01 | $0.002 | $0.0002 |
chrome (4 vCPU) | 4 vCPU, 8 GB | $0.16 | $0.027 | $0.0027 |
To put this in perspective:
- Scraping 100 product pages with headless-shell takes ~5 minutes: $0.002
- Filling out a 10-step form on a vendor portal takes ~3 minutes: $0.002
- Running a full visual regression suite (50 screenshots) takes ~10 minutes: $0.007
- An agent researching a topic across 20 pages takes ~15 minutes: $0.01
A 15-minute browser session costs $0.01 in compute. The LLM calls to decide what to click and what to extract cost $0.05–$0.50 depending on the model and token count. When budgeting, focus on model costs, not browser costs. The compute is effectively free.
Production tips
Always set idle_timeout_minutes
30 minutes is a good default. For quick scraping tasks, set 10. For long research sessions, set 60. Idle timeout is your safety net against forgotten browsers that run up your bill.
Use headless-shell for pure scraping
If you don't need screenshots, access_url, or visual debugging, headless-shell is 50% cheaper and starts faster. Switch to chrome only when debugging or when you need the visual stream.
Terminate explicitly, don't rely on idle timeout
Call DELETE /api/containers/{id} when you're done. Billing stops immediately. Idle timeout is a safety net, not a strategy.
Use page.wait_for_selector() not page.wait_for_timeout()
Waiting for a specific element is deterministic. Waiting for a fixed number of seconds is fragile and slow. The selector approach handles fast and slow networks equally well.
Handle navigation errors gracefully
Websites go down, pages 404, JavaScript errors crash SPAs. Wrap navigation in try/except and take a screenshot on failure for debugging:
try: await page.goto(url, timeout=30000) await page.wait_for_load_state("networkidle") except Exception as e: await page.screenshot(path=f"error-{container_id}.png") print(f"Navigation failed: {e}. Screenshot saved for debugging.") # Continue to next URL instead of crashing the whole run
Respect rate limits and robots.txt
Cloud browsers are real browsers with real IPs. If you hit a site too fast, you'll get rate-limited or blocked just like any other client. Add delays between requests, rotate through pages instead of hammering one endpoint, and check robots.txt for sites you scrape regularly.
Use the access_url for live debugging
When developing a new automation flow, open the access_url in your own browser side-by-side with your terminal. You can watch the remote browser in real time — see exactly what the agent sees, catch when a selector is wrong, spot when a page loads differently than expected. This is the fastest way to debug browser automation.
Troubleshooting
WebSocket connection refused
The container isn't ready yet. Wait for state ready by polling GET /api/containers/{id}/status, or sleep 15-20 seconds after creation. Cold-start containers take 15-30 seconds. Warm pool containers are instant.
Playwright can't find selectors
The page might be a single-page app that loads content dynamically. Use page.wait_for_selector() to wait for the element to appear. If the content is inside an iframe, switch to the iframe first: frame = page.frame(name="content").
Page shows CAPTCHA or bot detection
Cloud browsers have real browser fingerprints (not headless markers), but some sites detect automated access. Mitigations: (1) use chrome instead of headless-shell, (2) set a realistic user agent, (3) add human-like delays between actions, (4) use the Emulation CDP domain to set viewport and device metrics.
Screenshots are blank
The page may not have finished rendering. Use await page.wait_for_load_state("networkidle") before taking screenshots. For SPAs, wait for a specific element: await page.wait_for_selector(".main-content").
Memory errors on heavy pages
Chrome uses a lot of memory. Default containers have 2 GB. For JavaScript-heavy pages (dashboards, data visualizations, large SPAs), request 4096 MB: "memory": "4096". For simple pages, 1 GB suffices.
Browser disconnected unexpectedly
Check idle timeout — if there's no CDP activity for idle_timeout_minutes, the container terminates. Increase the timeout or send periodic keepalive commands (e.g., await page.evaluate("1") every few minutes).
What's next
You now know how to launch cloud browsers, connect Playwright, and automate common web tasks. This is the foundation. Here's where to go from here:
Launch a browser in the cloud
Chrome, Firefox, headless. Isolated, metered, API-driven. Each browser is its own container with its own IP, cookies, and session. Start in seconds, pay by the second.
Get Started Free