Testing real-time features: a practical Playwright guide
Real-time features like live notifications, collaborative editing, and instant price updates have become table stakes for modern applications. Testing these features with Playwright requires a different mindset than testing traditional request-response flows—you need to simulate multiple users, observe WebSocket frames, and validate that changes propagate correctly. In this guide, we'll walk through the patterns and techniques we've used across e-commerce, SaaS, and trading platforms to ensure real-time systems work reliably.
Why real-time testing matters
Real-time systems introduce complexity that synchronous tests often miss. When a user updates data, you need to verify not just that their change was saved, but that every connected client receives the update immediately. WebSocket connections can drop, messages can arrive out of order, and race conditions are notoriously hard to reproduce in production. Playwright gives us the tools to simulate these scenarios consistently in a test environment.
The challenge is that traditional UI testing frameworks were built for single-browser workflows. Real-time testing demands we orchestrate multiple browser contexts, inspect low-level protocol frames, and assert state consistency across parallel sessions.
Two browser contexts for two users
The foundation of real-time testing is simulating two independent users. In Playwright, we create separate browser contexts—each with its own cookies, storage, and network—to represent distinct sessions. This isolation ensures that actions in one context don't bleed into another.
Let's walk through a concrete example: testing a collaborative task list where one user creates a task and another user should see it appear instantly.
import { test, expect } from '@playwright/test';
test('task appears in real-time for second user', async ({ browser }) => {
// Create two independent browser contexts
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
// Both users navigate to the app
await page1.goto('http://localhost:3000/tasks');
await page2.goto('http://localhost:3000/tasks');
// Authenticate as different users
await page1.evaluate(() => {
localStorage.setItem('userId', 'user-123');
});
await page2.evaluate(() => {
localStorage.setItem('userId', 'user-456');
});
// Reload to pick up new user IDs
await page1.reload();
await page2.reload();
// Wait for WebSocket connections to establish
await page1.waitForLoadState('networkidle');
await page2.waitForLoadState('networkidle');
// User 1 creates a new task
await page1.fill('[data-testid="task-input"]', 'Review pull request');
await page1.click('[data-testid="create-task-btn"]');
// Verify User 1 sees the task
await expect(page1.locator('text=Review pull request')).toBeVisible();
// User 2 should see the task appear without refreshing
await expect(page2.locator('text=Review pull request')).toBeVisible();
await context1.close();
await context2.close();
});
This test creates two completely independent browser sessions. The key is that both contexts are authenticated as different users, so the backend treats them as separate clients. When user 1 creates a task, the server broadcasts the update via WebSocket, and page 2 receives it without any manual polling.
In production scenarios we've tested, this pattern scales to three or more contexts when you need to validate multi-user interactions like voting, commenting, or concurrent editing.
Assert updates appear in the other context
Real-time systems succeed when updates propagate reliably and consistently. The assertion strategy differs from traditional testing because you're waiting for asynchronous events rather than synchronous responses.
In our experience, the most common pitfall is asserting too quickly before the WebSocket message has been processed by the DOM. We use Playwright's built-in wait utilities to handle this gracefully.
test('live price update syncs across users', async ({ browser }) => {
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const trader1 = await context1.newPage();
const trader2 = await context2.newPage();
await trader1.goto('http://localhost:3000/trading');
await trader2.goto('http://localhost:3000/trading');
// Set up different trader IDs
await trader1.evaluate(() => localStorage.setItem('traderId', 'trader-a'));
await trader2.evaluate(() => localStorage.setItem('traderId', 'trader-b'));
await trader1.reload();
await trader2.reload();
// Get initial price
const initialPrice = await trader1.locator('[data-testid="btc-price"]').textContent();
// Trader 1 updates their bid price
await trader1.fill('[data-testid="bid-input"]', '45000');
await trader1.click('[data-testid="submit-bid"]');
// Trader 2 waits for the order book to update
// This uses Playwright's locator.waitFor() with timeout
const orderLocator = trader2.locator('[data-testid="order-item"]:has-text("45000")');
await expect(orderLocator).toBeVisible({ timeout: 5000 });
// Verify the price display is correct
await expect(trader2.locator('[data-testid="bid-price"]')).toContainText('45000');
await context1.close();
await context2.close();
});
The critical line here is the timeout on expect(orderLocator).toBeVisible({ timeout: 5000 }). Real-time updates can take a few hundred milliseconds to traverse the network, be processed server-side, and pushed to all connected clients. A 5-second timeout gives your system breathing room without making tests brittle.
When you have strict latency requirements (like a trading platform), you might assert within 500ms or 1000ms. The timeout value should reflect your application's SLA for real-time updates.
page.waitForEvent('websocket') to inspect frames
Sometimes you need to drop down to the protocol level and inspect actual WebSocket frames. This is invaluable for debugging why an update didn't propagate, or validating that your message format is correct.
Playwright allows you to listen for WebSocket events and examine the raw frames being sent.
test('verify websocket message structure', async ({ page }) => {
let capturedFrames = [];
// Listen for WebSocket frames
page.on('websocket', (ws) => {
console.log('WebSocket connected:', ws.url());
ws.on('framesent', (frame) => {
capturedFrames.push({
type: 'sent',
data: frame.payload,
});
});
ws.on('framereceived', (frame) => {
capturedFrames.push({
type: 'received',
data: frame.payload,
});
});
});
await page.goto('http://localhost:3000/dashboard');
await page.waitForLoadState('networkidle');
// Trigger an action that should send a WebSocket message
await page.click('[data-testid="update-status-btn"]');
// Wait a bit for frames to arrive
await page.waitForTimeout(500);
// Inspect the captured frames
const sentMessage = capturedFrames.find(f => f.type === 'sent');
console.log('Sent payload:', sentMessage?.data);
const receivedMessage = capturedFrames.find(f => f.type === 'received');
console.log('Received payload:', receivedMessage?.data);
// Assert message structure
expect(sentMessage?.data).toContain('update-status');
expect(receivedMessage?.data).toContain('ack');
});
This is powerful for debugging. We've used it many times to verify that message timestamps are correct, that user IDs are being included, or that our message compression is working. In test environments, you can also validate that sensitive data is being stripped before transmission.
One note: WebSocket frames may be binary or text. If they're binary (common with protobuf or messagepack), you'll need to decode them to inspect the content.
Mock servers for edge cases
In production, you cannot reliably test failure scenarios—network drops, slow servers, message loss—by relying on your staging environment. That's where mock servers come in.
We typically use a combination of Playwright's built-in mocking and tools like Mock Service Worker (MSW) to intercept WebSocket traffic and simulate edge cases.
test('handles websocket disconnection and reconnects', async ({ page, context }) => {
// Set up a custom WebSocket interceptor
await context.addInitScript(() => {
const originalWebSocket = window.WebSocket;
window.WebSocket = class MockWebSocket extends originalWebSocket {
constructor(url) {
super(url);
this._isInterrupted = false;
}
send(data) {
if (this._isInterrupted) {
throw new Error('WebSocket is closed');
}
super.send(data);
}
simulateDisconnect() {
this._isInterrupted = true;
this.close();
}
};
});
await page.goto('http://localhost:3000/notifications');
await page.waitForLoadState('networkidle');
// Verify the connection is open
await expect(page.locator('[data-testid="connection-status"]'))
.toContainText('Connected');
// Simulate a network drop
await page.evaluate(() => {
const ws = window.WebSocket.prototype;
ws.simulateDisconnect();
});
// Expect UI to show disconnected state
await expect(page.locator('[data-testid="connection-status"]'))
.toContainText('Reconnecting');
// After reconnection logic kicks in, verify we're back online
await expect(page.locator('[data-testid="connection-status"]'))
.toContainText('Connected', { timeout: 5000 });
});
For more sophisticated scenarios, we've used Playwright's route interception to block WebSocket upgrades entirely, forcing the client to test fallback behavior. This is closer to how the application would behave on a slow 3G connection.
Common mistakes
We've seen teams make these errors repeatedly when testing real-time features:
- Not waiting for WebSocket connection to establish. Calling
page.waitForLoadState('networkidle')after navigation ensures the WebSocket handshake is complete before you trigger updates. - Using the same browser context for two users. This defeats the purpose—both users will share cookies and storage. Always create separate contexts.
- Asserting too quickly. Real-time updates take time. Use Playwright's wait methods with appropriate timeouts instead of hard-coded delays.
- Not cleaning up browser contexts. Forgetting to call
context.close()can leak WebSocket connections and cause subsequent tests to hang or fail. - Ignoring message ordering. In high-latency networks, messages can arrive out of order. Your assertions should account for this, or you should test message IDs and timestamps.
Key takeaways
- Create separate browser contexts to simulate independent users; this is the foundation of any real-time test.
- Use
expect().toBeVisible()with appropriate timeouts rather than hard-coded waits—it's more reliable and faster. - Inspect WebSocket frames when you need to debug protocol-level issues; Playwright's
page.on('websocket')gives you full visibility. - Mock servers and network interruptions to test reconnection logic, retries, and graceful degradation.
- Real-time tests are inherently slower than synchronous tests; budget time in your CI pipeline accordingly.






Comments
Loading comments…