Complete Playwright proxy rotation guide: configure NinjaProxy residential proxies, rotate every N requests, handle CAPTCHAs and blocks, and avoid bot detection with fingerprint rotation.

Playwright sessions get flagged faster in 2026 because anti-bot systems do not score only the IP anymore. They correlate IP reputation, browser state, navigation timing, retry behavior, locale mismatches, and whether one identity suddenly jumps between routes mid-flow.
That changes how proxy rotation should be implemented. The goal is not "rotate every request no matter what." The goal is to keep one believable identity for the duration of a flow, then rotate cleanly before the next identity starts.
This guide covers everything you need: how to configure NinjaProxy residential rotation in Playwright, how to rotate every N requests, how to handle CAPTCHAs and blocks, how to avoid detection with fingerprint rotation, and how to test your proxy integration before going to production.
Playwright applies the proxy at browser launch time. That means your proxy plan has to match the browser lifecycle.
If you try to rotate identities inside the same browser without resetting storage and session state, you create one of the easiest patterns for detection systems to score. One session token per browser launch is the rule.
NinjaProxy's rotating gateway is the cleanest fit for Playwright proxy setup because the endpoint stays fixed while routing behavior moves into the username. You do not need to maintain a proxy list or build a rotation pool yourself.
The current username-control grammar is:
<USERNAME>--session-<SESSION_ID>--duration-<SECONDS>--provider-<dc|res>--geo-country-<COUNTRY_CODE>For Playwright, this is usually better than HTTP targeting headers because the proxy settings live directly on the browser launch config and work consistently across all browser traffic, including images, scripts, and API calls made by the page.
Step 1 — Install Playwright
npm install playwright
npx playwright install chromiumStep 2 — Get your credentials
Open Portal → Rotating Gateway IPs and copy your rotating HTTP endpoint. Copy your portal username and API key from account settings.
Step 3 — Residential proxy setup
import { chromium } from "playwright"
const ROTATING_HTTP_ENDPOINT = "<ROTATING_HTTP_ENDPOINT>"
const USERNAME = "<USERNAME>"
const API_KEY = "<API_KEY>"
function buildProxyUsername({ sessionId, duration = 120, provider = "res", country = "us" }) {
return [
USERNAME,
`--session-${sessionId}`,
`--duration-${duration}`,
`--provider-${provider}`,
`--geo-country-${country}`,
].join("")
}
async function runSession(sessionId) {
const browser = await chromium.launch({
headless: true,
proxy: {
server: `http://${ROTATING_HTTP_ENDPOINT}`,
username: buildProxyUsername({ sessionId, provider: "res" }),
password: API_KEY,
},
})
const context = await browser.newContext({
locale: "en-US",
timezoneId: "America/New_York",
viewport: { width: 1440, height: 900 },
})
const page = await context.newPage()
await page.goto("https://ip.ninjasproxy.com/", {
waitUntil: "networkidle",
timeout: 30000,
})
const ip = await page.textContent("body")
console.log(`Session ${sessionId} IP:`, ip)
await context.close()
await browser.close()
return ip
}
// Each call gets a fresh residential IP
await runSession("task-001")
await runSession("task-002")Using --provider-res tells the gateway to route through a residential IP instead of a datacenter address. Residential IPs originate from real consumer ISPs, so they pass IP reputation checks on protected targets where datacenter proxies fail.
Sometimes you want rotation on a schedule — every N page loads — rather than once per browser launch. The safest way to do this without creating a mid-session identity change is to wrap the browser lifecycle and restart with a new session token when the counter hits your threshold.
import { chromium } from "playwright"
const ROTATING_HTTP_ENDPOINT = "<ROTATING_HTTP_ENDPOINT>"
const USERNAME = "<USERNAME>"
const API_KEY = "<API_KEY>"
const ROTATE_EVERY = 5 // new proxy identity every 5 pages
function buildProxyUsername(sessionId) {
return `${USERNAME}--session-${sessionId}--duration-180--provider-res--geo-country-us`
}
async function scrapeUrls(urls) {
const results = []
let browser = null
let context = null
let requestCount = 0
let sessionIndex = 0
async function rotateBrowser() {
if (context) await context.close()
if (browser) await browser.close()
sessionIndex++
const sessionId = `batch-${sessionIndex}-${Date.now()}`
browser = await chromium.launch({
headless: true,
proxy: {
server: `http://${ROTATING_HTTP_ENDPOINT}`,
username: buildProxyUsername(sessionId),
password: API_KEY,
},
})
context = await browser.newContext({
locale: "en-US",
timezoneId: "America/New_York",
viewport: { width: 1280, height: 800 },
})
requestCount = 0
console.log(`Rotated to session ${sessionId}`)
}
await rotateBrowser()
for (const url of urls) {
if (requestCount >= ROTATE_EVERY) {
await rotateBrowser()
}
const page = await context.newPage()
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 })
results.push({ url, title: await page.title(), ok: true })
requestCount++
} catch (err) {
results.push({ url, title: null, ok: false, error: err.message })
} finally {
await page.close()
}
}
await context.close()
await browser.close()
return results
}
const urls = [
"https://example.com/page-1",
"https://example.com/page-2",
"https://example.com/page-3",
"https://example.com/page-4",
"https://example.com/page-5",
"https://example.com/page-6",
]
const results = await scrapeUrls(urls)
console.log(results)This pattern rotates the proxy identity cleanly between browser sessions, not mid-session. Each batch of N pages shares one IP. The next batch gets a new session token and a new IP.
Even with residential proxies, some targets will challenge your browser. There are two classes of response: a soft block (CAPTCHA, JS challenge, redirect to a challenge page) and a hard block (403, 429, connection reset).
Detecting a block
async function getPageContent(page, url) {
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: 30000,
})
const status = response?.status() ?? 0
// Hard block
if (status === 403 || status === 429) {
throw new Error(`Blocked: HTTP ${status} on ${url}`)
}
const body = await page.content()
// Soft block: CAPTCHA or JS challenge page
const isChallenge =
body.includes("captcha") ||
body.includes("cf-browser-verification") ||
body.includes("Just a moment") ||
body.includes("Access denied")
if (isChallenge) {
throw new Error(`Challenge page detected on ${url}`)
}
return body
}Retry with a fresh proxy session on block
async function scrapeWithRetry(url, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const sessionId = `retry-${attempt}-${Date.now()}`
const browser = await chromium.launch({
headless: true,
proxy: {
server: `http://${ROTATING_HTTP_ENDPOINT}`,
username: buildProxyUsername({ sessionId }),
password: API_KEY,
},
})
const context = await browser.newContext({
locale: "en-US",
timezoneId: "America/New_York",
viewport: { width: 1440, height: 900 },
})
const page = await context.newPage()
try {
const content = await getPageContent(page, url)
await context.close()
await browser.close()
return content
} catch (err) {
console.warn(`Attempt ${attempt} failed: ${err.message}`)
await context.close()
await browser.close()
if (attempt === maxAttempts) throw err
// Exponential backoff before next attempt
await new Promise((r) => setTimeout(r, 1000 * attempt))
}
}
}The key: each retry gets a new session token and therefore a new IP. Do not retry with the same session ID — the same IP that got challenged once will get challenged again.
Rate limiting with delays
async function humanizedDelay(minMs = 500, maxMs = 2500) {
const delay = minMs + Math.random() * (maxMs - minMs)
await new Promise((r) => setTimeout(r, delay))
}
// Use between page navigations
await page.goto(url1)
await humanizedDelay()
await page.goto(url2)Proxy rotation handles the network identity layer. Browser fingerprinting is a separate detection layer that examines the browser itself — WebGL renderer, Canvas hash, AudioContext fingerprint, navigator properties, and font enumeration. A mismatched fingerprint can get a session flagged even when the IP is clean.
The most common fingerprint leaks in Playwright
navigator.webdriver set to true (Playwright's headless default)Reducing fingerprint consistency
import { chromium } from "playwright"
const VIEWPORTS = [
{ width: 1280, height: 800 },
{ width: 1366, height: 768 },
{ width: 1440, height: 900 },
{ width: 1920, height: 1080 },
]
const TIMEZONES = [
"America/New_York",
"America/Chicago",
"America/Los_Angeles",
"America/Denver",
]
const USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
]
function randomItem(arr) {
return arr[Math.floor(Math.random() * arr.length)]
}
async function launchWithFingerprint(sessionId) {
const viewport = randomItem(VIEWPORTS)
const timezone = randomItem(TIMEZONES)
const userAgent = randomItem(USER_AGENTS)
const browser = await chromium.launch({
headless: true,
args: [
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-setuid-sandbox",
],
proxy: {
server: `http://${ROTATING_HTTP_ENDPOINT}`,
username: buildProxyUsername({ sessionId }),
password: API_KEY,
},
})
const context = await browser.newContext({
viewport,
userAgent,
locale: "en-US",
timezoneId: timezone,
javaScriptEnabled: true,
})
// Patch navigator.webdriver to undefined
await context.addInitScript(() => {
Object.defineProperty(navigator, "webdriver", {
get: () => undefined,
})
})
return { browser, context }
}Why locale and timezone must match the proxy geography
A US residential IP with timezoneId: "Asia/Shanghai" and locale: "zh-CN" sends conflicting signals that anti-bot systems score. Always align:
--geo-country-us → timezoneId in America/* → locale: "en-US"--geo-country-gb → timezoneId: "Europe/London" → locale: "en-GB"Proxy rotation handles the IP layer. Fingerprint alignment handles the browser identity layer. Both need to be consistent.
Many blocks blamed on "bad proxies" are really policy problems in the automation layer.
If you are scraping public pages with no login state, shorter sessions can work. If you are handling authenticated or multi-step flows, stability usually beats hyper-aggressive rotation.
The provider control matters as much as the session control. See the full residential proxy guide for when each type wins.
--provider-res is the safer default for sensitive flows, login walls, aggressive bot scoring, and sites that care about IP reputation.--provider-dc is better for lower-cost, higher-volume workloads when the target is not highly protected.--geo-country-xx should match the market you actually want to appear from.A common failure pattern is pairing a US storefront flow with a non-US route, an en-US browser, and a US checkout path. The proxy might work technically while the overall identity still looks wrong.
The cost difference matters too. Residential proxies cost more per GB than datacenter, but they achieve much higher success rates on protected targets. For most Playwright automation against modern sites, residential is the correct default.
Do not go straight to production without verifying the integration first. A simple test sequence catches 90% of misconfiguration issues before they cost you against real targets.
Step 1 — Verify proxy connectivity
import { chromium } from "playwright"
async function testProxyConnectivity() {
const sessionId = "test-" + Date.now()
const browser = await chromium.launch({
headless: true,
proxy: {
server: `http://${ROTATING_HTTP_ENDPOINT}`,
username: buildProxyUsername({ sessionId }),
password: API_KEY,
},
})
const context = await browser.newContext()
const page = await context.newPage()
try {
await page.goto("https://ip.ninjasproxy.com/", { timeout: 15000 })
const body = await page.textContent("body")
console.log("Connected via IP:", body.trim())
console.log("Test PASSED")
} catch (err) {
console.error("Connectivity test FAILED:", err.message)
} finally {
await context.close()
await browser.close()
}
}
await testProxyConnectivity()Step 2 — Verify session stickiness
async function testSessionStickiness() {
const sessionId = "sticky-test-" + Date.now()
const browser = await chromium.launch({
headless: true,
proxy: {
server: `http://${ROTATING_HTTP_ENDPOINT}`,
username: buildProxyUsername({ sessionId }),
password: API_KEY,
},
})
const context = await browser.newContext()
const page = await context.newPage()
// Hit the IP check endpoint twice — should return the same IP both times
await page.goto("https://ip.ninjasproxy.com/")
const ip1 = (await page.textContent("body")).trim()
await page.goto("about:blank")
await page.goto("https://ip.ninjasproxy.com/")
const ip2 = (await page.textContent("body")).trim()
if (ip1 === ip2) {
console.log(`Session sticky: ${ip1} (PASSED)`)
} else {
console.warn(`Session drifted: ${ip1} → ${ip2} (check duration setting)`)
}
await context.close()
await browser.close()
}
await testSessionStickiness()Step 3 — Verify rotation between sessions
async function testRotationBetweenSessions() {
const getIP = async (sessionId) => {
const browser = await chromium.launch({
headless: true,
proxy: {
server: `http://${ROTATING_HTTP_ENDPOINT}`,
username: buildProxyUsername({ sessionId }),
password: API_KEY,
},
})
const context = await browser.newContext()
const page = await context.newPage()
await page.goto("https://ip.ninjasproxy.com/", { timeout: 15000 })
const ip = (await page.textContent("body")).trim()
await context.close()
await browser.close()
return ip
}
const ip1 = await getIP("session-a-" + Date.now())
const ip2 = await getIP("session-b-" + Date.now())
if (ip1 !== ip2) {
console.log(`Rotation confirmed: ${ip1} → ${ip2} (PASSED)`)
} else {
console.warn(`Same IP on both sessions: ${ip1} (rotation may not be working)`)
}
}
await testRotationBetweenSessions()Run these three tests before targeting real sites. They confirm: credentials are valid, sessions are sticky, and rotation is working between sessions.
Proxy rotation helps with network identity, but it does not fix everything Playwright can leak on its own.
navigator.webdriver set to trueIn practice, the winning setup is: believable session boundaries, clean storage separation, randomized fingerprints, moderate concurrency, and routing controls that stay stable for the duration of the task.
--session-... value.sessionId per browser launch.https://ip.ninjasproxy.com/.res) and country controls when the target needs them.That order isolates whether the problem is credentials, route policy, or browser fingerprinting.
NinjaProxy's residential rotating proxies work with Playwright out of the box using the username-control grammar above. No proxy list to manage. No endpoint to rotate. One fixed gateway, millions of residential IPs.
Start your free NinjaProxy trial — residential proxy access is available on all plans.