BlogEngineering

How to Rotate Proxies in Playwright to Avoid Bot Detection (2026 Guide)

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

NinjaProxy

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.

What playwright proxy rotation should do

Playwright applies the proxy at browser launch time. That means your proxy plan has to match the browser lifecycle.

  1. Launch one browser for one identity window.
  2. Keep a sticky route for login, cookie creation, pagination, or checkout.
  3. Close that browser when the flow is done.
  4. Start the next browser with a new session token when you need a fresh route.

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.

Configure NinjaProxy residential proxies in Playwright

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.

  • Keep the same rotating HTTP endpoint copied from your account.
  • Keep the same API key.
  • Change only the username controls when you need stickiness, provider selection, or country targeting.

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 chromium

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

Rotating proxies every N requests

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.

Handling CAPTCHAs and blocks

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)

Avoiding detection with fingerprint rotation

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)
  • Consistent viewport size across all sessions
  • Missing or unrealistic browser plugins list
  • Canvas fingerprint identical across every run
  • No user interaction events before navigation

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-ustimezoneId in America/*locale: "en-US"
  • --geo-country-gbtimezoneId: "Europe/London"locale: "en-GB"

Proxy rotation handles the IP layer. Fingerprint alignment handles the browser identity layer. Both need to be consistent.

Rotation policy that looks less synthetic

Many blocks blamed on "bad proxies" are really policy problems in the automation layer.

  • Rotate per account, job, or workflow when a flow needs continuity.
  • Do not rotate during login or checkout unless the target explicitly tolerates it.
  • Reuse sticky sessions briefly for paginated browsing or multi-step form work.
  • Back off concurrency by route family instead of firing every worker through the same country and provider mix.
  • Separate browser storage by identity so cookies and local storage do not leak across routes.

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.

When to use residential vs datacenter proxies

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.

Testing your Playwright proxy integration

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.

Proxies do not fix these detection signals

Proxy rotation helps with network identity, but it does not fix everything Playwright can leak on its own.

  • Reused cookies across unrelated identities
  • Unrealistic page pacing or zero think time
  • Mismatched locale, timezone, and target geography
  • Repeated hard refreshes after challenge pages
  • One browser context touching too many accounts in sequence
  • navigator.webdriver set to true

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

Troubleshooting the most common failures

  • 407 Proxy Authentication Required means the username string or API key is wrong. Re-copy the base username and append controls to the username only.
  • Route never changes usually means you kept reusing the same --session-... value.
  • Route changes too often usually means you removed the session control for a flow that needs stickiness.
  • Country targeting looks ignored usually means the country code was malformed or the browser metadata does not match the route you requested.
  • Login succeeds but follow-up requests get challenged usually means the browser reused state from a different identity or the route changed between steps.
  • All sessions return the same IP usually means your session ID is not changing — check that you are generating a unique sessionId per browser launch.
  1. Verify one plain rotating browser against https://ip.ninjasproxy.com/.
  2. Add a sticky session token and confirm the IP stays stable for the whole flow.
  3. Add provider (res) and country controls when the target needs them.
  4. Add fingerprint randomization (viewport, user-agent, timezone).
  5. Then tune concurrency and add retry logic.

That order isolates whether the problem is credentials, route policy, or browser fingerprinting.

Start with a free trial

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.

Relevant docs