Playwright Browser Contexts: Test Isolation in TypeScript
Here is a number every SDET should internalize before they touch a test runner: Playwright browser contexts are what make your 200-test suite reproducible instead of a flaky lottery. A browser context is an isolated, incognito-style profile — its own cookies, localStorage, sessionStorage, cache, and viewport — and Playwright spins one up fresh for every test by default. In this tutorial I will show you exactly how contexts work in TypeScript, how to build multi-user tests, share login state, emulate devices, and the five traps that quietly leak state between tests. By the end you will be able to write isolated tests that pass on your machine, on CI, and under 8-way parallelism without touching a single `test.describe.configure({ mode: ‘serial’ })` hack.
Table of Contents
- What Is a Browser Context?
- Why Isolation Is a Superpower
- Creating a Context with Options
- Multiple Contexts in One Test
- Storage State and Auth Reuse
- Emulation: Viewport, Locale, and More
- Color Scheme, Offline, and Other Emulation Switches
- Five Pitfalls That Leak State
- Contexts vs Browsers: Performance
- India Context: The Interview Angle
- Key Takeaways
- FAQ
Contents
What Is a Browser Context?
A browser context in Playwright is the closest thing to opening a fresh incognito window — but it is an API, not a click. The official docs define it this way: “BrowserContexts provide a way to operate multiple independent browser sessions.” Every context has its own cookies, local storage, session storage, and cache, and Playwright guarantees they never bleed into each other, even when they share a single underlying browser process.
Here is the mental model I teach in my courses. A browser is a heavyweight process. A context is a lightweight sandbox inside that process. A page is a single tab inside a context. When your test receives page from the Playwright test fixture, it is actually getting a page that lives inside a brand-new context created just for that test.
import { test, expect } from '@playwright/test';
test('example test', async ({ page, context }) => {
// "context" is an isolated BrowserContext, created for this specific test.
// "page" belongs to this context.
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example Domain/);
});
test('another test', async ({ page, context }) => {
// "context" and "page" in this second test are completely
// isolated from the first test.
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example Domain/);
});
That snippet is almost verbatim what Playwright’s own isolation guide shows, and it is the most important five lines in this whole article. Two tests, two contexts, zero shared state. If the first test sets a cookie, the second test never sees it.
Why Isolation Is a Superpower
I have seen teams fight test-order dependencies for years in Selenium. They run a “cleanup” method after every test to delete cookies, only to discover you cannot clean up visited-link state — it is baked into the browser’s history. Playwright’s answer is simpler and harsher: start from scratch every time instead of trying to clean up in between.
Isolation buys you three concrete things, and all three show up in the Playwright guide:
- No failure carry-over. If one test fails, it does not drag the next test down with it. Your flake analysis starts and ends inside a single test file.
- Easy debugging. You can rerun a single failing test a hundred times and get the same clean slate every time. No “it passes after test 7 runs first” nonsense.
- Order-independence under parallelism. When you shard across workers or machines, test 3 and test 47 can run at the same time because they share nothing.
The cost is near zero. Contexts are described in the docs as “fast and cheap to create” — they reuse an existing browser process, so spinning up a context costs milliseconds, not the seconds a new browser launch would. That is the entire reason Playwright can run eight workers on a laptop without melting it.
Creating a Context with Options
Most of the time the test runner creates contexts for you, but the moment you need control — a specific viewport, a locale, a timezone, a permission — you create one manually with browser.newContext().
The basics: viewport, locale, timezone
import { chromium, devices } from '@playwright/test';
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
locale: 'en-IN',
timezoneId: 'Asia/Kolkata',
colorScheme: 'dark',
});
const page = await context.newPage();
await page.goto('https://example.com');
// Done with it — always close what you open.
await context.close();
await browser.close();
Notice locale: 'en-IN' and timezoneId: 'Asia/Kolkata'. If your app renders dates or currency based on the browser’s locale, this is how you test the Indian customer experience without a VPN. This is a detail most automation tutorials skip, and it is exactly the kind of bug that only shows up when a real user in Pune sees “08/16/2026” instead of “16/08/2026”.
Permissions and geolocation
const context = await browser.newContext({
geolocation: { longitude: 77.5946, latitude: 12.9716 }, // Bengaluru
permissions: ['geolocation', 'notifications'],
});
const page = await context.newPage();
await page.goto('https://your-app.com/nearest-store');
Granting permissions up front means the browser never shows the permission prompt, which would otherwise block your test. Every geolocation test I have ever debugged failed because the prompt swallowed the click.
Seeding the environment with addInitScript
addInitScript runs a script before any page in the context loads its own scripts. It is the clean way to stub Math.random, feature flags, or a mock analytics client. The docs note you can also pass a path to a real file.
await context.addInitScript(() => {
// Make random deterministic for snapshot / visual tests.
Math.random = () => 0.42;
// Stub a feature flag the app reads at boot.
(window as any).__FEATURE_FLAGS__ = { newCheckout: true };
});
I use this constantly for visual regression. A deterministic Math.random means the “suggested products” carousel renders identically on every run, so your screenshot diff is about the real UI change, not a shuffled widget.
Multiple Contexts in One Test
Single contexts are table stakes. The interesting work is multiple contexts in a single test, which is how you verify a feature where two different users interact — chat, a marketplace bid, a real-time document editor. I covered the broader multi-user pattern in Day 44: Multi-User Testing, but the foundation is right here.
import { test, expect } from '@playwright/test';
test('buyer and seller see different carts', async ({ browser }) => {
// Two isolated browser contexts in ONE test.
const buyerContext = await browser.newContext();
const sellerContext = await browser.newContext();
const buyerPage = await buyerContext.newPage();
const sellerPage = await sellerContext.newPage();
await buyerPage.goto('https://shop.example.com/login');
await buyerPage.getByLabel('Email').fill('buyer@example.com');
await buyerPage.getByLabel('Password').fill('secret');
await buyerPage.getByRole('button', { name: 'Sign in' }).click();
await sellerPage.goto('https://shop.example.com/login');
await sellerPage.getByLabel('Email').fill('seller@example.com');
await sellerPage.getByLabel('Password').fill('secret');
await sellerPage.getByRole('button', { name: 'Sign in' }).click();
// Each context keeps its own session cookie.
await expect(buyerPage.getByTestId('cart-count')).toHaveText('0');
await expect(sellerPage.getByTestId('cart-count')).toHaveText('3');
await buyerContext.close();
await sellerContext.close();
});
Two contexts, two independent session cookies, one browser process. If you tried this with a single page you would be logging out and back in constantly, and your assertions would race each other. Note the fixture here is browser, not page — you need the browser to mint manual contexts.
Storage State and Auth Reuse
The most common real-world use of contexts is storage state: save the cookies and localStorage after a login, then inject them into fresh contexts so every test starts already authenticated. This is the difference between a login that runs once and a login that runs in all 200 tests.
Step 1: capture the state once
// setup/global-setup.ts
import { chromium } from '@playwright/test';
async function globalSetup() {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://app.example.com/login');
await page.getByLabel('Email').fill('qa@example.com');
await page.getByLabel('Password').fill('correct-horse-battery');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
// Save cookies + localStorage to disk.
await context.storageState({ path: 'auth/user.json' });
await browser.close();
}
export default globalSetup;
Step 2: reuse it in a config or per-test
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
globalSetup: './setup/global-setup',
use: {
storageState: 'auth/user.json',
},
});
Every context the runner now creates starts with the logged-in session loaded. The key subtlety: storageState() writes non-persistent state only — cookies and storage that live in memory. If your app uses IndexedDB or service workers for auth, you may still need an explicit login. I detail the state-management tradeoffs in Day 45: Test Data Management, because storage state and test data are the same problem: how do I make the test start in the right world without re-deriving it every run.
Emulation: Viewport, Locale, and More
Playwright ships device descriptors you can import directly, so you can emulate an iPhone or a Pixel without configuring a single option yourself.
import { chromium, devices } from '@playwright/test';
const browser = await chromium.launch();
// iPhone 13 emulation: UA, viewport, DPR, touch — all in one object.
const iphone = await browser.newContext({ ...devices['iPhone 13'] });
const page = await iphone.newPage();
await page.goto('https://your-app.com/');
// Assert the mobile layout actually rendered.
const hamburger = page.getByRole('button', { name: 'Open menu' });
await expect(hamburger).toBeVisible();
await iphone.close();
Three things worth knowing about device emulation:
- It is a device profile, not a real device.
devices['iPhone 13']sets the user agent, viewport, and touch flag, but it does not emulate Safari’s rendering engine. Real Safari bugs need a real device farm or a cloud browser grid. - Combine descriptors with your own overrides. Spread the device object first, then override what you care about:
{ ...devices['iPhone 13'], locale: 'en-IN' }. - Service workers and PWA installs behave differently under emulation, so test install flows on a real profile where possible.
If you are coming from Selenium, this is the part that usually converts people. Selenium needs a separate mobile driver and capabilities soup for what Playwright does with one spread operator. I compared the two stacks head-to-head in Playwright vs Selenium in 2026 if you want the full data.
Color Scheme, Offline, and Other Emulation Switches
Context options are a grab bag of switches that most teams discover only after a production bug. Two I lean on every single week:
Dark mode and prefers-color-scheme
const dark = await browser.newContext({ colorScheme: 'dark' });
const page = await dark.newPage();
await page.goto('https://your-app.com/');
// Your app should reflect the user's OS preference.
await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
await dark.close();
I have shipped dark mode myself, and the bug is never “dark mode is broken everywhere” — it is always one component that hard-coded #ffffff and turns into a blinding white box at night. A context-level colorScheme flag turns that into a one-line test instead of a manual toggle hunt.
Offline and throttled network
// Offline: verify the app's "you're offline" fallback, not a blank screen.
const offline = await browser.newContext();
await offline.setOffline(true);
const page = await offline.newPage();
await page.goto('https://your-app.com/', { waitUntil: 'domcontentloaded' }).catch(() => {});
await expect(page.getByText(/you.re offline/i)).toBeVisible();
// Slow 3G: does the skeleton screen render, or does the spinner hang forever?
const slow = await browser.newContext();
await slow.route('**/*', route => {
route.request().resourceType() === 'document'
? route.continue()
: route.continue(); // swap in a delay wrapper for real throttling
});
The offline check is the one I make every junior SDET write, because “the page works on WiFi” tells you nothing about a customer on a patchy Jio connection in transit. Mobile users in India hit flaky networks daily, and setOffline(true) is a thirty-second test that catches a category of bug no happy-path suite ever will.
Five Pitfalls That Leak State
Contexts isolate you by default, but you can quietly defeat them. These are the five mistakes I see in real codebases.
1. Sharing one page across contexts
A page can only belong to one context. If you need a second isolated session, call context.newPage() — never reuse a page from another context. Mixing them up throws confusing errors about a page being closed or owned by another context.
2. Popups silently join the parent context
The docs are explicit here: “If a page opens another page, e.g. with a window.open call, the popup will belong to the parent page’s browser context.” That is usually what you want, but if you assumed the popup was isolated, your session cookie leaks into it and your “logged-out popup” test is lying to you.
3. Forgetting to close contexts
Manual contexts are not auto-closed by the test runner — the default page fixture is. A leaked context holds a browser connection open, and a few hundred leaked contexts will exhaust file descriptors on CI and hang the run. Pair every newContext() with a close(), ideally in a try/finally.
4. Storage state that is stale
A saved user.json goes bad the moment your auth token expires or your test user’s role changes. Treat storage state like a fixture: regenerate it in globalSetup on a schedule, and store it in CI artifacts so you can inspect it when tests mysteriously start failing on Mondays.
5. Assuming isolation fixes data problems
Contexts isolate browser state, not server state. If two tests create a user with the same email against the same API, they will still collide — the isolation only protects the browser side. Database reset or unique data per test is a separate concern, and it is exactly the gap I walked through in Day 45.
Contexts vs Browsers: Performance
Here is the trade-off, stated plainly, because I get this question in every batch of students.
- One browser, many contexts — fastest to spin up, shares the process, best for most tests. This is Playwright’s default and the reason parallel workers are cheap.
- One browser per test file — slightly more isolation (separate process), useful when a test crashes the renderer.
- New browser per test — the slowest and almost never needed. Reserve it for cases where a test intentionally kills the browser process or you need hard process isolation.
In practice, the default is right for roughly 95% of suites. The Playwright project sits at about 94,500 GitHub stars and over 200 million npm downloads a month for @playwright/test — a community that size has already hammered the default context model in production, and it holds.
India Context: The Interview Angle
If you are preparing for an SDET interview at a product company in Bengaluru or Hyderabad, expect a question that sounds simple but filters people fast: “What is the difference between a browser, a context, and a page in Playwright?” The interviewer is checking whether you understand isolation, not whether you can recite the API.
A strong answer covers three points: a browser is a process, a context is an isolated incognito-style profile with its own storage and cookies, and a page is a tab inside a context. Then you close with the practical punchline: “which is why Playwright can run parallel tests safely without state bleeding between them.”
Contexts also map directly to the multi-user and auth-reuse scenarios product companies care about. In the current Indian market, an SDET who can design an isolated, parallel-safe suite with storage-state auth reuse is positioned in the ₹15–35 LPA band, and the differentiator is rarely “knows the API” — it is “understands isolation and data strategy.” Frame it that way and you stop being a script-writer in the interviewer’s mind.
Key Takeaways
- Playwright browser contexts are isolated, incognito-style profiles — own cookies, storage, viewport, and cache — and the test runner creates one per test by default.
- Isolation gives you no failure carry-over, single-test reproducibility, and safe parallelism for free, because contexts are cheap to create.
- Create manual contexts with
browser.newContext()to control viewport, locale, timezone, permissions, geolocation, and device emulation. - Multiple contexts in one test is the clean way to verify multi-user flows like chat, marketplace, and collaborative editors.
- Reuse login state with
storageState()+globalSetupso 200 tests don’t log in 200 times. - Watch the five leaks: page reuse, popup inheritance, unclosed contexts, stale storage state, and server-side data collisions.
FAQ
What is the difference between browser.newContext() and browser.newPage()?
newContext() creates a new isolated session (fresh cookies and storage), while newPage() creates a new tab inside an existing context. Two pages in the same context share cookies; two pages in different contexts do not.
Are Playwright browser contexts persistent?
By default, no. They are non-persistent and write nothing to disk, which is why they are fast and clean. If you need persistence, use chromium.launchPersistentContext(), which is backed by a real user-data directory — useful for testing “remember me” across browser restarts.
No. Each context has fully isolated localStorage, sessionStorage, cookies, and cache. That is the entire point of the isolation model.
How do I run the same test on mobile and desktop without duplicating code?
Define a Playwright project per device descriptor, or spread a device object into newContext() inside a shared test. The project approach reuses the same test body across desktop, mobile, and tablet for free.
This is Day 52 of the Playwright + TypeScript series. Catch up on the earlier installments — from auto-waiting to multi-user testing — and build a complete, isolated framework one concept at a time.
