Playwright Proxy Setup for Web Scraping (Python + Node)
Configure a Playwright proxy in Python and Node: per-context proxies, rotation, residential IPs, and authentication. Real code plus when a scraping API wins.

Configuring a Playwright proxy looks trivial: pass a proxy object to launch() and your traffic exits through another IP. The part most guides skip is that Playwright can attach a different proxy to every browser context inside a single process, which is the cleanest proxy rotation model of any browser automation tool. This guide covers proxy setup in Python and Node, authentication, per-context rotation, residential IPs, stealth, block handling, and the point where a scraping API does the job better than raw Playwright.
Why use a proxy with Playwright
Playwright drives a real Chromium, Firefox, or WebKit browser, so every request the page makes travels the full browser network stack: the initial navigation, XHR and fetch calls, images, fonts, and WebSocket traffic. A proxy sits in front of that stack and swaps your real IP for the proxy's exit IP. For web scraping with Playwright, that matters in a few concrete situations.
| Scenario | Why a proxy is needed |
|---|---|
| Rate limiting on a single target | Spread requests across many exit IPs so no one IP trips the limit |
| Geo-restricted content or pricing | Present a specific country's IP to see localized data |
| Anti-bot IP reputation checks | Route through residential IPs that look like real users |
| Parallel account sessions | Bind a stable IP to each account to avoid association |
| Ad or SERP verification | Verify how a page renders from a target region |
The distinction from an HTTP library like httpx or requests is scope. A library proxy only affects the calls your script makes directly. A Playwright proxy affects the entire browser, including the JavaScript-initiated API calls that libraries never see. That is exactly why teams reach for a browser when a target is JavaScript-heavy or aggressively fingerprinted.
How Playwright handles proxies: launch vs context
Playwright exposes the proxy through a single proxy object with four fields. This is the same shape in Python and Node.
| Field | Required | Purpose |
|---|---|---|
| `server` | yes | Proxy URL with scheme, e.g. `http://host:port` or `socks5://host:port` |
| `username` | no | Auth username for HTTP(S) proxies |
| `password` | no | Auth password for HTTP(S) proxies |
| `bypass` | no | Comma-separated hosts that skip the proxy |
You can attach that object at one of two levels, and the choice is the single most important design decision in a Playwright scraper:
- Launch level (
chromium.launch({ proxy })) sets one proxy for the whole browser process. Every context and page uses it. - Context level (
browser.newContext({ proxy })) sets a proxy for that context only. Each context is an isolated session with its own cookies, cache, and storage, but all contexts share the same browser process.
That second option is Playwright's superpower and the reason it beats other tools for rotation. In Selenium you create a whole new WebDriver session per proxy, and in Puppeteer you typically launch a new browser per proxy. Playwright lets you run dozens of contexts, each on a different IP, inside one browser. That is far lighter on memory and startup time. If you have used those other tools, compare the approaches in our guides on proxies with Puppeteer and proxies with Selenium.
A single Chromium process running 20 contexts on 20 different proxies uses a fraction of the RAM of 20 separate browsers. On a scraping box with limited memory, per-context proxies are often the difference between 4 concurrent workers and 40.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Playwright proxy in Python
Install Playwright and the browser binaries first:
pip install playwright
playwright install chromium
The launch-level proxy is the simplest setup. This works with IP-whitelisted proxies, where your server's IP is authorized in the dashboard so no credentials are needed.
from playwright.sync_api import sync_playwright
def scrape_ip(proxy_server: str):
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
proxy={"server": proxy_server},
)
page = browser.new_page()
page.goto("https://httpbin.org/ip", timeout=30000)
print(page.inner_text("pre"))
browser.close()
scrape_ip("http://dc.sparkproxy.io:10000")
The server value must include a scheme. Playwright will not guess it. Use http:// for HTTP and HTTPS proxies (a single HTTP proxy tunnels HTTPS via CONNECT) and socks5:// for SOCKS. The bypass field takes a comma-separated list:
browser = p.chromium.launch(
proxy={
"server": "http://dc.sparkproxy.io:10000",
"bypass": "localhost,127.0.0.1,*.internal.sparkproxy.io",
}
)
For the async API, the shape is identical, only the calls are awaited:
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(
proxy={"server": "http://dc.sparkproxy.io:10000"}
)
page = await browser.new_page()
await page.goto("https://httpbin.org/ip")
print(await page.inner_text("pre"))
await browser.close()
asyncio.run(main())
Playwright proxy in Node.js
Install the Node package and browsers:
npm install playwright
npx playwright install chromium
The Node API mirrors Python one to one. Same proxy object, same fields.
const { chromium } = require("playwright");
(async () => {
const browser = await chromium.launch({
headless: true,
proxy: { server: "http://dc.sparkproxy.io:10000" },
});
const page = await browser.newPage();
await page.goto("https://httpbin.org/ip", { timeout: 30000 });
console.log(await page.innerText("pre"));
await browser.close();
})();
To confirm the proxy is actually routing, hit an IP echo endpoint and check the result is the proxy IP and not your own. If the response shows your real IP, the proxy was never applied, usually because the proxy object was attached to the wrong call or the scheme was missing from server.
Playwright proxy authentication
Most commercial proxies use username and password auth. This is where Playwright is genuinely nicer than the alternatives. Unlike Chrome under Selenium, which silently ignores credentials in the --proxy-server flag, Playwright accepts credentials directly in the proxy object and handles the auth challenge for you. No browser extension, no request interception.
Python:
browser = p.chromium.launch(
proxy={
"server": "http://dc.sparkproxy.io:10000",
"username": "sp_user",
"password": "sp_pass",
}
)
Node:
const browser = await chromium.launch({
proxy: {
server: "http://dc.sparkproxy.io:10000",
username: "sp_user",
password: "sp_pass",
},
});
Two limitations are worth committing to memory, because both cause confusing failures:
- SOCKS5 auth is not supported. Playwright can talk to a SOCKSv5 proxy, but it cannot pass a username and password to one. If you need authenticated SOCKS, whitelist your IP instead and connect without credentials, or use an HTTP proxy. Passing
username/passwordalongside asocks5://server is silently dropped. - Per-context credentials work, but with a catch on Chromium. You can put different credentials on each context (covered next). Early Playwright releases required launching Chromium with a placeholder proxy before per-context proxies would activate. Current releases accept a proxy directly on the context, so if per-context proxies mysteriously fail, upgrade Playwright first.
If your provider supports it, IP whitelisting sidesteps auth entirely. You add your scraping server's IP in the SparkProxy dashboard, then connect with just server and no credentials. It is the cleanest option for a fixed-IP box.
Playwright proxy rotation with contexts
This is the payoff. Playwright proxy rotation is built into the context model, so you rotate IPs by opening a new context, not by relaunching the browser.
Python, one browser and many proxies:
from playwright.sync_api import sync_playwright
PROXIES = [
{"server": "http://dc.sparkproxy.io:10000", "username": "sp_user", "password": "sp_pass"},
{"server": "http://dc.sparkproxy.io:10001", "username": "sp_user", "password": "sp_pass"},
{"server": "http://dc.sparkproxy.io:10002", "username": "sp_user", "password": "sp_pass"},
]
TARGETS = [
"https://httpbin.org/ip",
"https://httpbin.org/headers",
"https://httpbin.org/user-agent",
]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
for url, proxy in zip(TARGETS, PROXIES):
context = browser.new_context(proxy=proxy) # fresh IP + fresh session
page = context.new_page()
page.goto(url, timeout=30000)
print(f"[{proxy['server']}] {page.inner_text('body')[:80]}")
context.close() # free the session, keep the browser
browser.close()
Each context is a clean slate. New cookies, new local storage, new IP. That isolation is what makes contexts a better rotation unit than pages: two pages in the same context share cookies and can leak session state between targets, while two contexts never do.
For high concurrency, run the contexts in parallel rather than in a loop. In Node with the async API this is natural:
const { chromium } = require("playwright");
const proxies = [
{ server: "http://dc.sparkproxy.io:10000", username: "sp_user", password: "sp_pass" },
{ server: "http://dc.sparkproxy.io:10001", username: "sp_user", password: "sp_pass" },
{ server: "http://dc.sparkproxy.io:10002", username: "sp_user", password: "sp_pass" },
];
(async () => {
const browser = await chromium.launch({ headless: true });
const results = await Promise.all(
proxies.map(async (proxy, i) => {
const context = await browser.newContext({ proxy });
const page = await context.newPage();
await page.goto("https://httpbin.org/ip");
const body = await page.innerText("pre");
await context.close();
return `worker ${i}: ${body.replace(/\s+/g, " ").trim()}`;
})
);
console.log(results.join("\n"));
await browser.close();
})();
If your provider gives you a single rotating gateway endpoint instead of a list of ports, you often do not need to manage a list at all. The gateway hands out a new IP per connection, so a fresh context is a fresh IP automatically. For the request-level rotation patterns behind that, see how to rotate proxies in Node.js.
Using a Playwright residential proxy
Datacenter proxies are fast and cheap, and they are the right default for tolerant targets. When a site scores IP reputation aggressively, a Playwright residential proxy routes traffic through real consumer IPs that carry far more trust. The Playwright config does not change. Only the endpoint and the session encoding do.
Residential gateways usually encode routing options (country, sticky session) inside the username. A sticky session keeps the same exit IP for the session's lifetime, which matters for multi-step flows like login then scrape.
# Residential proxy with a US exit IP, held sticky for the session
proxy = {
"server": "http://residential.sparkproxy.io:8000",
"username": "sp_user-country-us-session-a1b2c3",
"password": "sp_pass",
}
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
proxy=proxy,
locale="en-US",
timezone_id="America/New_York", # match the IP's geography
)
page = context.new_page()
page.goto("https://www.sparkproxy.io/", timeout=45000)
print(page.title())
browser.close()
One detail teams miss: match the browser's locale and timezone_id to the proxy's geography. A US residential IP paired with a browser reporting Europe/Berlin is an obvious mismatch that anti-bot systems flag. Residential IPs cost more per request, so reserve them for the targets that actually need them and keep datacenter proxies for the rest. If you are weighing running this proxy stack yourself against handing it off, our breakdown of a scraping API vs self-managed proxies covers the cost math.
Stealth and fingerprint considerations
A proxy hides your IP. It does nothing about your browser fingerprint, and modern anti-bot systems read both. Vanilla Playwright leaks a few obvious automation signals: navigator.webdriver is true, the headless Chromium user agent contains HeadlessChrome, and the fingerprint is unusually clean.
Cover the cheap wins directly:
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
proxy: { server: "http://dc.sparkproxy.io:10000" },
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
viewport: { width: 1366, height: 768 },
locale: "en-US",
});
For harder targets, the community standard is playwright-extra with the stealth plugin, which patches the well-known detection vectors in one step:
const { chromium } = require("playwright-extra");
const stealth = require("puppeteer-extra-plugin-stealth")();
chromium.use(stealth);
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
proxy: { server: "http://dc.sparkproxy.io:10000" },
});
const page = await context.newPage();
await page.goto("https://www.sparkproxy.io/");
await browser.close();
})();
Be realistic about limits. Stealth plugins win against basic fingerprinting, but they are locked in a cat and mouse game with commercial anti-bot vendors, and a patched flag today can be detected again next month. If you find yourself maintaining a growing pile of fingerprint patches just to keep one target working, that is the signal you have outgrown DIY Playwright. More on that below.
Handling blocks and retries
Even with good proxies, some requests get blocked. A production Playwright scraper treats a block as a normal event and retries on a fresh context and IP rather than crashing. The pattern: detect the block, close the poisoned context, open a new one on a different proxy, retry with backoff.
import random
import time
from playwright.sync_api import sync_playwright
PROXIES = [
{"server": "http://dc.sparkproxy.io:10000", "username": "sp_user", "password": "sp_pass"},
{"server": "http://dc.sparkproxy.io:10001", "username": "sp_user", "password": "sp_pass"},
{"server": "http://dc.sparkproxy.io:10002", "username": "sp_user", "password": "sp_pass"},
]
BLOCK_MARKERS = ("captcha", "access denied", "unusual traffic", "are you a robot")
def looks_blocked(status: int, html: str) -> bool:
if status in (403, 429, 503):
return True
low = html.lower()
return any(m in low for m in BLOCK_MARKERS)
def fetch_with_retry(browser, url: str, attempts: int = 4) -> str | None:
for attempt in range(1, attempts + 1):
proxy = random.choice(PROXIES)
context = browser.new_context(proxy=proxy)
page = context.new_page()
try:
resp = page.goto(url, timeout=30000, wait_until="domcontentloaded")
status = resp.status if resp else 0
html = page.content()
if not looks_blocked(status, html):
return html
print(f"attempt {attempt}: blocked ({status}) via {proxy['server']}")
except Exception as e:
print(f"attempt {attempt}: error {e}")
finally:
context.close()
time.sleep(2 ** attempt) # exponential backoff: 2s, 4s, 8s, 16s
return None
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
html = fetch_with_retry(browser, "https://www.sparkproxy.io/pricing")
print("OK" if html else "gave up after retries")
browser.close()
The backoff matters. Hammering a target that just blocked you gets the whole proxy IP flagged faster. For the full checklist of what triggers blocks and how to reduce your rate, read how to avoid getting your proxy blocked.
When to use SparkProxy's Scraping API instead
Playwright plus proxies is the right tool when you need real browser behavior and full control over the page. It stops being the right tool when you are spending more time maintaining fingerprints, solving CAPTCHAs, and babysitting headless Chromium than you are extracting data. At that point a scraping API is cheaper in engineering time, even if it costs more per request.
The SparkProxy Scraping API runs the headless browser, proxy selection, rotation, stealth, and retries for you behind one endpoint. You send a URL, you get HTML or structured data back. It renders JavaScript with render_js, routes through residential IPs with premium_proxy, applies anti-detection with stealth, and geo-targets with country_code.
Python:
import requests
resp = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY"},
params={
"url": "https://www.sparkproxy.io/pricing",
"render_js": "true", # headless Chromium rendering
"premium_proxy": "true", # residential IP tier
"country_code": "US",
"stealth": "true",
"wait_for": ".price-table", # wait for a selector before returning
},
timeout=90,
)
print(resp.status_code, len(resp.text))
Node:
const params = new URLSearchParams({
url: "https://www.sparkproxy.io/pricing",
render_js: "true",
premium_proxy: "true",
country_code: "US",
stealth: "true",
wait_for: ".price-table",
});
const res = await fetch(`https://scrape.sparkproxy.io/api/v1?${params}`, {
headers: { "X-API-Key": "YOUR_API_KEY" },
});
console.log(res.status, (await res.text()).length);
The API also accepts Playwright-style browser actions through a js_scenario object, so flows you would script in Playwright (click a cookie banner, fill a search box, wait for results) move over without losing capability:
scenario = {
"instructions": [
{"click": "#cookie-accept"},
{"fill": {"selector": "#search", "value": "residential proxies"}},
{"wait_for": ".results"},
]
}
resp = requests.post(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY", "Content-Type": "application/json"},
json={"url": "https://www.sparkproxy.io/", "js_scenario": scenario, "render_js": True},
)
Credits scale with what you ask for: a rotating proxy without JS is 1 credit, with JS rendering it is 5, and residential plus JS is 25, with add-ons like stealth or country_code costing a few credits each. That pricing makes the decision quantitative. If your Playwright setup is cheap to run and rarely blocked, keep it. If you are burning engineering hours on the anti-bot arms race, the API is the better spend. We break the full economics down in web scraping API vs self-managed proxies.
Common Playwright proxy errors and fixes
| Error / symptom | Cause | Fix |
|---|---|---|
| Target shows your real IP, not the proxy | `proxy` attached to wrong call or missing scheme in `server` | Verify `server` starts with `http://` or `socks5://`; confirm the object is on `launch()` or `newContext()` |
| `net::ERR_PROXY_CONNECTION_FAILED` | Proxy host unreachable or port blocked | Test with `curl -x http://host:port https://httpbin.org/ip`; check firewall and port |
| `net::ERR_TUNNEL_CONNECTION_FAILED` | Auth failed or HTTPS CONNECT rejected | Check username/password; confirm the proxy allows HTTPS tunneling |
| `407 Proxy Authentication Required` | Credentials missing or IP not whitelisted | Add `username`/`password` to the proxy object, or whitelist your server IP |
| SOCKS5 auth silently ignored | Playwright does not support authenticated SOCKS5 | Use IP whitelisting on SOCKS, or switch to an HTTP proxy for credential auth |
| Per-context proxy has no effect | Old Playwright version, or proxy set on page not context | Upgrade Playwright; attach `proxy` to `newContext()`, not `newPage()` |
| Frequent 403 / CAPTCHA on residential IPs | Fingerprint mismatch (locale, timezone, user agent) | Match `locale` and `timezone_id` to the IP's country; add stealth patches |
| `Timeout 30000ms exceeded` on `goto` | Slow proxy or heavy page | Raise the timeout, use `wait_until="domcontentloaded"`, or switch to a faster proxy tier |
Frequently asked questions
FAQ
Pass a proxy object with a server field to either browser.launch() for a global proxy or browser.newContext() for a per-context proxy. The server value must include a scheme, for example http://host:port or socks5://host:port. The API is identical in Python and Node.
Yes, and this is Playwright's biggest advantage for rotation. You create a new browser context with a different proxy object instead of relaunching the browser. Each context has its own IP, cookies, and storage while sharing one browser process, so you can run many proxies concurrently with far less memory than Selenium or Puppeteer need.
Yes, for HTTP and HTTPS proxies. Put username and password in the proxy object and Playwright handles the auth challenge automatically, with no extension or request interception. The exception is SOCKS5: Playwright can connect to a SOCKSv5 proxy but cannot pass credentials to it, so use IP whitelisting for authenticated SOCKS.
Use residential proxies only for targets that score IP reputation aggressively, since they cost more per request than datacenter proxies. The Playwright config is the same, you just point server at the residential gateway and encode the country and sticky session in the username. Match the browser locale and timezone_id to the IP's geography to avoid an obvious mismatch.
Yes, Playwright supports SOCKSv5. Set the server to socks5://host:port. The important limitation is that authenticated SOCKS5 is not supported, so a SOCKS proxy must accept your connection by IP whitelisting rather than username and password. For credential-based auth, use an HTTP proxy instead.
Playwright with good proxies and a stealth plugin handles most targets. When you are constantly maintaining fingerprint patches, solving CAPTCHAs, and tuning retries just to keep one site working, a scraping API like SparkProxy's is usually cheaper in engineering time because it manages the browser, proxies, and anti-bot layer for you behind one endpoint.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon โ claim it before it's gone
Related articles

How to Scrape Airbnb Listings and Prices
Learn how to scrape Airbnb listings and prices: extract fields from Airbnb's embedded JSON, handle date-based pricing, map pagination, and anti-bot defenses.

How to Scrape GraphQL APIs
Learn to scrape GraphQL API data: find the /graphql endpoint, read the query and variables in DevTools, then replay your own queries with cursor pagination.

How to Bypass reCAPTCHA When Web Scraping
How to bypass reCAPTCHA when web scraping the ethical way: how v2 and v3 scoring work, how to raise your reCAPTCHA score, and solvers as a last resort.
