Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 055 / 100

Multi-user scenarios: Playwright lessons from production

Chat, collaboration and admin approvals.

Expona Admin9 min read

Multi-user scenarios: Playwright lessons from production

When you're testing real-world features like chat, collaborative editing, or admin approval workflows, you quickly realize that testing a single user isn't enough. We've shipped features across e-commerce platforms, CRM systems, and SaaS products where multiple users interact simultaneously, and every time we've learned the same lesson: Playwright's browser contexts are your lifeline for this kind of testing.

In this article, we'll walk through how we handle multi-user scenarios in production test suites—from spinning up isolated browser sessions to coordinating actions across users and cleaning up properly afterward.

Multiple contexts — Each context is an isolated browser session

Playwright contexts are independent browser sessions within the same browser instance. Each context has its own cookies, local storage, and session storage, so they act like completely separate users even though they're running in the same process. This is the foundation of reliable multi-user testing.

Think of a notification feature in a ride-hailing or travel app: driver A accepts a ride, and the dispatch system should immediately notify the passenger. You need two separate contexts—one for the driver, one for the passenger—to simulate this interaction realistically.

typescript
import { test, expect } from '@playwright/test';

test('driver accepts ride and passenger sees notification', async ({ browser }) => {
  // Create two isolated contexts: one for driver, one for passenger
  const driverContext = await browser.newContext();
  const passengerContext = await browser.newContext();

  const driverPage = await driverContext.newPage();
  const passengerPage = await passengerContext.newPage();

  // Navigate both users to the app
  await driverPage.goto('https://app.example.com');
  await passengerPage.goto('https://app.example.com');

  // Each context is completely isolated
  // Changes in driverContext won't affect passengerContext's cookies or storage
  
  await driverContext.close();
  await passengerContext.close();
});

By default, contexts share the same browser instance but maintain separate storage. This means you can have dozens of users "active" simultaneously without the overhead of spinning up multiple browsers. We've found this approach scales well for testing flows with 3–10 concurrent users, which covers most real-world scenarios you'll encounter in production.

Different roles — Load different storage states per context

In production systems, users have different roles: admins, moderators, regular users, guests. Each role sees different UI and has different permissions. Playwright lets you load pre-recorded storage states (authentication tokens, user preferences, role information) into contexts, so each "user" can start with the right identity immediately.

We typically capture storage state after logging in as each role, then reuse it across test runs. This avoids logging in repeatedly and keeps tests fast.

typescript
import { test, expect } from '@playwright/test';

// In a setup file, we'd have captured and saved these storage states:
// - storageState.admin.json
// - storageState.moderator.json
// - storageState.user.json

test('admin approves content, moderator and user see the change', async ({ browser }) => {
  // Create contexts with different roles
  const adminContext = await browser.newContext({
    storageState: './auth/storageState.admin.json'
  });

  const moderatorContext = await browser.newContext({
    storageState: './auth/storageState.moderator.json'
  });

  const userContext = await browser.newContext({
    storageState: './auth/storageState.user.json'
  });

  const adminPage = await adminContext.newPage();
  const moderatorPage = await moderatorContext.newPage();
  const userPage = await userContext.newPage();

  // All three users load with their respective roles
  // Admin has approval buttons, moderator sees moderation queue, user sees only approved content
  
  await adminPage.goto('https://app.example.com/admin/queue');
  await moderatorPage.goto('https://app.example.com/moderate');
  await userPage.goto('https://app.example.com/feed');

  await adminContext.close();
  await moderatorContext.close();
  await userContext.close();
});

For CRM or SaaS products where you're testing workflows like "manager approves expense report, employee sees confirmation," this approach is indispensable. Each user's context loads with their authentication and permissions baked in, so you can focus on testing the interaction, not authentication setup.

Coordinate actions — User A sends, user B sees it

The real magic happens when you orchestrate actions across users. User A posts a message in a chat, user B should see it instantly. User A uploads a file to a shared folder, user B's view should refresh. This is where multi-user testing gets tricky—you need to ensure changes propagate correctly between isolated contexts.

In practice, we use a combination of waiting, polling, and WebSocket listeners to coordinate. Here's a chat example from a collaboration tool we tested:

typescript
test('user A sends message, user B receives it in real-time', async ({ browser }) => {
  const userAContext = await browser.newContext({
    storageState: './auth/storageState.userA.json'
  });
  const userBContext = await browser.newContext({
    storageState: './auth/storageState.userB.json'
  });

  const pageA = await userAContext.newPage();
  const pageB = await userBContext.newPage();

  // Both users join the same chat room
  await pageA.goto('https://app.example.com/chat/room-123');
  await pageB.goto('https://app.example.com/chat/room-123');

  // User A types and sends a message
  await pageA.fill('[data-testid="message-input"]', 'Hello from User A');
  await pageA.click('[data-testid="send-button"]');

  // User B should see the message appear
  // Wait for the message to appear in User B's message list
  const messageLocator = pageB.locator(
    '[data-testid="message"]:has-text("Hello from User A")'
  );

  await expect(messageLocator).toBeVisible({ timeout: 5000 });

  // Verify the message contains the correct sender
  await expect(pageB.locator('[data-testid="message-sender"]')).toContainText('User A');

  await userAContext.close();
  await userBContext.close();
});

The key here is the timeout in expect—network latency and WebSocket delivery aren't instant. We set reasonable timeouts (3–5 seconds for real-time features) and rely on Playwright's built-in retry logic to avoid flaky tests.

For approval workflows (common in e-commerce and CRM), the coordination is often request-response: admin clicks "approve," system updates the database, user polls or listens for changes. We've found that injecting small waits between actions helps stabilize these tests without making them brittle.

typescript
test('admin approves order, vendor sees status change', async ({ browser }) => {
  const adminContext = await browser.newContext({
    storageState: './auth/storageState.admin.json'
  });
  const vendorContext = await browser.newContext({
    storageState: './auth/storageState.vendor.json'
  });

  const adminPage = await adminContext.newPage();
  const vendorPage = await vendorContext.newPage();

  // Vendor checks order status (currently pending)
  await vendorPage.goto('https://app.example.com/orders/pending');
  await expect(vendorPage.locator('[data-testid="order-123"]')).toBeVisible();

  // Admin approves the order
  await adminPage.goto('https://app.example.com/admin/approvals');
  await adminPage.click('[data-testid="approve-order-123"]');
  await expect(adminPage.locator('[data-testid="success-toast"]')).toBeVisible();

  // Give the system a moment to propagate, then vendor refreshes
  await vendorPage.reload();

  // Vendor sees order status is now "approved"
  const statusBadge = vendorPage.locator(
    '[data-testid="order-123"] [data-testid="status"]'
  );
  await expect(statusBadge).toContainText('Approved');

  await adminContext.close();
  await vendorContext.close();
});

Clean up — Close contexts at the end

Every context you create consumes memory and browser resources. If you open 10 contexts and forget to close them, your test runner will eventually run out of resources and crash with cryptic errors. Always close contexts explicitly, especially in a CI environment where tests run back-to-back.

We recommend closing contexts in an afterEach hook or using Playwright's built-in context fixture (which handles cleanup automatically). Here's the pattern we use most often:

typescript
test.describe('multi-user features', () => {
  let adminContext, userContext;

  test.beforeEach(async ({ browser }) => {
    adminContext = await browser.newContext({
      storageState: './auth/storageState.admin.json'
    });
    userContext = await browser.newContext({
      storageState: './auth/storageState.user.json'
    });
  });

  test.afterEach(async () => {
    // Always close contexts after each test
    await adminContext?.close();
    await userContext?.close();
  });

  test('admin setting propagates to user', async () => {
    const adminPage = await adminContext.newPage();
    const userPage = await userContext.newPage();

    // Test logic here...
  });
});

Alternatively, use Playwright's context fixture directly, which handles cleanup automatically:

typescript
test('with automatic context cleanup', async ({ browser }) => {
  const context1 = await browser.newContext();
  const context2 = await browser.newContext();

  // Use contexts...

  await context1.close();
  await context2.close();
  // Playwright's fixture will clean up any remaining contexts
});

In production suites, we've seen tests hang or timeout because contexts weren't closed. It's a common pain point, so make cleanup a routine part of your test structure from the start.

Common mistakes

  • Sharing storage between contexts: If you pass the same storageState to two contexts, they'll share authentication state but won't see each other's localStorage changes in real-time. Isolate storage states by role or user ID.
  • Not waiting for cross-context updates: Assuming User B sees User A's change immediately without a wait or reload will cause flaky tests. Always add reasonable timeouts (3–5 seconds) to expectations in multi-user flows.
  • Forgetting to close contexts: Leaving contexts open accumulates memory leaks, especially in long test runs. Use afterEach hooks or explicit close calls.
  • Testing too many simultaneous users: Beyond 10–15 concurrent contexts, overhead grows and tests become slow. If you need stress testing, use dedicated load-testing tools instead.
  • Relying on timing instead of events: Using fixed delays (e.g., await page.waitForTimeout(2000)) instead of waiting for actual elements or events makes tests brittle. Always prefer element visibility or network wait conditions.

Key takeaways

  • Use browser contexts to create isolated user sessions; each context is a separate browser session with its own storage and cookies.
  • Load different storage states into contexts to simulate different roles (admin, user, moderator) without re-authenticating.
  • Coordinate actions across contexts by waiting for observable changes—DOM updates, status badges, or message arrivals—with sensible timeouts (3–5 seconds).
  • Always close contexts explicitly in afterEach hooks or use Playwright's built-in fixtures to prevent memory leaks and hanging tests.
  • Start with 2–3 concurrent users and expand only if your scenario demands it; beyond 10–15 users, dedicated load-testing tools are more appropriate.
  • Test real-world interaction patterns: chat, approvals, notifications. These expose timing and synchronization issues that single-user tests miss.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles