Playwright Multi-Context: One Mobile Proxy Per Identity (2026)
Playwright Multi-Context: One Mobile Proxy Per Identity (2026)
You ran five Playwright contexts to manage five accounts, separate cookies, separate storage, everything looked isolated, and all five got linked and flagged inside a day. Here is why: all five contexts left the building from the exact same proxy ip. The cookies were separate, but the network path was shared, and the platform reads the network path before it reads your cookies.
I run a hardware mobile proxy farm in Singapore. Real phones, real sims, actual Singtel, M1, and StarHub mobile lines sitting in racks here. I drive Playwright across many contexts behind these proxies every day, for our own testing and for operators running multi-account setups. So this is not theory. This is the thing that bites people, and the fix is one option in one call that almost nobody sets in the right place.
The core mistake: proxy at launch
In Playwright there are two places you can attach a proxy, and only one of them isolates your identities. You can set it at browser launch, or you can set it per context. When you set it at launch, that one proxy applies to every context you open from that browser. So five contexts inherit one ip. Five identities, one exit. That is the link. The platform does not need your cookies to connect those accounts, they all phoned home from the same address at the same time.
The launch and context options both accept a proxy object that looks identical, the same three fields, so the code reads the same in both spots. The only difference is where you put it. One line up at launch and you have shared the ip across everything. One line down in newContext and you have isolated it. Same object, completely different blast radius. When accounts get linked, the first thing I check is which call the proxy is attached to, and nine times out of ten it is sitting at launch.
Move the proxy down to the context
The fix is to set the proxy inside the newContext call, not at launch. The browser itself does not need a global proxy at all. Each context gets its own proxy object, so each identity leaves from its own ip.
const { chromium } = require('playwright');
// no global proxy here
const browser = await chromium.launch({
args: ['--disable-webrtc'],
});
// proxy lives on the context, not the browser
const context = await browser.newContext({
proxy: {
server: 'http://sg.proxy.example.com:8000',
username: 'acct123-session-id01',
password: 'your-account-password',
},
});
The server field is the gateway host and port. The username and password are your account credentials. Notice the session token tucked into the username, that is doing more work than the server field, and it is the part people miss.
The username is the real control
Giving each context a different server is not how you get different ips, the username is. With a mobile proxy gateway you do not get a thousand different host names. You get one gateway host and one port, and you steer which exit ip you land on through the username. You embed a session token in it. A different session token gives you a different sticky exit ip.
So context one carries session id one in its username, context two carries session id two, and so on. The server stays the same across all contexts. The username changes per context. All five hit the same gateway, but each one is pinned to a distinct mobile ip that stays stable for the life of that session. That is what sticky means here: the same identity keeps the same ip across page loads and across a whole work session, instead of rotating mid-task and looking like a teleporting user.
function proxyFor(sessionToken) {
return {
server: 'http://sg.proxy.example.com:8000',
username: `acct123-session-${sessionToken}`,
password: 'your-account-password',
};
}
The session token is just a short string you make up, and it is the lever for everything. Keep it stable and the exit ip stays stable. Change it and you get a brand new exit ip. For a long-lived identity you want a token that never changes, so you store it next to that account and reuse it every session. Some gateways also let you set a session lifetime, so you can hold the same mobile ip for twenty or thirty minutes of work. Pick a lifetime that covers a normal task and you are done.
Pair the ip with the storage
Cookies and ip have to travel together, or you create a new tell. If account one always logs in from session id one, then account one’s cookies and local storage have to load with session id one every time, never with session id two. In Playwright you do this with storage state: you save each context’s state to its own file, one file per identity. When you reopen that identity, you load its storage state file and attach its matching session token.
const identities = {
alice: { session: 'id01', storage: './state/alice.json' },
bob: { session: 'id02', storage: './state/bob.json' },
};
async function openIdentity(name) {
const id = identities[name];
return browser.newContext({
proxy: proxyFor(id.session),
storageState: id.storage,
...devices['Pixel 7'], // more on this next
});
}
Keep a small table, in your head or in code: identity one maps to session token one and storage file one, identity two maps to session token two and storage file two. You never cross them. If you ever load identity one’s cookies behind identity two’s ip, you have just told the platform those two accounts are the same operator. The table is the source of truth, and every new context reads from it. That pairing is half the battle.
Make the device look mobile too
These are mobile ips, so the rest of the fingerprint should look mobile. An ip that geolocates to a Singapore mobile carrier, paired with a desktop user agent and a desktop viewport, is a contradiction the platform can see. Playwright ships device descriptors for exactly this. You pick a device, say a common Android phone, and spread that descriptor into the newContext call.
const { chromium, devices } = require('playwright');
const context = await browser.newContext({
proxy: proxyFor('id01'),
storageState: './state/alice.json',
...devices['Pixel 7'],
});
That descriptor sets the user agent, the viewport, the device scale factor, and touch support to match a real phone. So the new context now carries two things that have to agree: the proxy object with its per-identity session token, and the device descriptor for a mobile phone. The ip says mobile Singapore carrier, the browser says mobile phone, the cookies say this specific account. Nothing contradicts.
Close the webrtc and dns leaks
Webrtc can reach around the proxy and grab your real local and public ip straight from the browser. You can have a perfect proxy setup and a perfect device, and a webrtc probe still hands the platform your real address. The clean fix is to disable webrtc at launch, or block the stun traffic, so the only ip anything can see is the proxy exit. Test it, because a webrtc leak silently undoes everything else.
Dns is the quietest leak of all. If your proxy tunnels the http traffic but your machine still resolves host names through your own local resolver, your isp sees every domain you visit. The exit ip is clean but the dns trail is not. With a socks5 proxy you want the variant that does remote resolution, socks5h. The h means host name resolution happens at the proxy, on the mobile line, not on your machine, so the lookup and the connection both leave from the same mobile ip. This is the same socks5h rule covered in using a mobile proxy with curl and python, it carries straight over to Playwright.
Verify every context independently
Once it is wired, you verify, and you check each context independently, not just one. The simplest check is an ip echo. Inside each context you open a page that returns the ip it saw you come from, and you read it back.
async function checkExit(context, expectSession) {
const page = await context.newPage();
await page.goto('https://api.ipify.org?format=json');
const { ip } = JSON.parse(await page.locator('body').innerText());
console.log(expectSession, '->', ip);
await page.close();
return ip;
}
const a = await checkExit(ctxA, 'id01');
const b = await checkExit(ctxB, 'id02');
if (a === b) throw new Error('two contexts share one ip, stop now');
Context one should report one mobile ip, context two a different one. If any two report the same ip, your session tokens collided or you set the proxy at launch by accident, and you stop right there before you log anything in. While you are at it, check what carrier each exit geolocates to. You want a real Singtel, M1, or StarHub block, not a hosting provider. The echo check is two assertions, not one: the ip is distinct per context, and the ip is mobile. This thirty-second check saves accounts.
Run them in parallel without collisions
Running in parallel is where collisions actually happen. It is fine to run many contexts at once, that is the whole point of multi-context, but every context needs its own session token before any of them open a page. The trap is building the contexts in a loop and reusing one token, or letting two contexts share a default. So generate the token first, attach it, then open.
const names = ['alice', 'bob', 'carol'];
const contexts = await Promise.all(
names.map((n) => openIdentity(n)) // each gets its own token up front
);
Allocate a unique token per context up front and you can run ten of them side by side with ten distinct mobile ips and zero overlap. This is the same isolation discipline that keeps multiple social accounts off each other’s radar, applied at the browser-context level.
Clean up so contexts do not bleed
Contexts bleed if you are sloppy. When you finish with an identity, close its context fully and let it write its storage state back to its own file, so the next session resumes where it left off.
await context.storageState({ path: './state/alice.json' });
await context.close();
You do not reuse a context object across two identities, and you do not let one identity’s cookies linger in memory when you open the next. One identity, one context, one session token, one storage file, opened and closed as a clean unit. That discipline is what keeps fifty accounts looking like fifty separate people instead of one operator with fifty tabs.
The whole flow in one breath
Launch the browser with no global proxy and webrtc disabled. For each identity, build a proxy object where the server is the shared gateway, the username carries that identity’s unique sticky session token, and the password is your account password. Call newContext with that proxy object, the matching storage state file, and a mobile device descriptor. Point dns through the tunnel with socks5h so lookups leave from the mobile line. Navigate to an ip echo page and confirm the exit is distinct and mobile. Then do your work, close cleanly, and save state. Repeat per identity.
That is the difference between five accounts that survive and five that get linked on day one. The cookies were never the problem. The shared network path was. Fix the path, pair it to the cookies, and check it.
Run it on real Singapore mobile lines
If you want the proxies that make this clean, that is what we run. Real Singapore mobile ips on actual Singtel, M1, and StarHub sims, sticky sessions so each context holds its own exit ip for as long as you need it, and dns that routes through the tunnel so you stop leaking your real resolver. Start a free trial of Singapore Mobile Proxy, use code YT30, then point your newContext proxy object at our gateway, set a session token per identity, and run your whole stack behind real mobile lines.
Get new guides and videos first — join the Telegram channel.