← all articles

How to route a Playwright agent through a mobile proxy

Most of my agent traffic leaves from a datacenter, and for most jobs that is fine. Some jobs need to see the web the way a phone on Singtel, M1 or StarHub sees it. That might be a localised page on your own site, a mobile-only flow you are QA-ing, or a workflow an agent runs for a user who asked for it. From a cloud VM those requests can land on a different page, a slower one, or a block page your real users never get.

This is for teams running Playwright agents in production who need a specific network vantage point and want it to fail cleanly. By the end you will have a Node script that launches Chromium through an authenticated proxy, checks its own exit IP, gives each identity its own context and IP, keeps data use down, and retries only the failures worth retrying.

One boundary. This is for your own properties, consenting users and sites whose terms allow automated access. It is not a guide to solving CAPTCHAs or dodging bans, and a mobile IP fixes neither. This is not legal advice, so read the terms of whatever you point an agent at.

what you need

  • Node.js on a current LTS release, plus Playwright (npm i -D playwright). Playwright is free under the Apache 2.0 licence.
  • A mobile proxy endpoint that speaks HTTP: host, port, username, password. Plans are usually billed by data or by port, and prices move, so check the provider’s pricing page on the day you buy. I am not quoting a number.
  • For Singapore exits, Singapore Mobile Proxy sells real Singapore mobile IPs with sticky sessions. It is ours, so weigh my opinion accordingly. It is also Singapore only, so it is the wrong tool if your users sit in Jakarta or Sydney.
  • A target you own or have permission to automate.
  • A VM or laptop that can reach the proxy port outbound. Some office and cloud firewalls block unusual ports.
  • Environment variables or a secret store for credentials. Not a config file in git.

step by step

1. Get a baseline IP with no proxy

Run one navigation without a proxy so you know what “before” looks like.

npm init -y && npm i -D playwright
npx playwright install chromium
// baseline.mjs
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://api.ipify.org?format=json');
console.log(await page.textContent('body'));
await browser.close();

Expected output: {"ip":"203.0.113.7"}, with your VM’s real address in place of that example one.

If it breaks: an SSL error during install behind a corporate proxy is covered in this fix, and a “browser executable doesn’t exist” error in Docker in this one.

2. Put credentials in environment variables

export PROXY_SERVER="http://proxy.example.com:8000"
export PROXY_USER="agent-01"
export PROXY_PASS="change-me"

On PowerShell that is $env:PROXY_SERVER = "...". Pass username and password as separate Playwright fields, never inside the URL.

Expected output: nothing.

If it breaks: a password containing @, : or # mangles a user:pass@host URL. That is the whole reason the fields are separate.

3. Launch Chromium through the proxy

The proxy launch option takes server, username, password and bypass, per the Playwright network docs.

// egress.mjs
import { chromium } from 'playwright';
export const proxy = {
  server: process.env.PROXY_SERVER,
  username: process.env.PROXY_USER,
  password: process.env.PROXY_PASS,
};
const browser = await chromium.launch({ proxy });
const page = await browser.newPage();
await page.goto('https://ipinfo.io/json', { timeout: 45000 });
const info = JSON.parse(await page.textContent('body'));
console.log(info);
await browser.close();

Expected output: JSON whose ip differs from your baseline.

If it breaks: net::ERR_PROXY_CONNECTION_FAILED is usually a wrong host or port, or a blocked outbound port. A wrong username or password comes back as HTTP 407 Proxy Authentication Required, which RFC 9110 defines, or as a net:: auth error. ERR_TUNNEL_CONNECTION_FAILED has its own write-up: the tunnel fix.

4. Verify the exit before you trust it

Look at country and org in that JSON. For a Singapore plan, country should be SG and org should name a carrier’s ASN, not a hosting company. Set the pattern from what your first run prints, then make every run assert it:

if (info.country !== 'SG' || !new RegExp(process.env.EXPECT_ORG, 'i').test(info.org)) {
  throw new Error(`unexpected egress: ${info.ip} ${info.country} ${info.org}`);
}

Expected output: no error, and a log line with the IP.

If it breaks: a cloud provider in org means the proxy is not applied or you were given a non-mobile pool. Raise that with the provider before anything else. Geo databases also lag, so a freshly assigned IP can look wrong for a while.

5. Give each identity its own context and IP

One browser, many contexts, each with its own proxy. Chromium needs a launch-level proxy for this to work, and the docs describe a placeholder for it. How you pick a sticky session varies by provider, usually a session id in the username or a dedicated port, so read their docs.

const browser = await chromium.launch({ proxy: { server: 'http://per-context' } });
const makeContext = (user) =>
  browser.newContext({ proxy: { ...proxy, username: user } });
const a = await makeContext('agent-01');
const b = await makeContext('agent-02');

Expected output: two contexts, and two different IPs if your plan gives each username its own session.

If it breaks: both contexts on one IP means the provider maps sessions by port, not username. A context that seems to ignore its proxy usually means the placeholder launch proxy is missing.

6. Trim what goes through the proxy

Mobile data is often the expensive part of the bill. bypass skips the proxy for hosts you list, and a route rule can drop resources the agent never reads.

const ctx = await browser.newContext({
  proxy: { ...proxy, bypass: 'localhost,127.0.0.1' },
});
await ctx.route('**/*', (route) =>
  ['image', 'font', 'media'].includes(route.request().resourceType())
    ? route.abort()
    : route.continue());

Expected output: the same page text, and a lower number on your provider’s usage counter.

If it breaks: pages that lay out on image size or lazy-load on scroll can misbehave. Let images through for those. And never bypass the target itself, or that traffic leaves from your VM’s real IP.

7. Add timeouts and retries that only cover proxy failures

Mobile latency is higher and more variable than a datacenter’s. Use domcontentloaded and a generous timeout. A page that never fires load is its own problem, covered in the goto timeout fix. Retry network-level errors on a fresh context. Never retry a step that changes state.

const PROXY_ERR = /ERR_(PROXY|TUNNEL)_|ERR_CONNECTION_(RESET|TIMED_OUT)/;

async function read(makeCtx, url, tries = 3) {
  for (let i = 1; i <= tries; i++) {
    const ctx = await makeCtx();
    try {
      const page = await ctx.newPage();
      await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
      return await page.content();
    } catch (e) {
      if (!PROXY_ERR.test(String(e)) || i === tries) throw e;
      await new Promise((r) => setTimeout(r, 2000 * i));
    } finally {
      await ctx.close();
    }
  }
}

Expected output: a transient tunnel reset recovers on the second try, and a genuine page error throws at once.

If it breaks: if all three tries fail, stop and hand the job to a person. Looping harder on a dead session just burns data.

8. Pair each IP with its saved session and log the egress

Cookies issued to one IP and replayed from another are a classic cause of “logged out for no reason”. Save state per identity and keep the identity-to-proxy mapping stable. If injected cookies do not stick, this walkthrough covers the usual causes.

import fs from 'node:fs';
fs.mkdirSync('state', { recursive: true });
const path = `state/${user}.json`;
const ctx = await browser.newContext({
  proxy: { ...proxy, username: user },
  storageState: fs.existsSync(path) ? path : undefined,
});
// ... agent work, log info.ip and user in your run record ...
await ctx.storageState({ path });

Expected output: the second run starts logged in, from the same egress IP as the first.

If it breaks: if the provider expired the sticky session you get a new IP. Treat that as a fresh login and do not replay old cookies blindly.

common pitfalls

  • Rotating the IP under a logged-in session. A site that sees one account hop across IPs every request has a reasonable basis to distrust it. Use sticky sessions for anything with a login.
  • Sending everything through the proxy. Downloads, autoplay video and image-heavy pages eat a metered plan fast. Step 6 exists for a reason.
  • Skipping the egress check. Providers swap pools and geo databases drift. I would rather fail a run in step 4 than discover a week later that a job ran from the wrong network.
  • SOCKS5 with credentials. As far as I know, as of September 2026, Chromium cannot do username and password auth on SOCKS5. If your provider hands you a socks5 URL, ask for the HTTP endpoint.
  • Treating a mobile IP as a clean slate. Carriers put many phones behind shared addresses, so what other people did from that IP is part of its reputation. How IP reputation is earned and lost explains why, and why you still need your own rate limits.

scaling this

At 10x, about ten identities, one browser process handles it. The work is bookkeeping: a small table of identity, proxy username, state file and last-seen egress IP. Keep it in a file or SQLite, not in someone’s head.

At 100x, you will run several browser processes or VMs. Your plan’s limits on concurrent connections or ports start to matter, so ask the provider before you find out. Bandwidth becomes the biggest line item, so per-target rate limits and a queue stop being optional. Pin one browser build across the fleet too, and Chrome for Testing versus open-source Chromium is the comparison I would read first. Health-check each identity with the step 4 assertion before dispatching work to it.

At 1000x, I need to be straight: I have not run a thousand concurrent contexts through mobile proxies, so this is expectation, not a result. I would expect supply to be the limit, not Playwright. Singapore is a small market with a handful of carriers, and both of our products are Singapore only, so if you need Singapore mobile IPs at that volume, ask about capacity before you build.

And ask whether every job needs mobile at all. My view, and you can argue it, is that most do not. Route only the jobs that need a carrier’s vantage point through mobile. Send everything else out of a datacenter, or through an official API where the site offers one.

where to go next

Written by Xavier Fok

disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-09-25.

free download
Why did my agent get blocked? A triage checklist

The checks we run, in order, when a browser or phone agent starts failing: network, fingerprint, behaviour, account. Leave your email and we will also tell you when we publish a new field note, a few times a month at most.

from the team behind this site
A Singapore mobile IP for browser agents

Singapore Mobile Proxy runs real mobile IPs on SingTel, StarHub and M1, with sticky sessions so one task keeps one IP. Singapore only: a fit for SEA or location-agnostic work, the wrong tool if you need a US IP.

see plans →
from the team behind this site
A real Android phone for phone-use agents

cloudf.one hosts real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP. For agents that need an actual device and a stable carrier identity.

get a phone →
read on
More from The Agent Ops Report

Blocks, sessions, retries, traces, cost per task and phone-use agents. Browse all articles →