Playwright Multi-Tab and Multi-User Testing: Test Collaboration Features Other Frameworks Ignore
There is a class of bug that traditional single-user test automation simply cannot find: the bugs that only appear when two users interact with the same resource simultaneously.
- A collaborator edits a document while the owner deletes it
- User A accepts an invitation that User B already cancelled
- An admin revokes a permission while the user is mid-session
- A buyer completes a purchase for the last item in stock that another buyer is also adding to cart
These are multi-party state conflicts, and they are among the most impactful bugs in modern web applications — especially SaaS products where concurrent users are the norm, not the exception.
Playwright is uniquely well-suited to test them. Its browser context architecture makes it straightforward to simulate multiple independent users operating within the same test, coordinating their actions to exercise race conditions, shared state, and real-time update flows.
This guide covers the full spectrum: multiple tabs, multiple users, and real-time scenarios.
Playwright's Context Model: The Foundation
Every Playwright test works within a hierarchy:
Browser
└── BrowserContext (isolated "session")
└── Page (tab)
A BrowserContext is the equivalent of an incognito window or a fresh browser profile — it has its own cookies, localStorage, and session state. Multiple contexts within the same browser instance do not share any state.
This means you can simulate two completely different logged-in users in the same test by creating two separate contexts:
test('owner and collaborator see matching state', async ({ browser }) => {
// Create two isolated user sessions
const ownerContext = await browser.newContext({
storageState: 'playwright/.auth/owner.json',
});
const collaboratorContext = await browser.newContext({
storageState: 'playwright/.auth/collaborator.json',
});
const ownerPage = await ownerContext.newPage();
const collaboratorPage = await collaboratorContext.newPage();
// Both navigate to the same shared project
await ownerPage.goto('/projects/shared-123');
await collaboratorPage.goto('/projects/shared-123');
// Owner creates a scan
await ownerPage.getByRole('button', { name: 'Run Scan' }).click();
await ownerPage.waitForResponse('/api/scans');
// Collaborator should see the scan appear in real-time
await expect(collaboratorPage.getByText('Scan Running')).toBeVisible({ timeout: 5000 });
await ownerContext.close();
await collaboratorContext.close();
});
Setting Up Multi-User Authentication States
The prerequisite for multi-user testing is having separate authenticated storage states for each user type. Your global setup should provision all the states you need:
// tests/setup/global-setup.ts
import { chromium } from '@playwright/test';
type UserCredentials = {
email: string;
password: string;
storageStatePath: string;
};
const users: UserCredentials[] = [
{
email: process.env.OWNER_EMAIL!,
password: process.env.OWNER_PASSWORD!,
storageStatePath: 'playwright/.auth/owner.json',
},
{
email: process.env.MEMBER_EMAIL!,
password: process.env.MEMBER_PASSWORD!,
storageStatePath: 'playwright/.auth/member.json',
},
{
email: process.env.ADMIN_EMAIL!,
password: process.env.ADMIN_PASSWORD!,
storageStatePath: 'playwright/.auth/admin.json',
},
];
async function globalSetup() {
const browser = await chromium.launch();
for (const user of users) {
const page = await browser.newPage();
await page.goto(process.env.BASE_URL + '/login');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: user.storageStatePath });
await page.close();
console.log(`✅ Auth state saved for ${user.email}`);
}
await browser.close();
}
export default globalSetup;
Multi-Tab Testing: When One User Needs Multiple Windows
Before jumping to multi-user, consider a common single-user, multi-tab scenario: a user opens a settings page in a new tab while still viewing the dashboard in the original tab. Changes in one tab should reflect in the other.
test('settings changes reflect in other open tabs', async ({ page, context }) => {
// Open dashboard in the main tab
await page.goto('/dashboard');
await expect(page.getByText('Free Plan')).toBeVisible();
// Open settings in a new tab (same context = same session)
const settingsTab = await context.newPage();
await settingsTab.goto('/settings/billing');
// Simulate a plan upgrade in the settings tab
await settingsTab.getByRole('button', { name: 'Upgrade to Pro' }).click();
await settingsTab.getByRole('button', { name: 'Confirm Upgrade' }).click();
await expect(settingsTab.getByText('Pro Plan Active')).toBeVisible();
// Verify the dashboard tab reflects the upgrade (via reload or real-time)
await page.reload();
await expect(page.getByText('Pro Plan')).toBeVisible();
await expect(page.getByText('Free Plan')).not.toBeVisible();
});
Key insight: pages created from the same context share cookies and session state, just like tabs in the same browser window. Pages from different contexts are completely isolated.
Testing Real-Time Collaboration Features
Real-time features — live updates, presence indicators, collaborative editing — are notoriously hard to test. Multi-context Playwright makes them tractable.
Here is a pattern for testing a notification that appears when a team member joins a session:
test('user sees notification when team member joins', async ({ browser }) => {
const ownerContext = await browser.newContext({
storageState: 'playwright/.auth/owner.json',
});
const memberContext = await browser.newContext({
storageState: 'playwright/.auth/member.json',
});
const ownerPage = await ownerContext.newPage();
await ownerPage.goto('/workspace/project-abc/live');
// Wait for owner to establish WebSocket connection
await ownerPage.waitForFunction(() => window.__ws_connected === true);
// Now member joins
const memberPage = await memberContext.newPage();
await memberPage.goto('/workspace/project-abc/live');
// Owner should see member's presence notification
await expect(ownerPage.getByRole('alert')).toContainText('joined the workspace');
// Both should see each other's cursors / presence indicators
await expect(ownerPage.getByTestId('active-users-count')).toHaveText('2');
await expect(memberPage.getByTestId('active-users-count')).toHaveText('2');
await Promise.all([ownerContext.close(), memberContext.close()]);
});
Timing and Synchronization: The Hard Part
Multi-user tests introduce temporal coupling — the test must coordinate actions across multiple pages in the right order. Use these strategies to avoid timing issues:
Strategy 1: Wait for Network Responses
// After owner performs an action, wait for the API call to complete
// before checking the collaborator's UI
const scanPromise = ownerPage.waitForResponse((res) => res.url().includes('/api/scans') && res.status() === 201);
await ownerPage.getByRole('button', { name: 'Run Scan' }).click();
await scanPromise; // Ensures the scan was created on the server
// Now safe to check collaborator's UI
await expect(collaboratorPage.getByText('New scan available')).toBeVisible();
Strategy 2: Poll for Expected State
For real-time updates with eventual consistency, polling with a generous timeout is often more reliable than fixed waits:
// Wait up to 10 seconds for the real-time update to propagate
await expect(async () => {
await collaboratorPage.reload(); // or rely on WebSocket push
await expect(collaboratorPage.getByText('Scan Complete')).toBeVisible();
}).toPass({ timeout: 10_000, intervals: [500, 1000, 2000] });
Strategy 3: Use Event Listeners
Playwright can wait for specific events on a page — useful for WebSocket-driven updates:
// Listen for a specific console message that signals update received
const updateReceived = collaboratorPage.waitForEvent('console', (msg) => msg.text().includes('realtime:scan_updated'));
await ownerPage.getByRole('button', { name: 'Complete Scan' }).click();
await updateReceived; // Waits for the WebSocket event, then continues
Testing Concurrent Mutations: Finding Race Conditions
Some of the most damaging bugs in SaaS apps are race conditions in resource contention. Here is how to test for a "last writer wins" scenario:
test('simultaneous saves are handled without data loss', async ({ browser }) => {
const user1Context = await browser.newContext({
storageState: 'playwright/.auth/user1.json',
});
const user2Context = await browser.newContext({
storageState: 'playwright/.auth/user2.json',
});
const page1 = await user1Context.newPage();
const page2 = await user2Context.newPage();
// Both open the same document
await Promise.all([page1.goto('/documents/shared-doc'), page2.goto('/documents/shared-doc')]);
// Both users start editing at the same time
await page1.getByRole('textbox', { name: 'Title' }).fill('User 1 Title');
await page2.getByRole('textbox', { name: 'Title' }).fill('User 2 Title');
// Both save simultaneously
await Promise.all([
page1.getByRole('button', { name: 'Save' }).click(),
page2.getByRole('button', { name: 'Save' }).click(),
]);
// Verify: one of the saves succeeded, no silent data corruption
await page1.reload();
await page2.reload();
const page1Title = await page1.getByRole('textbox', { name: 'Title' }).inputValue();
const page2Title = await page2.getByRole('textbox', { name: 'Title' }).inputValue();
// Both users should see the same value (no split-brain)
expect(page1Title).toBe(page2Title);
// Bonus: check for conflict notification if your app supports it
// await expect(page1.getByRole('alert')).toContainText('Conflict detected');
await Promise.all([user1Context.close(), user2Context.close()]);
});
Multi-User Fixtures: Encapsulating the Pattern
Creating two contexts in every multi-user test is verbose. Encapsulate it in a fixture:
// tests/fixtures/multiUser.ts
import { test as base } from '@playwright/test';
type MultiUserFixture = {
ownerPage: Page;
memberPage: Page;
};
export const test = base.extend<MultiUserFixture>({
ownerPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'playwright/.auth/owner.json' });
const page = await ctx.newPage();
await use(page);
await ctx.close();
},
memberPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'playwright/.auth/member.json' });
const page = await ctx.newPage();
await use(page);
await ctx.close();
},
});
Tests become clean and explicit:
import { test, expect } from '../fixtures/multiUser';
test('owner-initiated scans appear in member view', async ({ ownerPage, memberPage }) => {
// ...
});
For a broader look at fixture architecture, see our guide on mastering Playwright fixtures.
Visual: Multi-Context Test Flow Diagram
sequenceDiagram
participant Test as Test Runner
participant Owner as Owner (Context A)
participant Server as Application Server
participant Member as Member (Context B)
Test->>Owner: Navigate to /workspace
Test->>Member: Navigate to /workspace
Owner->>Server: POST /api/scans (create)
Server-->>Owner: 201 Created { id: "scan-123" }
Server-->>Member: WebSocket push: scan_created
Test->>Member: Wait for "New Scan" notification
Member-->>Test: ✅ Notification visible
Test->>Owner: Expect scan in list
Test->>Member: Expect scan in list
Production Relevance: Why This Testing Matters
Multi-user bugs are high-severity because they are hard to reproduce in isolation. They often only manifest in production under real concurrent load. If you are building any collaborative feature — shared dashboards, team workspaces, real-time notifications, shared resources — testing it with simulated concurrent users before it ships is essential.
ScanlyApp's own continuous scan pipeline exercises authenticated flows against production-like environments, validating that multi-user states remain consistent across deploys.
Monitor your collaborative features in production: Try ScanlyApp free to set up ongoing scans of your authenticated, multi-user workflows.
Related articles: Also see fixtures that simplify multi-user test setup and teardown, intercepting network calls in multi-tab collaboration tests, and verifying real-time updates when multiple users share a session.
Summary: The Multi-Context Capability Map
| Scenario | Playwright Approach | Key API |
|---|---|---|
| Multiple tabs, same user | context.newPage() |
Shared cookies/session |
| Multiple users | browser.newContext({ storageState }) |
Isolated sessions |
| Real-time updates | page.waitForResponse / event listeners |
Network observation |
| Race conditions | Promise.all simultaneous actions |
Concurrent execution |
| WebSocket verification | page.waitForEvent('console') |
Event monitoring |
Start with multi-tab tests (they are simple and high-value). Graduate to multi-user tests for your collaboration features. The investment pays off on the first concurrent-user bug you catch before it ships.
Further Reading
- Playwright Browser Contexts: The official guide to BrowserContext isolation — the foundation of multi-user testing
- Playwright Storage State: How to capture, save, and reuse authentication state across tests for multi-user scenarios
- Playwright Multi-Window Testing: Managing popup windows, new tabs, and cross-page navigation in Playwright tests
- Playwright Network Events: Intercepting and monitoring WebSocket messages and server-sent events
