v1.10.90-0e025b8
Skip to main content
TutorialTypeScriptCode

Playwright Proxy Rotation and Session Management in TypeScript

11 min read

By Hex Proxies Engineering Team

Playwright Proxy Rotation and Session Management in TypeScript

Playwright gives you one browser process and many contexts. Each context is its own cookie jar, storage, cache, and — critically — its own proxy. That is the architectural hook that makes rotation clean: bind a proxy to a context, not to a browser.

This guide builds a TypeScript session manager that rotates proxies per context, persists cookies across runs, uses the Puppeteer stealth plugin, and captures screenshots for debugging. It runs on Playwright 1.47+.

Why contexts, not browsers

Launching a fresh Chromium process for every proxy is slow (~1.5s startup) and memory-hungry (~150MB per process). Playwright contexts share the browser process but isolate everything else, so you can hold 50 contexts in a single browser for the cost of one Chromium at startup. Each context still gets its own --proxy-server-equivalent via the proxy option passed to newContext().

The only case where you need separate browsers is when a target fingerprints Chromium at the process level (rare) or when you need different browser binaries (Chromium vs Firefox vs WebKit).

Dependencies

// package.json dependencies
{
  "dependencies": {
    "playwright": "1.47.2",
    "playwright-extra": "4.3.6",
    "puppeteer-extra-plugin-stealth": "2.11.2"
  }
}

playwright-extra wraps Playwright to accept Puppeteer plugins, including puppeteer-extra-plugin-stealth, which patches the most common automation fingerprints (navigator.webdriver, chrome runtime, etc.).

Session manager

import { chromium, type BrowserContext, type Browser } from "playwright";
import { chromium as chromiumExtra } from "playwright-extra";
import stealth from "puppeteer-extra-plugin-stealth";

chromiumExtra.use(stealth());

export interface ProxyDescriptor {
  readonly server: string;   // e.g. "http://gate.hexproxies.com:7777"
  readonly username: string;
  readonly password: string;
  readonly label: string;
}

export interface SessionOptions {
  readonly proxy: ProxyDescriptor;
  readonly storageStatePath?: string;
  readonly userAgent?: string;
  readonly locale?: string;
  readonly timezoneId?: string;
}

const DEFAULT_UA =
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) " +
  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36";
export class BrowserSessionManager {
  private browser: Browser | null = null;

  async start(): Promise<void> {
    // One shared browser process; isolation happens at the context layer.
    this.browser = await chromiumExtra.launch({
      headless: true,
      args: [
        "--disable-blink-features=AutomationControlled",
        "--disable-dev-shm-usage",
      ],
    });
  }

  async stop(): Promise<void> {
    await this.browser?.close();
    this.browser = null;
  }

  async newSession(opts: SessionOptions): Promise<BrowserContext> {
    if (!this.browser) throw new Error("call start() first");

    const context = await this.browser.newContext({
      proxy: {
        server: opts.proxy.server,
        username: opts.proxy.username,
        password: opts.proxy.password,
      },
      userAgent: opts.userAgent ?? DEFAULT_UA,
      locale: opts.locale ?? "en-US",
      timezoneId: opts.timezoneId ?? "America/New_York",
      viewport: { width: 1440, height: 900 },
      storageState: opts.storageStatePath,
      ignoreHTTPSErrors: false,
    });

    // Block heavy resources we don't need for data extraction.
    await context.route("**/*", (route) => {
      const type = route.request().resourceType();
      if (type === "image" || type === "font" || type === "media") {
        return route.abort();
      }
      return route.continue();
    });

    return context;
  }

  async persistCookies(
    context: BrowserContext,
    outPath: string,
  ): Promise<void> {
    await context.storageState({ path: outPath });
  }
}

Two details worth highlighting. First, context.route() blocks images, fonts, and media. On most data-extraction jobs those account for 60-80% of bytes transferred and give you nothing, so aborting them at the router level cuts residential proxy cost dramatically. Second, storageState on newContext() hydrates cookies from a previous session, which is how you maintain logins across rotations.

Rotating across URLs

import { BrowserSessionManager, type ProxyDescriptor } from "./session-manager";
import { mkdir } from "node:fs/promises";
import path from "node:path";

const PROXIES: ProxyDescriptor[] = [
  {
    server: "http://gate.hexproxies.com:7777",
    username: "USER-session-a",
    password: "PASS",
    label: "hex-us-a",
  },
  {
    server: "http://gate.hexproxies.com:7777",
    username: "USER-session-b",
    password: "PASS",
    label: "hex-us-b",
  },
];

async function scrapeWithRotation(urls: string[]): Promise<void> {
  const mgr = new BrowserSessionManager();
  await mgr.start();
  await mkdir("./artifacts", { recursive: true });

  try {
    for (let i = 0; i < urls.length; i++) {
      const proxy = PROXIES[i % PROXIES.length];
      const storagePath = path.join("./artifacts", `${proxy.label}.json`);
      const context = await mgr.newSession({
        proxy,
        storageStatePath: await fileExists(storagePath) ? storagePath : undefined,
      });

      const page = await context.newPage();
      try {
        await page.goto(urls[i], { waitUntil: "domcontentloaded", timeout: 30000 });
        await page.waitForLoadState("networkidle", { timeout: 10000 });

        const title = await page.title();
        console.log(`[${proxy.label}] ${urls[i]} -> ${title}`);

        // Debug artifact for the first few pages.
        if (i < 5) {
          await page.screenshot({
            path: path.join("./artifacts", `shot-${i}.png`),
            fullPage: true,
          });
        }

        await mgr.persistCookies(context, storagePath);
      } catch (err) {
        console.error(`[${proxy.label}] failed: ${(err as Error).message}`);
      } finally {
        await context.close();
      }
    }
  } finally {
    await mgr.stop();
  }
}

async function fileExists(p: string): Promise<boolean> {
  try {
    const { access } = await import("node:fs/promises");
    await access(p);
    return true;
  } catch {
    return false;
  }
}

scrapeWithRotation([
  "https://httpbin.org/headers",
  "https://httpbin.org/ip",
  "https://httpbin.org/cookies",
]).catch((e) => {
  console.error(e);
  process.exit(1);
});

The rotation strategy here is round-robin per URL. For harder targets, switch to a rotation that sticks to one proxy for an entire session (login + navigation + logout) and only rotates between sessions. Per-request rotation breaks login flows because the site sees a new IP mid-session and invalidates the cookie.

Sticky sessions with Hex gateway

Hex residential gateway supports sticky sessions via username suffix: USER-session-foo pins a single exit IP to the "foo" session for up to 30 minutes. That gives you a clean idiom: one sticky session ID per Playwright context, rotated only when you need a new identity. See how proxy rotation works.

Debugging with screenshots

The script writes full-page screenshots for the first five URLs. When something breaks in headless mode, that screenshot is the only way to tell whether you were blocked, served a captcha, or hit a layout change. Keep screenshots on for the first N requests of every new scraper you build, then disable them when the job is stable.

When not to use Playwright

Playwright is the wrong tool for high-volume, JSON-API scraping. A single httpx or reqwest client will do 10-100x the throughput at 1/50 the memory. Use Playwright only when the target renders content client-side, challenges you with JavaScript, or requires DOM interaction (clicks, form fills, infinite scroll).

Residential proxies from Hex work out of the box with Playwright's proxy option. See pricing.