What Does a Virtual Browser API Actually Give You?
A virtual browser api is an HTTP interface that starts a real Chromium instance outside your machine and returns a control endpoint — a CDP port or a ws:// URL. You authenticate with a key, call a launch operation, then attach Playwright or Puppeteer to the endpoint you got back. The part that trips people up: the API and the browser are separate services on separate ports.
📌 TL;DR Executive Summary
- Core Takeaway: Every virtual browser API has two channels — an HTTP control API (launch, status, stop) and a CDP endpoint your automation library attaches to. They use different ports and often different credentials.
- Key Risk/Challenge: Pointing
connectOverCDPat the API port instead of the returned debugging port is a common cause of scripts that hang and then fail after 30 seconds. - Recommended Solution: Read the CDP port out of the launch response, attach with Playwright or Puppeteer, and stop the profile through the API rather than assuming your client library killed it.
Prerequisites: What You Need Before the First Call
Two things have to be true before any of this runs: the browser is reachable, and your client library speaks the same CDP dialect as the build on the other end.
- Python 3.10+ for the Playwright path and Node.js 18+ for the Puppeteer path. Node 18 is the floor because the example uses the built-in
fetch. pip install playwright requests, thenplaywright install chromiumso the package has a local binary to fall back on.npm i puppeteer-core— the core package skips the roughly 150 MB Chromium download that full Puppeteer bundles, which is the point when you drive someone else’s browser.- A browser profile manager with a published API reference and an API key. VirtualBrowser, for example, exposes an HTTP API on
http://127.0.0.1:{port}that needs an active Pro membership and the desktop client left running. - The API port itself, read from the client. It is allocated dynamically — 9000 is only ever an example in the docs. For hosted providers the equivalent step is a create-session call that returns a WebSocket URL instead of a port; the walkthrough on the Browserbase API with Python shows that shape.
Store three values in environment variables and keep them out of your source tree: the API base URL, the API key, and the profile or session ID you intend to drive.
Step 1: Authenticate Against the Control API, Not the Browser
The control API answers JSON about profiles, versions and lifecycle. The browser it starts answers the Chrome DevTools Protocol on a separate port. Auth on the control side is a header, not a login flow: VirtualBrowser’s reference sends the key in an api-key header, and that key is neither a website session token nor an IP-geolocation key. Its Apifox listing marks api-key as optional while current clients reject requests without it, so treat the header as mandatory and check the docs against real behaviour.
import os
import requests
# The HTTP control API and the CDP endpoint are two different ports.
API_BASE = os.environ["BROWSER_API_BASE"] # read the real port from the client
API_KEY = os.environ["BROWSER_API_KEY"]
session = requests.Session()
session.headers.update({"api-key": API_KEY, "Content-Type": "application/json"})
def api(path: str, method: str = "GET", **kwargs) -> dict:
response = session.request(method, f"{API_BASE}{path}", timeout=20, **kwargs)
response.raise_for_status()
return response.json()
if __name__ == "__main__":
print(api("/core-version")) # version totals sit at the root of the response
That version call is your cheapest health check. A 200 with data at the root means the key is valid and the client is up. A connection error means the desktop app is closed, the port moved after a restart, or you reused a value from a previous session. If you have only ever called hosted REST services, the pattern is the same one covered in how to call a Browserless API — request, key, JSON out.
Step 2: Launch a Profile and Read data.debuggingPort
Launch is a POST that names the profile. VirtualBrowser’s launchBrowser operation returns the number you actually automate against, nested at data.debuggingPort. That number belongs to the Chromium process just started for that profile, not to the API server, and the two are never interchangeable.
# Placeholders: copy the exact operation paths from the provider's API reference.
LAUNCH_PATH = "/launch" # the launchBrowser operation
STATUS_PATH = "/is-browser-running" # the isBrowserRunning operation
def launch_profile(profile_id: str) -> int:
data = api(LAUNCH_PATH, method="POST", json={"profileId": profile_id, "headless": False})
if data.get("success") is False:
raise RuntimeError(data.get("message", "launch failed"))
port = (data.get("data") or {}).get("debuggingPort")
if not port:
raise RuntimeError(f"no debuggingPort in response: {data}")
return int(port)
def is_running(profile_id: str) -> bool:
result = api(f"{STATUS_PATH}?profileId={profile_id}")
if isinstance(result, bool): # some operations skip the success/data wrapper
return result
return bool(result.get("data"))
Two habits pay for themselves immediately. First, do not assume every response has the same shape — partial responses exist, and a status endpoint returning a bare boolean breaks a parser written for {"success": ..., "data": ...}. Second, bound your concurrency. A launch that times out on your side may still have started a browser, so query the running state and reuse the port before retrying rather than stacking a second process on the same profile.
Step 3: Attach Playwright with connectOverCDP
Playwright does not need to launch anything here. chromium.connect_over_cdp() attaches to a Chromium that is already running, and per the BrowserType documentation it is supported only for Chromium-based browsers. It accepts an http:// or ws:// endpoint, defaults to a 30-second connection timeout, and takes isLocal when the browser sits on the same host. Pass noDefaults when you attach to a browser you did not configure and want Playwright to stop applying its own context overrides.
Be honest about the trade-off: connecting over CDP is lower fidelity than browserType.connect(), which requires a browser started with launchServer and a matching Playwright major/minor version. Features that depend on launch-time patching can behave differently when Playwright is a guest in someone else’s process. The same caution applies to any browser profile API you attach to rather than start yourself.
from playwright.sync_api import sync_playwright
def smoke_test(cdp_port: int, url: str) -> str:
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(
f"http://127.0.0.1:{cdp_port}",
timeout=30_000,
)
# Reuse the profile's existing context and tab so its cookies are in play.
context = browser.contexts[0] if browser.contexts else browser.new_context()
page = context.pages[0] if context.pages else context.new_page()
page.goto(url, wait_until="domcontentloaded")
page.wait_for_selector('[data-testid="app-shell"]')
title = page.title()
page.screenshot(path="smoke.png", full_page=True)
browser.close() # clears Playwright's contexts and disconnects the client
return title
Note what browser.close() does on a browser you connected to rather than launched: it clears the contexts Playwright created and disconnects the client, so the process on the other end can outlive your script. If you want the profile genuinely stopped, call the provider’s stop operation — do not assume the library did it for you.
Step 4: Drive the Same Browser from Node with Puppeteer
Puppeteer has no launch() in this model. You pass a CDP WebSocket URL to puppeteer.connect({ browserWSEndpoint }). That URL is the browser-level control channel — literally the string Chromium prints to stderr as DevTools listening on ws://…. Every CDP-enabled Chromium also exposes it as JSON at http://127.0.0.1:{port}/json/version, which is what the snippet below reads.
// ESM: save as .mjs, or set "type": "module" in package.json.
import puppeteer from 'puppeteer-core';
const cdpPort = Number(process.env.CDP_PORT);
const target = process.env.TEST_URL ?? 'https://staging.example.com';
// Browser-level WebSocket URL, read from the CDP HTTP endpoint.
const { webSocketDebuggerUrl } = await fetch(`http://127.0.0.1:${cdpPort}/json/version`)
.then((response) => response.json());
const browser = await puppeteer.connect({
browserWSEndpoint: webSocketDebuggerUrl,
defaultViewport: null, // keep the window size the profile already has
});
const [page] = await browser.pages();
await page.goto(target, { waitUntil: 'domcontentloaded' });
console.log('title:', await page.title());
await page.screenshot({ path: 'smoke-node.png' });
await browser.disconnect(); // socket dropped, browser stays up for the next worker
The disconnect() versus close() distinction decides whether your next job finds a warm session. browser.disconnect() drops the socket and leaves the browser running, so a second worker attaches and sees the same cookies and tabs. browser.close() shuts the browser down. Pick deliberately, because one of those choices silently throws away the signed-in state you came for.
0.0.0.0, never forward the port through a tunnel, and never leave it reachable from a shared network without authentication in front of it.
Step 5: Keep Sessions Alive Across Workers
If a background job launches the browser, does its work and shuts down, every run pays the startup cost and loses the signed-in state. Decouple the two lifetimes: leave the browser running on the provider side, store the profile ID and CDP port in Redis or Postgres, and let workers attach and detach. A hosted session typically costs 1–3 seconds of container spin-up, while a local Chromium instance can use 200–500 MB of RAM — which is why ten parallel local sessions on one laptop is not a plan.
| Dimension | Local CDP attach | Hosted virtual browser API |
|---|---|---|
| Startup | Profile manager start, then near-instant attach | Typically 1–3 seconds of container spin-up |
| Cost shape | Fixed hardware, bounded by RAM | Billed per browser-hour |
| Parallelism | Bounded by RAM: 200–500 MB per instance | Set by plan limits and egress |
| Profile state | Lives on your disk | Persistent volume, if the provider offers one |
| Network origin | Your IP or your own proxy | Provider egress — check the controls |
| Exposure surface | Loopback by default | Public endpoint that needs auth in front |
Chromium’s own memory benchmarks are a decent sizing reference — the browse, load and multitab stories approximate what a real page costs. One caveat on the local-versus-hosted comparison: the vendors publishing those numbers sell hosted sessions, so their per-hour framing flatters their own model. Measure your launch times before you commit. If you are stitching several providers into one pipeline, read the sequencing in how to integrate cloud browser APIs before you write the abstraction layer.
{profile_id: cdp_port} with a short TTL and refresh it on every attach. When a worker dies mid-job, the next one checks the TTL, re-validates the port against the status endpoint, and resumes instead of relaunching.
What to Check Before You Build on a Provider
Read the API reference before the marketing page. Six things decide whether a provider fits your pipeline:
- Lifecycle semantics. What exactly does stop or terminate do, and what happens to a session whose worker crashed? If billing runs per browser-hour, an orphaned browser costs you until something kills it.
- CDP compatibility. Which Chromium build are you talking to, and can it change under you mid-contract? Version drift is the quiet killer of scripts that worked last month.
- Persistent profiles. Cookies and localStorage survive only when the provider offers a persistent profile volume, and it is usually opt-in per session. Check the fine print before you design around it.
- Debugging access. A live view of the running session beats log archaeology when a selector breaks three layers deep.
- Egress controls. Where does the traffic leave from, can you attach your own proxy, and can you allowlist the CDP endpoint by IP?
- Cold-start budget. A few seconds per session is fine for one job and painful for fifty. Measure before you promise timing to anyone.
Common Errors and How to Read Them
Connection refused on the API base URL
The desktop client is closed, or the port changed after a restart because it is allocated dynamically. Re-read the port from the running client rather than reusing yesterday’s value. On hosted providers, the same error usually means a session that already expired.
401 or 403 from the control API
Missing api-key header, the wrong key type, or an account without the required tier. A website login token and an IP-geolocation key both look like credentials and neither works here.
connectOverCDP hangs, then fails after 30 seconds
You pointed Playwright at the API port. Compare the number in your code against data.debuggingPort from the launch response; if they match and it still hangs, the browser process died after launch and the port is stale.
Protocol errors or unknown method names
Version drift. With browserType.connect() the connecting Playwright must share major and minor with the launcher — 1.2.x against 1.2.3. With raw CDP the same principle applies loosely: pin your Puppeteer or Playwright version per browser build and upgrade deliberately.
Launch reports success but nothing is running
Check the headless flag and the profile state. A launch call against a profile that is already open may return the existing session rather than starting a second one, so query the status endpoint before assuming failure.
Everything works until you run five jobs at once
Memory. Each Chromium instance costs 200–500 MB, so five workers on a small box start swapping and timeouts cascade. Cap concurrency with a semaphore and queue the rest.
The Full Script
Here is the whole flow in one file: read config, launch, attach, verify, and stop in a finally block so a failed assertion never leaves a browser running. It checks a staging URL you own with a test account — a smoke test, not a load generator.
"""End-to-end smoke check against your own staging site."""
import os
import requests
from playwright.sync_api import sync_playwright
API_BASE = os.environ["BROWSER_API_BASE"]
API_KEY = os.environ["BROWSER_API_KEY"]
PROFILE_ID = os.environ["BROWSER_PROFILE_ID"]
TEST_URL = os.environ.get("TEST_URL", "https://staging.example.com")
LAUNCH_PATH = "/launch" # copy the exact path from the API reference
STOP_PATH = "/stop" # copy the exact path from the API reference
session = requests.Session()
session.headers.update({"api-key": API_KEY, "Content-Type": "application/json"})
def api(path: str, method: str = "GET", **kwargs) -> dict:
response = session.request(method, f"{API_BASE}{path}", timeout=20, **kwargs)
response.raise_for_status()
return response.json()
def launch(profile_id: str) -> int:
data = api(LAUNCH_PATH, method="POST", json={"profileId": profile_id, "headless": False})
if data.get("success") is False:
raise RuntimeError(data.get("message", "launch failed"))
port = (data.get("data") or {}).get("debuggingPort")
if not port:
raise RuntimeError(f"no debuggingPort in response: {data}")
return int(port)
def stop(profile_id: str) -> dict:
return api(STOP_PATH, method="POST", json={"profileId": profile_id})
def main() -> None:
port = launch(PROFILE_ID)
print(f"attached to CDP on 127.0.0.1:{port}")
try:
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(
f"http://127.0.0.1:{port}", timeout=30_000
)
context = browser.contexts[0] if browser.contexts else browser.new_context()
page = context.pages[0] if context.pages else context.new_page()
page.goto(TEST_URL, wait_until="domcontentloaded")
page.wait_for_selector('[data-testid="app-shell"]')
title = page.title()
page.screenshot(path="smoke.png", full_page=True)
browser.close()
if "staging" not in title.lower():
raise SystemExit(f"unexpected title: {title}")
print("OK:", title)
finally:
stop(PROFILE_ID)
if __name__ == "__main__":
main()
The launch and stop paths come from your provider’s reference; the CDP attach, the context reuse and the screenshot are standard Playwright. Once this runs green, the same skeleton carries your own checks — a login flow, a checkout step, a locale switch — as long as every account you sign in with is one you own or are authorised to manage.
🏆 Send.win Verdict
Send.win covers the other half of the work: it is a browser profile manager built for sessions a person signs into by hand, not a scripting platform. If your job is to drive a Chromium session programmatically, the tools in this tutorial are the right ones. Send.win looks after the profiles your team works in directly — each isolated profile keeps its own cookies, storage, sessions, fingerprint settings and proxy, and a profile shared with a paid teammate opens already signed in, so the QA engineer testing as a customer, the support agent reproducing a region-specific bug and the social media manager running client accounts each work in their own space without passwords changing hands. The cloud browser runs those profiles on Send.win’s server in Helsinki (EU), which helps when a colleague on another machine needs the exact session you are looking at.
See Send.win plans — Pro from $19/mo with 150 profiles and 5 GB of proxy bandwidth, Team at $49/mo with 500 profiles and 16 seats, or start with the free cloud demo first.
Frequently Asked Questions
What is a virtual browser API and how does it work?
It is an HTTP service that starts a real Chromium instance for you and returns a CDP port or WebSocket URL. Your code authenticates with a key, calls a launch operation, then attaches Playwright or Puppeteer to the endpoint it receives. The API manages lifecycle; the browser does the rendering.
Why does launch return a different port than the API?
Because they are separate services. The control API listens on a fixed or dynamically allocated port of its own, while each launched browser opens its own CDP port, returned at data.debuggingPort. Sending automation traffic to the API port is a common cause of connection timeouts.
What is the difference between connectOverCDP and connect in Playwright?
connectOverCDP attaches to any running Chromium over the DevTools Protocol and is Chromium-only, with lower fidelity than Playwright’s own protocol. browserType.connect() talks to a browser started with launchServer, gives you full feature parity, and requires the connecting Playwright to match the launcher’s major and minor version.
What is browserWSEndpoint in Puppeteer and where do I get it?
It is the browser-level CDP WebSocket URL — the one Chromium prints as DevTools listening on ws://…. You can read it from http://127.0.0.1:{port}/json/version or take it directly from a hosted provider’s create-session response, then pass it to puppeteer.connect() instead of calling launch().
Should I call browser.disconnect() or browser.close()?
In Puppeteer, disconnect() drops your socket and leaves the browser running so another worker can attach; close() shuts the browser down. In Playwright, browser.close() on a connected browser clears the contexts Playwright created and disconnects — stop the browser through the provider’s API if you want it gone.
Can I keep a browser session alive across cloud workers?
Yes, by decoupling browser lifetime from worker lifetime. Launch once, store the profile ID and CDP port in Redis or Postgres, and let each worker attach, do its work and detach. Add a TTL and re-validate the port against the status endpoint before reusing a stored value.
Do hosted browser APIs keep cookies and localStorage?
Only if the provider offers persistent profiles — and they are usually opt-in per session. Without a persisted profile volume, each session starts clean and your cookies disappear at termination, so check that specific line in the pricing and docs before you design around it.
Is it safe to expose a CDP endpoint to the internet?
No. Anyone who reaches a CDP port can read cookies, navigate and execute script in that browser. Keep it on loopback, or put authentication and network restrictions in front of it if it must be remote — a bare forwarded port is equivalent to handing over the session.
Run Virtual Browser Api in the Cloud With Send.win
Send.win’s cloud browser runs isolated browser profiles on a server in the EU — open a clean session from any device without installing anything:
- Instant cloud sessions – launch an isolated browser in seconds, no local install
- Isolated profiles – separate cookies, storage and fingerprint settings per session
- Cloud sync & profile sharing – pick up the same profiles on the desktop app (Windows, macOS, Linux) or share them with your team
- Residential proxies included – on Pro and Team, with timezone and locale matched to the proxy
You can try it right now: the Send.win demo browser opens an isolated cloud session directly in this browser tab. Paid plans start at $19/month, with a 7-day money-back guarantee on your first card purchase — see pricing.