Playwright proxy per browser context rotation

    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.

    Two constraints that shape every Playwright proxy design

    A context's proxy is fixed at creation

    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.

    Playwright documents SOCKS5 servers, not SOCKS5 authentication

    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.

    Browser-level versus context-level proxy

    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.

    Verify each context's exit before you trust it

    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×.

    Playwright proxy per browser context rotation: the pool pattern

    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.

    Worked example

    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.

    Identity isolation: one context, one storage state, one route

    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:

    • Cookies. A session cookie issued to one exit address and replayed from another is the most common single reason a flow that works manually fails through a proxy.
    • localStorage and IndexedDB. Sites store device and session identifiers here. Two identities sharing a context share those identifiers permanently, not just for the session.
    • HTTP cache. Shared cache means shared ETag and If-None-Match behavior, which is a correlation signal across what are supposed to be different visitors.
    • Service workers. Registered per context, and they persist state of their own.

    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.

    Cutting the bandwidth bill

    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.

    Authentication, and what ends up in logs

    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.

    What a dead exit looks like from inside Playwright

    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.

    Concurrency: contexts, pages and your thread limit

    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.

    Puppeteer differences, in one table

    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.

    Frequently asked questions

    Can I change a Playwright context's proxy after it is created?

    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.

    Does Playwright support authenticated SOCKS5 proxies?

    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.

    Do I still need a proxy at browser launch for per-context proxies?

    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.

    How many browser contexts should I run per proxy account?

    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.

    Does one context per proxy actually isolate identity?

    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.

    Start routing

    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.