What a Browserless Proxy Is and Where It Sits
A browserless proxy is the outbound route attached to a hosted headless browser session. The browser runs on Browserless hardware; the proxy decides which IP the target site sees. You choose the route once, at session start, with query parameters on the connection URL: proxy=residential, proxyCountry, proxySticky, or externalProxyServer for your own provider. Get those wrong and the session rotates IPs mid-flow, doubles your unit spend, or fails with a 401.
📌 TL;DR Executive Summary
- Core Takeaway: The proxy is chosen at session start from query parameters on the WebSocket or HTTP endpoint —
proxy=residential,proxyCountry,proxySticky, orexternalProxyServerfor your own provider. - Key Risk/Challenge: Residential traffic bills at 6 units/MB against 2 for datacenter, every reconnect starts a fresh 30-second unit, and
proxyStickyhas no effect on a bring-your-own route. - Recommended Solution: Read the exit IP inside the session before you run the real flow, pin stickiness for anything involving a sign-in, and drop to datacenter where IP reputation is not part of the test.
Prerequisites
You need five things before the first step, and none of them require a local browser install — you are attaching to a browser that already runs on Browserless hardware.
- A Browserless token. The free plan covers the built-in residential and datacenter networks, with 1,000 units a month, two concurrent browsers and a two-minute maximum session. External proxies need a paid plan.
- Python 3.10 or newer with
playwrightandrequests:pip install playwright requests. Skipplaywright installhere — nothing launches locally. - An endpoint region. Browserless runs San Francisco, London and Amsterdam hosts. Pick the one closest to your users, not to your laptop.
- A destination you own or are authorised to test. The examples read the exit IP from
api.ipify.organd headers fromhttpbin.org; swap in your own staging URLs for real work. - Optional proxy credentials from a provider, if you plan to skip the built-in networks.
How the Proxy Parameters Attach to a Session
Browserless runs Chromium, WebKit or Firefox on its own machines. The proxy sits between that browser and the site, so the target never sees the Browserless datacenter address — it sees the exit node of whichever network you selected. Because the proxy is part of the browser launch environment, it is fixed for the life of the session. You cannot swap routes from the client mid-run; you reconnect with a different URL instead.
Every option is a query parameter, which means the whole configuration is one string. That string is also the thing most likely to end up in a log file with your token in it, so build it from environment variables and never paste it into a shared notebook.
| Parameter | Accepted values | What it changes |
|---|---|---|
proxy |
residential, datacenter |
Selects the built-in network. Datacenter was added in July 2026 at 2 units/MB. |
proxyCountry |
Two-letter country code | Exit country, for example us, br, de. |
proxyCity |
City slug from /proxy/cities |
City-level exit. Requires a Scale plan of 500k+ units; lower plans return 401. |
proxySticky |
true / false |
Pins one exit IP for the session instead of a random node per request. |
proxyLocaleMatch |
1 |
Sets the browser language to match the proxy country, such as pt-BR for Brazil. |
externalProxyServer |
Percent-encoded proxy URL | Routes through your own HTTP proxy. Paid plans only. |
The unit model matters as much as the parameters. One unit covers up to 30 seconds of browser time per connection, and a reconnect counts as a new connection with a fresh unit. If you are still mapping out what a month of sessions costs, calling the API from Python and Node is the shorter companion read; the full parameter list lives in the Browserless proxy documentation.
Step 1 — Build the Connection URL Safely
Start with a small helper that assembles the endpoint from an environment variable and encodes every value. The urlencode call handles the escaping, including credentials you pass in later steps, and the redaction function keeps the token out of anything you print.
import os
from urllib.parse import urlencode
TOKEN = os.environ["BROWSERLESS_TOKEN"]
HOST = "wss://production-sfo.browserless.io"
def cdp_url(**params):
query = urlencode({"token": TOKEN, **params})
return f"{HOST}?{query}"
def redact(url):
return url.replace(TOKEN, "TOKEN_REDACTED")
url = cdp_url(proxy="residential", proxyCountry="us", proxySticky="true")
print(redact(url))
Keep the helper in a module of its own. Every later step calls it with a different combination, and one place to change the host is one place to fix when you move from San Francisco to Amsterdam.
Step 2 — Attach and Read the Exit IP
Before you test anything real, prove that traffic leaves through the proxy you asked for. Attach over CDP, open a page that echoes the client address, and print it.
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(url)
context = browser.contexts[0]
page = context.new_page()
page.goto("https://api.ipify.org?format=json", wait_until="domcontentloaded")
print("exit IP:", page.inner_text("body"))
page.close()
browser.close()
Two details here matter. connect_over_cdp attaches to a browser that is already running, so browser.contexts[0] is the remote browser’s default context — it exists before you arrive and may already hold pages, cookies and storage. If you need a clean slate, call browser.new_context() and work inside that: the context is your isolation boundary and the only one you fully control from the client side.
The second detail is the address you see. With no proxy parameter, the printed IP belongs to the Browserless region you connected to. With proxy=residential&proxyCountry=us, it should be a residential address in the United States. If both runs print the same value, your parameters are not reaching the browser.
Step 3 — Pin One Exit IP With proxySticky
By default, each new request through the built-in networks picks a random node, so the exit IP can change between page loads. That breaks anything with a server-side session: sign-in cookies issued against one address, and checkout or application forms validated on submit. Pin the IP for the whole session with proxySticky=true and confirm it holds.
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(
cdp_url(proxy="residential", proxyCountry="us", proxySticky="true")
)
page = browser.contexts[0].new_page()
ips = set()
for _ in range(3):
page.goto("https://api.ipify.org?format=json", wait_until="domcontentloaded")
ips.add(page.inner_text("body"))
page.wait_for_timeout(1000)
print("distinct exit IPs:", len(ips), sorted(ips))
browser.close()
A sticky session prints 1. If it prints three different addresses, the parameter did not apply — check for a typo, a missing =true, or a second proxy key earlier in the string. Note the inverse case too: Browserless’s managed endpoints such as /unblock, /scrape and BrowserQL are sticky by default, and you opt out with proxySticky=false when you actively want rotation mid-session.
Rotation has its place. If you are load-testing your own rate limiter, or pulling pages that carry no session state, a fresh IP per request spreads the traffic. The rule is simple: server-side state means sticky, no state means rotate. For the rotating side, including how providers express session affinity in the username, see rotating proxy setup.
Step 4 — Choose Datacenter When IP Reputation Is Not the Test
Browserless added datacenter proxies in July 2026 at 2 units/MB, roughly a third the cost of the residential network’s 6 units/MB. The trade-off is coverage and IP type: fewer countries, and addresses that plainly come from a hosting range. Use datacenter for load tests, latency checks, and verifying your own geo-routing rules — anything where the page does not care what kind of network the request came from.
url_dc = cdp_url(proxy="datacenter", proxyCountry="de", proxySticky="true")
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(url_dc)
page = browser.contexts[0].new_page()
page.goto("https://api.ipify.org?format=json", wait_until="domcontentloaded")
print("datacenter exit:", page.inner_text("body"))
browser.close()
The arithmetic explains why the choice matters at volume. A gigabyte is 1,024 MB, so a gigabyte of residential traffic is 6,144 units and the same gigabyte over datacenter is 2,048. At the Scale plan’s $0.0015 per-unit overage, that is about $9.22 against $3.07 per gigabyte.
| Network | Units per MB | Units per GB | Cost per GB at $0.0015/unit |
|---|---|---|---|
| Residential | 6 | 6,144 | about $9.22 |
| Datacenter | 2 | 2,048 | about $3.07 |
Session time is a separate line on the same bill: 30 seconds per unit, per connection. A script that reconnects after every navigation spends more units on handshakes than on bandwidth. Reuse one connection for the whole flow, and for plan sizes and overage tiers, estimating Browserless pricing walks through the maths against the official pricing page.
Step 5 — Target a Country, Then a City
Country targeting works on every plan: add proxyCountry and the exit node moves. City targeting is the expensive one. It requires a Scale plan of 500,000 units or more, and anything below that returns a 401 rather than a helpful message — a status code that sends most people hunting for a token problem that does not exist.
import requests
resp = requests.get(
"https://production-sfo.browserless.io/proxy/cities",
params={"country": "US", "token": TOKEN},
timeout=30,
)
resp.raise_for_status()
print(resp.json())
List the slugs first, then use one in the connection URL. City-level exits are useful for checking a store locator, regional pricing display, or delivery estimates on your own storefront — tests where “somewhere in the US” is not precise enough.
url_city = cdp_url(
proxy="residential",
proxyCountry="US",
proxyCity="new-york",
proxySticky="true",
)
print(redact(url_city))
If the request fails with 401, check the plan before you check the token. Drop proxyCity and retry with only proxyCountry to confirm the rest of the string is valid.
Step 6 — Match the Browser Locale to the Exit Country
A session that exits in Brazil but reports en-US as its browser language is an inconsistent test subject. Regional pages often branch on Accept-Language before they branch on IP, so you end up measuring the wrong variant. proxyLocaleMatch=1 sets the browser language to match the proxy country.
url_br = cdp_url(
proxy="residential",
proxyCountry="br",
proxySticky="true",
proxyLocaleMatch="1",
)
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(url_br)
page = browser.contexts[0].new_page()
page.goto("https://httpbin.org/headers", wait_until="domcontentloaded")
lang = page.evaluate("navigator.language")
print("browser language:", lang)
browser.close()
Read navigator.language rather than trusting the parameter, because the value has to survive the launch and the page has to observe it. Expect something like pt-BR; if you still see en-US, the parameter did not reach the session. Keep locale, country and any timezone your script sets pointing at the same place — a mismatch between them surfaces in QA reports as “the page renders strangely” rather than as an error.
Step 7 — Bring Your Own Proxy
If you already pay for a proxy network, route Browserless through it with externalProxyServer. Two rules apply: it is a paid-plan feature, and the credential half of the URL must be percent-encoded so an @ or a colon inside a password does not split the string in the wrong place.
from urllib.parse import quote
proxy_user = quote(os.environ["PROXY_USER"], safe="")
proxy_pass = quote(os.environ["PROXY_PASS"], safe="")
proxy_host = os.environ["PROXY_HOST"]
proxy_url = f"http://{proxy_user}:{proxy_pass}@{proxy_host}"
url_byo = cdp_url(externalProxyServer=proxy_url)
print(redact(url_byo))
Then attach exactly as before and read the exit IP to confirm the traffic really left through your provider:
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(url_byo)
page = browser.contexts[0].new_page()
page.goto("https://api.ipify.org?format=json", wait_until="domcontentloaded")
print("external proxy exit:", page.inner_text("body"))
browser.close()
Stickiness works differently here. proxySticky is scoped to Browserless’s own networks; for an external route you configure affinity on the supplier’s side, usually by putting a session identifier in the proxy username. Browserless sends the request through whatever gateway you named, so if the supplier rotates per connection, so does your session. Test the exit IP across three loads before you build anything that depends on it holding.
Step 8 — End the Session Without Burning Units
Close deliberately. Over CDP, browser.close() detaches your client — it does not kill the remote browser, which keeps its launch flags and its existing tabs until the session ends server-side. That is fine, as long as you do not assume close() resets anything: a new attachment inherits whatever the remote browser already had.
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(cdp_url(proxy="residential", proxySticky="true"))
try:
page = browser.contexts[0].new_page()
page.goto("https://api.ipify.org?format=json", wait_until="domcontentloaded")
print(page.inner_text("body"))
finally:
browser.close()
Watch the reconnect pattern in your own code. Every reconnect counts as a new connection and starts a fresh 30-second unit, so a retry loop that reconnects on each failure multiplies spend faster than the pages do. The free plan adds a hard two-minute session ceiling, which is enough for the checks above but not for a long multi-step flow. CDP is also Chromium-only — if your test needs WebKit or Firefox, use the Playwright-native endpoint instead, and Browserless vs Puppeteer cloud covers when each approach is the better fit.
Common Errors and What They Actually Mean
Most failures in this workflow fall into six buckets, and the status code usually tells you which one.
| Symptom | Likely cause | Fix |
|---|---|---|
401 when using proxyCity |
Plan below Scale (500k+ units) | Remove proxyCity; use proxyCountry |
| Exit IP changes on every page load | proxySticky missing or set to false |
Add proxySticky=true and re-test with three loads |
| External proxy appears to be ignored | Free plan, or unencoded credentials | Move to a paid plan; percent-encode user and password |
| Session dies around the two-minute mark | Free-plan session ceiling | Shorten the flow or move to a paid plan |
| Units burn with little traffic | Reconnects, or residential at 6 units/MB | Reuse one connection; switch to datacenter |
| Browser language does not match the country | proxyLocaleMatch absent |
Add proxyLocaleMatch=1 and read navigator.language |
Two more that are not errors but read like them. An IP that looks like a hosting range when you asked for residential usually means the network name is spelled residential in one place and mistyped in another — dump the redacted URL and read it character by character. And a page that behaves differently on the second navigation is often not a proxy problem at all: it is a server-side session established on the first load, which is exactly what stickiness is supposed to give you.
The Full Script
This is the complete version, with network, country and an optional external proxy read from environment variables so the same file covers both routes.
"""Check the exit IP, language and stickiness of a Browserless proxy session."""
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
TOKEN = os.environ["BROWSERLESS_TOKEN"]
HOST = os.environ.get("BROWSERLESS_HOST", "wss://production-sfo.browserless.io")
def cdp_url(**params):
return f"{HOST}?" + urlencode({"token": TOKEN, **params})
def build_params():
external = os.environ.get("EXTERNAL_PROXY_URL")
if external:
# Stickiness for an external route is configured at the provider.
return {"externalProxyServer": external}
return {
"proxy": os.environ.get("PROXY_NETWORK", "residential"),
"proxyCountry": os.environ.get("PROXY_COUNTRY", "us"),
"proxySticky": "true",
}
def main():
url = cdp_url(**build_params())
print("endpoint:", url.replace(TOKEN, "TOKEN_REDACTED"))
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(url)
try:
page = browser.contexts[0].new_page()
ips = set()
for _ in range(3):
page.goto("https://api.ipify.org?format=json",
wait_until="domcontentloaded")
ips.add(page.inner_text("body"))
page.wait_for_timeout(1000)
page.goto("https://httpbin.org/headers", wait_until="domcontentloaded")
language = page.evaluate("navigator.language")
print("distinct exit IPs:", len(ips), sorted(ips))
print("browser language:", language)
finally:
browser.close()
if __name__ == "__main__":
main()
Read the output as a checklist: 1 distinct IP for a sticky session, an address in the country you asked for, and a language tag that matches it. Anything else points at one specific parameter, and now you know which one.
🏆 Send.win Verdict
The scripts in this guide belong to Browserless. Send.win takes a different approach: it is a browser profile manager built for people who work in the browser by hand, and each profile keeps its own cookies, storage, sessions, fingerprint settings and proxy — in the desktop app for Windows, macOS and Linux, or in the Helsinki-hosted cloud browser. That covers the human half of the same problem: the support agent signing into six client dashboards, or the QA lead reproducing a bug as a logged-in user in another country. A profile shared with a paid teammate opens already signed in, cloud sync carries the same logins to another device, and per-profile fingerprint settings inside the browser engine mean testing several client accounts does not mean several browsers on your desk.
See Send.win plans — Pro from $19/mo with residential proxies and 150 profiles, or start with the free cloud demo and 10 minutes a day.
Frequently Asked Questions
What is a browserless proxy and how is it different from a normal proxy?
A normal proxy is a network endpoint you point your own software at. A browserless proxy is the route attached to a headless browser session running on someone else’s server, selected at session start with query parameters. The difference matters because the browser, not your machine, makes the request — so the proxy is configured where the browser launches, not in your local network settings.
How do I route Browserless traffic through a residential proxy?
Add proxy=residential to the connection URL, along with proxyCountry if you need a specific exit country and proxySticky=true if the session has to hold one IP. Then attach with Puppeteer or Playwright as usual and read the exit IP inside the page to confirm it took effect. Residential traffic is billed at 6 units/MB.
Can I use my own proxy provider with Browserless?
Yes, on a paid plan, using the externalProxyServer parameter with a percent-encoded proxy URL. The free plan lists the built-in residential and datacenter networks but not external proxies. Once connected, verify the exit IP — and remember that affinity on a bring-your-own route is configured with your provider, not with proxySticky.
Why does my Browserless session keep changing IP?
Without proxySticky, each request through the built-in networks picks a random node. Add proxySticky=true and re-run a loop that loads an IP echo three times; you should see one distinct address. If it still rotates, the parameter is not reaching the session — check the URL for typos or a duplicated proxy key.
How much does Browserless proxy bandwidth cost?
In Browserless’s own unit system, residential is 6 units/MB and datacenter is 2 units/MB. Convert that with your plan’s per-unit overage to get a currency figure: at the Scale rate of $0.0015 per unit, one gigabyte works out to roughly $9.22 residential and $3.07 datacenter. Session time is charged separately at one unit per 30 seconds of browser time per connection.
Does proxySticky work with externalProxyServer?
No. proxySticky is scoped to Browserless’s built-in networks. For an external proxy, the gateway you name decides whether to rotate, so affinity is set on the supplier’s side — often through a session identifier in the proxy username. Test the exit IP over several loads before relying on it.
Why do I get a 401 error with proxyCity?
City-level targeting requires a Scale plan of 500,000 units or more, and smaller plans return 401 rather than a plan-specific error. Drop proxyCity and retry with only proxyCountry to confirm the rest of the connection string is valid, then decide whether city precision is worth the upgrade.
Does browser.close() kill my remote browser over CDP?
No. Over CDP, browser.close() detaches your client from a browser that is already running; the remote session keeps its launch flags and existing tabs until it ends server-side. That also means a new attachment can inherit existing contexts, pages and cookies, so use browser.newContext() when you need a clean starting point.
How Send.win Helps With Browserless Proxy
Send.win is a browser profile manager for professionals and teams: every account gets its own isolated browser profile.
- Isolated profiles – separate cookies, storage and sessions per profile
- Fingerprint protection – each profile keeps its own consistent browser fingerprint, so profiles never share one browser identity
- Desktop app + cloud browser – native app for Windows, macOS and Linux, or run profiles in the cloud with no install
- Residential proxies included – on Pro and Team, with timezone and locale matched to the proxy
- Team sharing – share a signed-in profile with a teammate without sharing the password
Try the free cloud demo — no install — or download the desktop app. Paid plans start at $19/month, and your first card purchase is covered by a 7-day money-back guarantee (see pricing).