A context's proxy is fixed at creation and SOCKS5 auth is undocumented. Build a context pool on sticky ports, isolate identity, and cut the bandwidth bill.
· 14 min read · Integrations
Verified against Playwright 1.62.1 on 2026-08-06.
Playwright proxy per browser context rotation is built on two facts the large
setup guides skip. A context's proxy is decided when the context is created
and cannot be changed afterward — there is no setter anywhere on
BrowserContext. And Playwright documents proxy credentials for HTTP(S) only,
which means authenticated SOCKS5 is not a supported configuration. Everything
useful about rotating exits in Playwright follows from those two, and neither
one is a limitation you work around. They are the shape of the design.
What comes out of them is a context pool: N contexts, each pinned to its own upstream session, recycled on failure rather than on a timer. That pattern is below, with the failure handling that makes it survive contact with real targets.
browser.newContext({ proxy }) takes the proxy at construction. The
BrowserContext API
contains no proxy method at all — no setProxy, no updateProxy, nothing
that mutates routing on a live context. To change the exit path you close the
context and open a new one, or you relaunch the browser.
That single constraint is why "rotate the proxy" in Playwright always means "manage a set of contexts". Your rotation logic is a lifecycle problem, not a configuration problem, and the code you write is an allocator.
The proxy option's own field descriptions draw the line. server says
"HTTP and SOCKS proxies are supported, for example http://myproxy.com:3128
or socks5://myproxy.com:3128." The credential fields say something narrower:
"Optional username to use if HTTP proxy requires authentication."
So a SOCKS5 endpoint that needs credentials has no documented home in the
proxy object. The corresponding feature request,
microsoft/playwright#10567,
was opened on 2021-11-26 and is still open, labeled P3-collecting-feedback.
The practical consequence: on a gateway where SOCKS5 requires a username and
password, use the HTTP/HTTPS port from Playwright. On AnonEdge that is
gw.anonedge.com:823 for rotating and any port in 10000-20000 for sticky —
the connection modes are the
same either way; only the protocol changes. If you specifically need
SOCKS5 and its limits for the remote-DNS
behavior, terminate it outside the browser with a local forwarding proxy and
point Playwright at localhost.
Set at launch, the proxy applies to the whole browser process:
const browser = await chromium.launch({
proxy: { server: 'http://gw.anonedge.com:823', username: 'YOUR_LOGIN', password: 'YOUR_PASSWORD' }
})
Set per context, it applies to that context only and overrides the launch value:
const context = await browser.newContext({
proxy: { server: 'http://gw.anonedge.com:10000', username: 'YOUR_LOGIN', password: 'YOUR_PASSWORD' }
})
There is a piece of received wisdom that you must also set a proxy at launch —
even a placeholder — for the per-context option to take effect in Chromium.
That was true, and it was scoped more narrowly than the folklore suggests.
Playwright's documentation source carried the note "For Chromium on Windows
the browser needs to be launched with the global proxy for this option to
work" in the context-option-proxy block
through release-1.46,
and it is absent from
release-1.47
onward and from
the current source.
If you are on 1.47 or later, drop the placeholder. If you are pinned below it and running Chromium on Windows, keep it — the cost is one line:
const browser = await chromium.launch({ proxy: { server: 'http://per-context' } })
The bypass field is a comma-separated string of domains, not an array and
not a regex list. It is worth setting even when you think you have nothing to
exclude: any localhost service your test harness talks to, any internal
metrics endpoint, and any authentication service you host yourself should not
be paying per-gigabyte proxy rates or exiting from a foreign country. A
bypass entry keeps that traffic on the local interface where it belongs.
One browser with many contexts is the right unit for this, rather than one browser per identity. A browser process carries the cost of a full Chromium launch — process spawn, profile initialization, GPU and network service startup — while a context is a much smaller allocation inside a process that is already running. At a pool size of eight you are choosing between eight Chromium processes and one, and the difference shows up in memory before it shows up in speed. Reach for a second browser process only when you need a different browser build, a different launch argument, or true process-level crash isolation.
This is the structural difference from Selenium, where the proxy is a Chromium launch argument and therefore a property of the process. There, a pool of identities costs a pool of browsers. If you are porting or choosing between the two, the Selenium equivalent sets out what that costs and how authentication has to be handled instead.
A proxy that connects is not a proxy that is routing where you asked. An unrecognized targeting parameter is usually ignored rather than rejected, so the first request from a new context should be an assertion rather than a job:
async function readExitIp(context) {
const page = await context.newPage()
try {
await page.goto('https://api.ipify.org/?format=json', { waitUntil: 'domcontentloaded' })
const { ip } = JSON.parse(await page.locator('pre').innerText())
if (!ip) throw new Error('NO_EXIT_IP')
return ip
} finally {
await page.close()
}
}
// at spawn time, inside ContextPool#spawn:
const exitIp = await readExitIp(context)
if (seenExits.has(exitIp)) throw new Error(`DUPLICATE_EXIT_${exitIp}_ON_PORT_${port}`)
seenExits.add(exitIp)
Run it once per context at spawn time, record the address alongside the port, and fail the slot rather than the job if it does not match what you asked for. Two contexts reporting the same exit address means two contexts sharing a session slot — a port allocator bug, and one that silently destroys the isolation the whole design exists to provide. The targeting reference covers which filters are included in the base rate and which bill at 2×.
The design: a fixed number of contexts, each holding one upstream session slot, handed out to jobs and recycled when a job fails. Recycling means closing the context and opening a replacement on a different port — because on a port-range gateway the port is the session identity, so reconnecting to the same port keeps the same exit.
Each port in the 10000-20000 range is one session slot. Two contexts on the
same port share an exit, which is a bug in a rotation pool and a feature in a
worker that wants continuity. Which of the two you want is the
sticky sessions and ports decision,
made once per job rather than once per context. The
port access reference has the range
and the ports open by default.
import { chromium } from 'playwright'
const GATEWAY = 'gw.anonedge.com'
const AUTH = { username: process.env.PROXY_USER, password: process.env.PROXY_PASS }
// Each port in 10000-20000 is one session slot; a fresh identity needs a fresh port.
function portAllocator(start = 10000, end = 20000) {
let next = start
return () => {
if (next > end) next = start
return next++
}
}
class ContextPool {
constructor(browser, size) {
this.browser = browser
this.size = size
this.nextPort = portAllocator()
this.idle = []
this.live = new Set()
}
async #spawn() {
const port = this.nextPort()
const context = await this.browser.newContext({
proxy: { server: `http://${GATEWAY}:${port}`, ...AUTH }
})
const slot = { port, context }
this.live.add(slot)
return slot
}
async init() {
for (let i = 0; i < this.size; i++) this.idle.push(await this.#spawn())
}
acquire() {
const slot = this.idle.pop()
if (!slot) throw new Error('CONTEXT_POOL_EXHAUSTED')
return slot
}
async release(slot, { recycle = false } = {}) {
if (!recycle) return void this.idle.push(slot)
this.live.delete(slot)
await slot.context.close()
this.idle.push(await this.#spawn())
}
async close() {
for (const slot of this.live) await slot.context.close()
this.live.clear()
this.idle.length = 0
}
}
Checkout, failure detection and recycle:
const browser = await chromium.launch()
const pool = new ContextPool(browser, 8)
await pool.init()
async function fetchOne(url) {
const slot = pool.acquire()
const page = await slot.context.newPage()
let recycle = false
try {
const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 })
const status = res?.status() ?? 0
if (status === 403 || status === 429 || status === 0) recycle = true
return { status, html: await page.content(), port: slot.port }
} catch (err) {
recycle = true
throw err
} finally {
await page.close()
await pool.release(slot, { recycle })
}
}
Recycle on evidence, not on a clock. A context that is getting 200s is a
context whose exit address currently works against this target; retiring it
on a timer throws away a working identity and buys a random one. Recycle on
403, on 429, on a navigation timeout, on a challenge page you can
fingerprint — and leave everything else alone.
A BrowserContext is the isolation boundary. Cookies, localStorage,
sessionStorage, IndexedDB, HTTP cache and permissions all live inside it, and
none of them are shared with a sibling context. That is why the rule the
account-management world states as gospel — one account, one profile, one
proxy route — maps exactly onto one context per identity.
Break the mapping and specific things leak, in this order of how quickly they get you caught:
ETag and If-None-Match
behavior, which is a correlation signal across what are supposed to be
different visitors.Persist and restore state per identity rather than per run, keeping the same session slot:
const state = await slot.context.storageState() // save
const restored = await browser.newContext({ // restore, same port
proxy: { server: `http://${GATEWAY}:${slot.port}`, ...AUTH },
storageState: state
})
If you restore a storage state onto a different exit address, you have recreated the cookie-replay problem deliberately. Store the port alongside the state.
Browser automation on per-gigabyte billing pays for every byte the page pulls, including the ones you throw away. Images, fonts, video and third-party analytics beacons are usually the largest share of a page load and usually contribute nothing to what you extract. Before you tune this, settle the prior question — whether you need a browser at all — because the largest saving available here is not fetching the page in a browser in the first place.
const BLOCKED = new Set(['image', 'media', 'font'])
const BLOCKED_HOSTS = [/doubleclick\.net/, /googletagmanager\.com/, /hotjar\.com/]
await context.route('**/*', (route) => {
const req = route.request()
if (BLOCKED.has(req.resourceType())) return route.abort()
if (BLOCKED_HOSTS.some((re) => re.test(req.url()))) return route.abort()
return route.continue()
})
Register the route on the context, not the page, so it covers every page and popup the context opens.
Two cautions. Blocking stylesheet breaks layout-dependent selectors and any
visibility check, so leave CSS alone unless you have verified your selectors
survive without it. And some targets detect missing subresource requests as a
bot signal, so treat aggressive blocking as a variable to measure rather than
a setting to copy.
Measure the saving on your own targets before you claim one. Run the same
sample twice — once with routing on, once off — and compare total response
bytes from page.on('response') plus response.body() sizes, or from a HAR
capture. Do not lift a percentage from an article, including this one: the
answer is a property of your target pages, not of Playwright. The rest of the
levers, in priority order, are in
cutting browser bandwidth.
Two ways to pass credentials, and only one of them keeps the password out of a string that other things format:
// Preferred: discrete fields
proxy: { server: 'http://gw.anonedge.com:10000', username: 'YOUR_LOGIN', password: 'YOUR_PASSWORD' }
// Avoid: credentials inside the server string
proxy: { server: 'http://YOUR_LOGIN:[email protected]:10000' }
The second form puts the secret into a value that appears in error messages, in anything that echoes context options, and in whatever your log shipper does with an exception. Use the discrete fields and read them from the environment.
Traces and HAR files deserve the same care regardless of which form you use. Both capture request and response detail from inside the browser, both are routinely attached to CI artifacts and bug reports, and neither is worth auditing field by field before you decide how to store it. Treat a trace as a credential-bearing artifact: keep it out of public buckets, expire it, and do not attach it to an issue tracker anyone can read.
Proxy failures do not arrive as a clean exception with a status code. They arrive as navigation errors, and the string is the diagnosis:
| What you see | What it means |
|---|---|
net::ERR_TUNNEL_CONNECTION_FAILED |
The CONNECT to the gateway failed. Wrong port, or a blocked destination port |
net::ERR_PROXY_CONNECTION_FAILED |
The gateway itself was unreachable |
net::ERR_NO_SUPPORTED_PROXIES |
Usually a socks5:// server with credentials Playwright will not send |
TimeoutError: page.goto ... exceeded |
The tunnel came up and nothing came back. A dead exit, or a target that is stalling you |
net::ERR_EMPTY_RESPONSE |
The exit was cut mid-response |
A 407 rendered as a page |
Credentials never reached the gateway |
Recycle the context on all of them except the last, which is a configuration bug that recycling will not fix. Do the recycle without losing the job: pull the URL from a queue, acknowledge it only on success, and let the failed item return to the queue for a different slot to pick up. A pool that drops work on recycle turns a transient exit failure into missing data you will not notice.
Pool size is not a free parameter. A single page load opens many concurrent connections — the browser parallelizes subresource fetches — and every one of them is a connection through the gateway. Eight contexts each loading a media-heavy page is not eight connections; it is easily an order of magnitude more.
That is where 407 THREADS_EXHAUSTED comes from, and it looks exactly like an
authentication failure to any code that only reads the status. It appears only
under load, which is why it survives staging and lands in production. Size the
pool against your plan's concurrent connection limit rather than against your
CPU count — concurrency and your thread limit
covers how to arrive at that number — and note that route.abort() on images
and media cuts connection count as well as bytes. The
threads and concurrency reference has the
error and how limits are raised.
Both do this; the API shapes differ enough to break a port.
| Playwright 1.62.1 | Puppeteer 25.5.0 | |
|---|---|---|
| Per-context proxy | browser.newContext({ proxy }) |
browser.createBrowserContext({ proxyServer }) |
| Bypass list | proxy.bypass — comma-separated string |
proxyBypassList — array of strings |
| Credentials | proxy.username / proxy.password on the context |
page.authenticate({ username, password }), per page |
| SOCKS5 | socks5:// server; auth undocumented |
--proxy-server=socks5://... at launch |
| Interception | context.route() / page.route(), route.abort() |
page.setRequestInterception(true), request.abort() |
The difference that bites during a port is credentials. Playwright attaches
them to the context, so a context is a complete identity. Puppeteer attaches
them to the page via page.authenticate, so every new page in a context has
to be authenticated again — and forgetting one page in a multi-page flow
produces a 407 on exactly one navigation.
No. The proxy option is read when the context is constructed and there is no
method on BrowserContext that changes it. Rotating an exit address means
closing the context and creating a new one, or relaunching the browser. This
is why the standard pattern is a pool of contexts with a lifecycle rather than
a single context with a mutable setting.
Not in a documented way. The proxy.server field accepts socks5:// URLs,
but the username and password fields are described as applying "if HTTP
proxy requires authentication." The feature request for SOCKS5 authentication
has been open since November 2021. Use the HTTP or HTTPS port from Playwright,
or terminate SOCKS5 in a local forwarding proxy and point the browser at
localhost.
Not on current versions. The note requiring a global proxy at launch was
scoped to Chromium on Windows and was present in Playwright's documentation
source through release-1.46; it is absent from release-1.47 onward. If you are
pinned below 1.47 and running Chromium on Windows, keep a placeholder proxy in
chromium.launch(). Otherwise drop it.
Size the pool against your plan's concurrent connection limit, not your CPU
count. One page load opens many parallel connections through the gateway, so a
pool of eight contexts can consume far more than eight threads. Exceeding the
limit surfaces as 407 THREADS_EXHAUSTED under load only. Blocking images,
media and fonts reduces connection count as well as bytes.
Yes, for everything the browser stores: cookies, localStorage, sessionStorage, IndexedDB, HTTP cache, permissions and service workers are all per context and are not shared with siblings. What it does not isolate is anything above the browser — reused storage state restored onto a different exit address, or two identities sharing one sticky port, will correlate regardless of how many contexts you opened.
Build the pool against real ports before you tune anything else.
Start routing on a pay-as-you-go account, take
the login from
the quickstart, and point eight
contexts at eight ports in the 10000-20000 range. Billing is per gigabyte,
which is why route.abort() belongs in the first version and not the third —
check the per-GB rates.