Real-Time Collaboration Testing Guide

How to Test Presence Avatars Online with Playwright: Multi-Tab Coordination, Heartbeats, and State Transitions

A scenario-by-scenario walkthrough of testing presence avatars in collaborative applications. Multi-tab presence deduplication, heartbeat expiry, avatar stack rendering order, and the online/away/offline state machine that breaks under real network conditions.

500M+

Microsoft Teams reports over 300 million monthly active users, each generating continuous presence signals. Slack, Figma, Notion, and Google Docs collectively serve hundreds of millions more, making presence indicators one of the most widely deployed real-time features on the web.

Microsoft FY2024 Earnings Report

0Presence states to test
0sTypical heartbeat interval
0Avatar stack scenarios
0%Fewer lines with Assrt

Presence Avatar System End-to-End Flow

User A BrowserWebSocket ServerPresence ServiceUser B BrowserConnect + join channelRegister presence (user A)Broadcast: A is onlinePush presence updateRender avatar stackHeartbeat pingRefresh TTLDisconnect (tab close)Broadcast: A is offlineRemove avatar from stack

1. Why Testing Presence Avatars Is Harder Than It Looks

Presence avatars appear deceptively simple. A green dot next to a name, a row of overlapping circles at the top of a document, a tooltip that says “3 people viewing.” But underneath that UI lives a real-time distributed system with multiple failure modes that are invisible during manual testing and only surface under automation or production load.

The core difficulty is that presence is inherently multi-client. Unlike a login form or a checkout flow where a single browser session exercises the full path, presence requires at least two browser contexts interacting simultaneously. One user joins a document; the other user must see their avatar appear. That means your test must orchestrate parallel browser contexts, synchronize assertions across them, and handle the timing uncertainty of WebSocket message delivery.

Beyond multi-client coordination, there are four structural challenges. First, heartbeat timers introduce wall-clock dependencies: a user who stops sending heartbeats after 30 seconds should transition to offline, but waiting 30 real seconds in a test is painfully slow. Second, the same user opening multiple tabs should appear as a single avatar, not duplicate entries, which requires presence deduplication logic that is easy to get wrong. Third, avatar stack rendering has overflow behavior (the “+3” pill) that depends on container width, intersection observer thresholds, and CSS stacking contexts. Fourth, network interruptions should trigger graceful degradation, not ghost avatars that linger indefinitely.

Presence State Machine

Disconnected

No WebSocket connection

↪️

Connecting

WebSocket handshake

Online

Heartbeats flowing

🔒

Away

No user interaction

Offline

Heartbeat expired or disconnected

2. Setting Up a Reliable Test Environment

Testing presence avatars requires a server that supports WebSocket connections and a configurable heartbeat interval. Most production systems use a heartbeat interval of 15 to 60 seconds, but your test environment should use a much shorter interval (1 to 3 seconds) to avoid slow tests. Set this via an environment variable so your CI pipeline runs fast without modifying production code.

You will also need multiple Playwright browser contexts to simulate concurrent users. Each context acts as an independent browser session with its own cookies, localStorage, and WebSocket connections. Playwright supports creating multiple contexts from a single browser instance, which is more resource-efficient than launching separate browser processes.

playwright.config.ts
Install Dependencies

Environment Variables for Presence Testing

.env.test

Test Environment Architecture

🌐

Playwright

Multiple browser contexts

⚙️

App Server

localhost:3000

🔔

WebSocket

Presence channel

⚙️

Presence Store

Redis or in-memory

Broadcast

State updates to all clients

3

Basic Online Presence on Join

Straightforward

Goal:When User A opens a document, User B (already viewing the document) sees User A's avatar appear in the presence indicator within 2 seconds. The avatar should display the correct initials or profile image, and a tooltip should show User A's name.

Preconditions: Both users are authenticated. User B is already on the document page. The WebSocket connection is established.

Playwright Implementation

tests/presence/basic-online.spec.ts

What to Assert Beyond the UI

Check the WebSocket messages directly to verify the server is broadcasting the correct presence payload. Playwright lets you intercept WebSocket frames using the page.on('websocket') event. Verify that the presence message includes the correct user ID, display name, avatar URL, and a status of "online". Also confirm that the message arrives within your SLA (typically under 500ms for a local test environment).

Basic Presence: Playwright vs Assrt

test('joining user appears in presence', async ({ browser }) => {
  const ctxA = await browser.newContext({ storageState: './auth/user-a.json' });
  const ctxB = await browser.newContext({ storageState: './auth/user-b.json' });
  const pageA = await ctxA.newPage();
  const pageB = await ctxB.newPage();

  await pageB.goto('/docs/test-document-123');
  await pageB.waitForSelector('[data-testid="presence-indicator"]');
  await pageA.goto('/docs/test-document-123');

  const avatars = pageB.locator('[data-testid="presence-avatar"]');
  await expect(avatars).toHaveCount(2, { timeout: 5000 });
  await expect(avatars.nth(1)).toContainText('AC');
  await ctxA.close();
  await ctxB.close();
});
60% fewer lines
4

Multi-Tab Presence Deduplication

Complex

Goal: When User A opens the same document in three browser tabs, other users should see exactly one avatar for User A, not three. When User A closes two of those tabs, the avatar should remain visible. Only when the last tab is closed should the avatar disappear.

Preconditions: User A is authenticated. User B is viewing the document. The presence system uses a connection-count or session-deduplication strategy.

This scenario catches one of the most common presence bugs: each tab registers a separate WebSocket connection, and naive implementations count each connection as a distinct user. The fix is typically server-side deduplication keyed by user ID rather than connection ID, combined with a reference counter that tracks how many connections a single user has open. The avatar should only be removed when the reference count drops to zero.

Playwright Implementation

tests/presence/multi-tab-dedup.spec.ts

What to Assert Beyond the UI

Intercept WebSocket frames on User B's page to verify the server sends a single presence:join event for User A (not three). When tabs close, verify the server does not broadcast presence:leave until the last connection for that user is gone. You can also query the presence API endpoint directly to confirm the server-side member count matches the UI.

tests/presence/ws-dedup-verify.spec.ts

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started
5

Avatar Stack Rendering and Overflow

Moderate

Goal:When more users are present than the avatar stack can display (e.g., 7 users in a stack that shows 4), the component should render the first N avatars with correct z-index stacking and show a “+3” overflow pill. Avatars should overlap with consistent negative margins and maintain the correct visual order.

Preconditions: The MAX_VISIBLE_AVATARS constant is set to 4. Seven test users are seeded in the database. All seven navigate to the same document.

Playwright Implementation

tests/presence/avatar-stack-overflow.spec.ts

What to Assert Beyond the UI

Beyond visual checks, verify the DOM structure and CSS properties programmatically. The avatar stack should use position: relative with decreasing z-index values so the first avatar renders on top. The negative margin-left on each subsequent avatar creates the overlap effect. Screenshot comparison can catch subtle rendering regressions that DOM assertions miss, such as avatar images not loading or border radius changes breaking the circular shape.

Avatar Stack Overflow: Playwright vs Assrt

test('overflow pill shows correct count', async ({ browser }) => {
  const contexts = await Promise.all(
    Array.from({ length: 8 }, (_, i) =>
      browser.newContext({ storageState: `./auth/user-${i}.json` })
    )
  );
  const observer = await contexts[0].newPage();
  await observer.goto('/docs/test-document-123');
  for (let i = 1; i < 8; i++) {
    const p = await contexts[i].newPage();
    await p.goto('/docs/test-document-123');
  }
  await observer.waitForTimeout(3000);
  const pill = observer.locator('[data-testid="presence-overflow"]');
  await expect(pill).toContainText('+4');
  await Promise.all(contexts.map(c => c.close()));
});
63% fewer lines
6

Away/Idle State After Inactivity

Moderate

Goal:When User A stops interacting with the page (no mouse movement, no keyboard input, no scroll) for longer than the idle threshold, their avatar should transition from “online” (green dot) to “away” (yellow dot). When they resume activity, the avatar should transition back to “online” within one heartbeat interval.

Preconditions: The idle threshold is configured to 3 seconds for testing. Both users are viewing the document. User A is currently shown as online.

Testing idle detection is tricky because Playwright tests inherently generate no user input. By default, a Playwright page is “idle” the moment you stop sending actions. This means you need to either continuously dispatch synthetic events to keep a user “active,” or you need to design your test so that the lack of input is the trigger you are testing.

Playwright Implementation

tests/presence/idle-transition.spec.ts
7

Heartbeat Expiry and Offline Transition

Complex

Goal:When User A's WebSocket connection is severed without a clean disconnect (simulating a network failure or browser crash), the presence system should detect the missed heartbeats and remove User A's avatar after the timeout period. The transition should not be instantaneous; there should be a grace period to handle transient network blips.

Preconditions: Heartbeat interval is 2 seconds. Presence timeout is 5 seconds (missing roughly 2 heartbeats triggers removal). Both users are viewing the document and showing as online.

This scenario is critical because it tests the server-side timeout logic rather than relying on a clean WebSocket.close() event. In production, users lose connectivity, laptop lids close, and mobile browsers get killed by the OS. The presence system must handle all of these gracefully, and the only way to test it is to simulate an unclean disconnect by blocking network traffic rather than closing the page.

Playwright Implementation

tests/presence/heartbeat-expiry.spec.ts
Heartbeat Expiry Test Output

Heartbeat Expiry: Playwright vs Assrt

test('missed heartbeats trigger avatar removal', async ({ browser }) => {
  const ctxA = await browser.newContext({ storageState: './auth/user-a.json' });
  const ctxB = await browser.newContext({ storageState: './auth/user-b.json' });
  const pageA = await ctxA.newPage();
  const pageB = await ctxB.newPage();
  await pageB.goto('/docs/test-document-123');
  await pageA.goto('/docs/test-document-123');
  const avatars = pageB.locator('[data-testid="presence-avatar"]');
  await expect(avatars).toHaveCount(2, { timeout: 5000 });
  const cdp = await pageA.context().newCDPSession(pageA);
  await cdp.send('Network.emulateNetworkConditions', {
    offline: true, downloadThroughput: 0, uploadThroughput: 0, latency: 0
  });
  await expect(avatars).toHaveCount(1, { timeout: 10_000 });
  await ctxA.close();
  await ctxB.close();
});
59% fewer lines
8

WebSocket Reconnection and State Recovery

Complex

Goal:When User A experiences a brief network interruption (under the heartbeat timeout), the WebSocket client should automatically reconnect, re-register presence, and User A's avatar should remain visible to other users without flickering. If the interruption exceeds the timeout and the avatar was removed, it should reappear upon reconnection.

Preconditions: The WebSocket client implements exponential backoff reconnection. The heartbeat timeout is 5 seconds. Both users are viewing the document.

Playwright Implementation

tests/presence/reconnection-recovery.spec.ts

What to Assert Beyond the UI

Monitor the WebSocket reconnection attempts by intercepting frames on User A's page. Verify the client uses exponential backoff (the delay between reconnection attempts should increase). After reconnection, verify the client sends a fresh presence:joinmessage and that the server responds with the current participant list. This ensures the client's local presence state is synchronized with the server after the outage.

9. Common Pitfalls That Break Presence Test Suites

Presence testing introduces a category of failures that do not exist in typical request/response test suites. These are the pitfalls that teams consistently encounter when building presence test coverage, drawn from GitHub issues and community forums for popular real-time frameworks including Socket.IO, Ably, Pusher, and LiveKit.

Presence Testing Anti-Patterns

  • Using fixed setTimeout delays instead of polling assertions. Heartbeat intervals vary under load, and hardcoded waits cause flaky tests. Use Playwright's expect().toHaveCount() with a timeout instead.
  • Sharing a single browser context for multiple users. Each context shares cookies and WebSocket connections, so user deduplication tests become meaningless. Always create separate contexts per user.
  • Forgetting to close browser contexts in afterEach. Leaked contexts keep WebSocket connections alive, causing phantom presence entries that pollute subsequent tests.
  • Testing presence on a page that also runs complex background tasks. Heavy JavaScript on the page can delay WebSocket message processing, making presence updates appear slow. Use a minimal test page with only presence logic.
  • Assuming WebSocket messages arrive in order. Under network congestion, presence:leave can arrive before the final heartbeat. Your UI must handle out-of-order messages gracefully.
  • Not testing the race condition where two users join simultaneously. If the server processes joins concurrently, both users may receive stale participant lists that exclude the other. Verify eventual consistency.
  • Ignoring timezone and system clock drift in CI. Heartbeat expiry relies on server-side timestamps. If your CI runner's clock is skewed, timeouts trigger at unexpected intervals.
  • Running presence tests in parallel without isolated channels. If two test files share the same document ID, their presence states interfere. Use unique document IDs per test or per test file.

Real-World Failure: Ghost Avatars in Production

One of the most commonly reported presence bugs across collaboration tools is the “ghost avatar” problem. A user's avatar remains visible long after they have left the document, sometimes for hours. This typically happens when the WebSocket connection is severed without a clean close event (mobile browser killed by OS, laptop lid closed on WiFi), and the server's heartbeat timeout is set too high or not implemented at all. The fix is always the same: server-side TTL on presence entries with periodic cleanup, and a heartbeat interval that is aggressive enough to detect stale connections within a reasonable window. Your test suite should include the heartbeat expiry scenario (Section 7) specifically to catch regressions in this logic.

Common Test Failure: Ghost Avatar

Pre-Flight Checklist for Presence Tests

  • Heartbeat interval set to 2-3 seconds in test environment
  • Presence timeout set to 5 seconds in test environment
  • Idle threshold set to 3 seconds in test environment
  • Unique document IDs per test to prevent state pollution
  • Separate browser contexts per simulated user
  • afterEach hook closes all browser contexts
  • WebSocket server logs enabled for debugging flaky tests
  • CI runner has sufficient memory for 8+ concurrent contexts

10. Writing These Scenarios in Plain English with Assrt

The Playwright implementations above are thorough but verbose. Each scenario requires boilerplate for creating browser contexts, managing page lifecycles, setting up WebSocket interception, and manually orchestrating timing. Assrt lets you express the same scenarios in plain English, and the Assrt runtime handles context management, multi-user coordination, and assertion timing automatically.

Here is the multi-tab deduplication scenario from Section 4, rewritten as an .assrt file. Notice that the multi-context orchestration, tab lifecycle management, and timing synchronization are all implicit. You describe what should happen, and Assrt figures out the how.

tests/presence/multi-tab-dedup.assrt

Compare the 50+ lines of Playwright TypeScript (creating contexts, opening pages, waiting for selectors, closing tabs one by one, asserting counts at each step) with the Assrt version that captures the same behavioral requirements in plain, readable sentences. The Assrt runtime translates each step into the appropriate Playwright API calls, manages the browser contexts, and handles synchronization and cleanup automatically.

For teams building collaborative applications, presence testing is particularly well suited to Assrt because the scenarios are inherently multi-actor and timing-sensitive. Expressing “Alice opens 3 tabs, Bob still sees 1 avatar” in plain English is both more readable for code review and less prone to the timing bugs that plague hand-written Playwright tests with hardcoded waits.

Full Presence Suite: Playwright vs Assrt

// 5 test files, ~400 lines total
// - basic-online.spec.ts (45 lines)
// - multi-tab-dedup.spec.ts (65 lines)
// - avatar-stack-overflow.spec.ts (80 lines)
// - idle-transition.spec.ts (70 lines)
// - heartbeat-expiry.spec.ts (75 lines)
// - reconnection-recovery.spec.ts (85 lines)
// Each file: context setup, page lifecycle,
// WebSocket interception, manual timing, cleanup
85% fewer lines

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk