Wiring a residential proxy into Playwright is a few lines once you’ve seen it done. The friction is never the launch option, it’s the details nobody writes down: how the credentials encode targeting, the Chromium gotcha that breaks per-context proxies, how rotation maps onto browser contexts, and the fact that every image the browser loads is bandwidth you pay for.
This is the companion to how to use residential proxies with Python, for the browser side. Copy-paste examples that actually run, plus the parts that turn a working snippet into automation that survives production.
Everything below uses the Shifter residential gateway: one endpoint, p.shifter.io:443, with all targeting encoded in the username. If you’re on a different provider the shape is the same; swap the host and credential format. Examples are Node/JavaScript; a Python Playwright snippet is near the end.
The one thing you have to understand first
On a residential gateway, the proxy username carries your auth and your targeting. You don’t change endpoints to switch country or session, you change the username string. A username looks like this:
customer-USERNAME-country-us-sid-abc123-ttl-600Read it left to right: account id, then flags. country-us targets the US. sid-abc123 pins a sticky session. ttl-600 holds that IP for 600 seconds. Drop sid/ttl and every new connection rotates to a new IP. The password is constant. That’s the whole mental model, the rest is plugging this into Playwright’s proxy slot.
Keep credentials in environment variables, never hardcoded:
const USER = process.env.SHIFTER_USER; // your account usernameconst PASS = process.env.SHIFTER_PASS;const GATEWAY = "http://p.shifter.io:443";One Playwright-specific note up front: do not put the username and password inline in the proxy URL. Chromium ignores inline proxy credentials, so http://user:pass@host silently fails to authenticate. Playwright has dedicated username and password fields on the proxy object, use those.
The 10-line version
For a single proxy across the whole browser, set proxy on launch():
const { chromium } = require("playwright");
const USER = process.env.SHIFTER_USER;const PASS = process.env.SHIFTER_PASS;
(async () => { const browser = await chromium.launch({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-us`, // targeting lives here password: PASS, }, }); const page = await browser.newPage(); await page.goto("https://api.ipify.org?format=json"); console.log(await page.textContent("body")); // {"ip":"<a US residential IP>"} await browser.close();})();The username field is where the whole gateway model lives. ${USER}-country-us targets the US; add sid/ttl for a sticky IP, add city/asn to narrow further. The password never changes.
The Chromium gotcha: per-context proxies
The single-proxy version is fine for one identity. But the real reason to use Playwright is running many identities, and for that you want a different proxy per browser context (each context is its own isolated cookies/storage, i.e. its own “user”).
Here’s the trap: in Chromium, per-context proxies only work if you launch the browser with a proxy already set. Launch with no proxy and every newContext({ proxy }) is silently ignored. The fix is a placeholder server at launch:
const browser = await chromium.launch({ proxy: { server: "per-context" }, // placeholder, enables per-context proxying});
// Now each context can carry its own real proxy + targetingconst ctxUS = await browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-us-sid-a1-ttl-600`, password: PASS, },});const ctxDE = await browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-de-sid-b2-ttl-600`, password: PASS, },});ctxUS browses on a US IP, ctxDE on a German one, at the same time, in one browser. This is the pattern to build on. (Firefox and WebKit don’t strictly need the placeholder, but setting it is harmless and keeps your code cross-browser.)
Rotating vs sticky, the Playwright way
In a raw HTTP scraper you often rotate on every request. In a browser, that’s usually wrong: a real user doesn’t change IP mid-session, so an IP that flips between page loads while cookies stay constant is a detection signal. The idiomatic mapping is:
- One context = one sticky identity. Give each context a unique
sidand attlthat covers the session, so all its page loads share one IP, like a real user. - Rotate by creating new contexts. A fresh context with a new
sid(or nosid) gets a new IP. Close the old one when you’re done.
async function contextForSession(browser, sessionId, country = "us") { return browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-${country}-sid-${sessionId}-ttl-600`, password: PASS, }, });}
// Process 50 targets, each with its own IP-stable identityfor (let i = 0; i < 50; i++) { const ctx = await contextForSession(browser, `job-${i}`, "us"); const page = await ctx.newPage(); await page.goto("https://example.com/item/" + i); // ... scrape ... await ctx.close(); // done with this identity}Pick the sid yourself, any unique string per logical session. The same string returns the same IP until the TTL expires. Run contexts concurrently (a small pool) rather than 50 at once; more browsers isn’t more speed, and a burst of IPs hammering one target still trips behavioral detection. If you’re getting blocked consistently rather than intermittently, the problem is IP quality or behavior, not rotation, see how to avoid getting blocked when scraping.
Geo-targeting, and matching the browser to the IP
Because targeting lives in the username, geo is just a flag: country-de, add city-berlin or asn-3320 to narrow. But with a real browser there’s a second half people miss: your browser fingerprint has to agree with your IP. A German residential IP paired with an en-US locale and a New York timezone is a contradiction that anti-bot systems flag on sight. (See why scrapers get blocked.)
Playwright lets you align all of it on the context:
const ctx = await browser.newContext({ proxy: { server: "http://p.shifter.io:443", username: `${USER}-country-de-city-berlin-sid-de1-ttl-600`, password: PASS, }, locale: "de-DE", timezoneId: "Europe/Berlin", geolocation: { latitude: 52.52, longitude: 13.405 }, permissions: ["geolocation"],});City names use the gateway’s format (lowercase); combine flags freely, order doesn’t matter to the gateway. Matching locale/timezoneId/geolocation to the proxy country is one of the highest-leverage things you can do to look like a genuine local user.
Cut your bandwidth (you pay per GB)
This one is specific to browsers and it matters, because residential proxies are billed by the gigabyte. A headless browser, left alone, downloads every image, font, video, and tracking script on the page, most of which you don’t need for scraping. Aborting them can cut bandwidth (and cost) by well over half, and speeds up your runs.
Use context.route() to block heavy resource types before they hit the proxy:
await ctx.route("**/*", (route) => { const type = route.request().resourceType(); if (["image", "media", "font"].includes(type)) { return route.abort(); // never downloaded, never billed } return route.continue();});Keep document, script, xhr, and fetch (the actual data and the JS that renders it); drop the rest. If the site lazy-loads content behind images, test that your scrape still works, but for most jobs this is free money. It pairs well with the per-GB model covered in bandwidth-priced residential proxies.
Verifying it actually works
Before you trust a config, confirm two things: the IP is in the right country (geo) and it changes between contexts (rotation). One quick check:
const page = await ctx.newPage();await page.goto("http://ip-api.com/json");const data = JSON.parse(await page.textContent("body"));console.log(data.query, data.countryCode); // expect a DE IPSpin up two contexts with different sids and you should see two different IPs, both the right country. If the country is wrong, check your flag spelling. If the IP never changes across contexts, you probably forgot the launch-time placeholder and Chromium is ignoring the per-context proxy.
The errors that actually happen
Playwright surfaces proxy problems as navigation errors, not HTTP status codes. The three you’ll meet:
net::ERR_TUNNEL_CONNECTION_FAILEDproxy auth failed, or an unrecognized flag (a typo incountryorasn, or inline credentials Chromium ignored). Fix the username/password fields; retrying won’t help.net::ERR_PROXY_CONNECTION_FAILEDcouldn’t reach the gateway, or no IP matched an over-tight filter (like country + city + asn). Check connectivity, then loosen a flag.- A page that loads but shows a block/CAPTCHA the target, not the proxy, is refusing you. Rotate to a new context/IP, slow down, and align the fingerprint as above.
Wrap navigation in a small retry that makes a fresh context on failure:
async function gotoWithRetry(browser, url, country = "us", tries = 3) { for (let attempt = 0; attempt < tries; attempt++) { const ctx = await contextForSession(browser, `r-${Date.now()}-${attempt}`, country); const page = await ctx.newPage(); try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); return { ctx, page }; // caller closes ctx when done } catch (e) { await ctx.close(); if (attempt === tries - 1) throw e; await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); // 1s, 2s } }}A new context means a new IP, so a retry genuinely gets a different route, not the same failing one.
A note on SOCKS5
The gateway speaks SOCKS5, but there’s a Playwright limitation worth knowing: Playwright/Chromium does not support authenticated SOCKS proxies. Since the gateway authenticates by username (that’s where your targeting lives), SOCKS5 isn’t practical here, use the HTTP proxy shown above, which is the right default for browser scraping anyway. The SOCKS tradeoffs are in SOCKS5 proxies for automation.
Same thing in Python
Playwright’s Python API mirrors the JavaScript one; the proxy object is identical:
from playwright.sync_api import sync_playwrightimport os
USER, PASS = os.environ["SHIFTER_USER"], os.environ["SHIFTER_PASS"]
with sync_playwright() as p: browser = p.chromium.launch(proxy={"server": "per-context"}) ctx = browser.new_context(proxy={ "server": "http://p.shifter.io:443", "username": f"{USER}-country-us-sid-a1-ttl-600", "password": PASS, }) page = ctx.new_page() page.goto("https://api.ipify.org") print(page.text_content("body")) browser.close()Same placeholder-at-launch rule, same username-encoded targeting. For non-browser HTTP work, the Python guide covers requests, httpx, and Scrapy.
FAQ
Why won’t http://user:pass@host work in Playwright?
Chromium ignores inline proxy credentials. Put the username and password in the dedicated username/password fields of the proxy object instead. This is the single most common reason “the proxy isn’t working” in Playwright.
Why do my per-context proxies get ignored?
Because you launched Chromium without a proxy. Per-context proxying only activates if the browser was launched with one, pass a placeholder proxy: { server: "per-context" } to launch(), then set real proxies on each context.
Should I rotate the IP on every request in a browser?
No. A browser session should hold one IP, like a real user. Use a sticky sid per context and rotate by creating new contexts, not by flipping IPs mid-session.
How do I run different countries at the same time?
One browser, multiple contexts, each with a different country-<cc> in its proxy username. They run concurrently and independently.
Does this work with Puppeteer or Selenium too?
The gateway does. Puppeteer takes --proxy-server at launch and authenticates via page.authenticate(); Selenium uses a proxy capability or an extension for auth. The username-encoded targeting is identical; only the wiring differs. Playwright’s per-context model is the cleanest for multi-identity work.
My runs are burning bandwidth fast. What do I do?
Block images, media, and fonts with context.route() as shown above. Browsers download everything by default, and on a per-GB plan that’s real money. Aborting heavy resources often cuts usage by more than half.
Wrapping up
The pattern: launch Chromium with a per-context placeholder, give each context a proxy whose username encodes country/sid/ttl, align locale/timezone/geolocation to the country, and abort images and fonts to save bandwidth. Rotate by making new contexts, not by flipping IPs. Errors show up as net:: navigation failures, and a retry that builds a fresh context gets you a fresh IP.
Start from the snippets above, point them at the residential gateway, and you’ve got production-shaped browser automation in an afternoon. Plans and per-GB rates are on the pricing page, and the full flag reference lives in the gateway docs.