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.