Chat Feature Testing Guide

How to Test Typing Indicator Chat: Debounce, Presence, and Animation

A scenario-by-scenario walkthrough of testing typing indicators in real-time chat applications with Playwright. Covers debounce timing, presence decay timeouts, multi-user indicator display, CSS animation state verification, and WebSocket event throttling that silently breaks production.

3B+

Messaging apps handle over three billion daily active users worldwide, and typing indicators are among the most latency-sensitive UI features in every one of those applications.

Statista, 2025 Global Messaging Report

0msTypical debounce delay
0sPresence decay timeout
0Scenarios covered
0%Fewer lines with Assrt

Typing Indicator End-to-End Flow

User A (Browser)Chat ClientWebSocket ServerPresence ServiceUser B (Browser)Keystroke in inputtyping:start (debounced)Update presence stateBroadcast to channeltyping:start eventShow indicator animationUser stops typing (idle)typing:stop (after timeout)typing:stop eventHide indicator

1. Why Testing Typing Indicators Is Harder Than It Looks

A typing indicator appears simple on the surface: the user types, a “User is typing...” message appears for other participants, and it disappears when they stop. But beneath that simplicity lies a stack of timing-sensitive behaviors that make automated testing genuinely difficult. The core problem is that typing indicators operate on multiple intersecting timers, and those timers interact with network latency, browser rendering cycles, and WebSocket connection state in ways that produce subtle, hard-to-reproduce failures.

Five structural challenges make this feature particularly resistant to reliable test automation. First, debounce logic means the client does not emit a WebSocket event on every keystroke. Instead, it batches keystrokes and sends a single “typing:start” event after a quiet period (typically 300ms). Your test must account for this delay without introducing brittle waitForTimeoutcalls that slow your suite. Second, presence decay means the typing indicator automatically disappears after a server-side timeout (commonly 3 to 5 seconds) even if no explicit “typing:stop” event arrives. Testing this requires precise time control. Third, multi-user display logic changes the indicator text dynamically: “Alice is typing” becomes “Alice and Bob are typing” and then “3 people are typing” as more users join, each transition happening in real time. Fourth, the bouncing dots animation is a CSS or Lottie animation whose running state is invisible to standard DOM assertions. Fifth, WebSocket event throttling on the server side can silently drop or coalesce typing events under load, making the indicator vanish or appear stale.

Typing Indicator Lifecycle

🌐

Keystroke

User presses a key

⚙️

Debounce Gate

Wait 300ms of silence

🔔

WebSocket Emit

typing:start sent

⚙️

Server Broadcast

Fan out to channel

🌐

Remote Render

Show indicator UI

⚙️

Decay Timer

5s server timeout

Auto-Hide

Indicator removed

2. Setting Up a Reliable Test Environment

Testing typing indicators requires a chat environment where you control both the sender and the receiver. You need two browser contexts (or two pages within the same context) connected to the same chat channel through the same WebSocket server. The most reliable approach is to spin up a local chat server with configurable debounce and decay timers, so your tests do not depend on production timing values that other teams might change.

Your environment variables control the timing behavior. Setting shorter debounce and decay values in test mode keeps your suite fast without losing coverage of the timing logic itself. The key insight is that you are testing the behavior of the debounce and decay mechanisms, not the specific production millisecond values.

.env.test
Project Setup

Playwright Configuration for Dual-Context Tests

You need a Playwright configuration that supports two isolated browser contexts connecting to the same chat room. The fixture below creates both contexts, logs each user in, and joins them to the same channel before every test. This pattern avoids per-test setup overhead while keeping the contexts fully isolated.

fixtures/chat-fixture.ts

Test Environment Architecture

🌐

Alice Context

Browser context 1

🔔

WebSocket

ws://localhost:3001

⚙️

Chat Server

Presence + broadcast

🔔

WebSocket

ws://localhost:3001

🌐

Bob Context

Browser context 2

1

Debounce Timing Verification

Moderate

3. Scenario: Debounce Timing Verification

Goal:Verify that the typing indicator on the remote user's screen appears only after the debounce period elapses, not on every individual keystroke. This confirms that the client batches keystroke events correctly before emitting the WebSocket typing event.

Preconditions: Both Alice and Bob are connected to the same chat channel. The debounce period is configured to 300ms. No typing activity has occurred in the channel.

The test types a series of characters rapidly (within the debounce window) and then checks the remote side. The key assertion is that the WebSocket only fires one “typing:start” event for the entire burst, not one per keystroke. We intercept WebSocket frames to count emitted events.

tests/typing-debounce.spec.ts

What to Assert Beyond the UI

Debounce Assertions

  • WebSocket sends exactly one typing:start per keystroke burst
  • No typing event fires if input returns to empty before debounce flushes
  • Debounce timer resets on each new keystroke within the window
  • Remote indicator appears within 50ms of debounce flush
2

Presence Decay Timeout

Complex

4. Scenario: Presence Decay Timeout

Goal:Verify that the typing indicator disappears automatically after the server-side decay timeout, even if the client never sends an explicit “typing:stop” event. This covers the case where a user closes their browser tab mid-typing, loses their network connection, or simply walks away from the keyboard.

Preconditions: Both users are in the same channel. The decay timeout is set to 5 seconds. Alice has triggered a typing event.

This scenario is one of the hardest to test reliably because it depends on precise server-side timing. The naive approach of using waitForTimeout(5000)is fragile: it adds 5 seconds to every test run and can flake if the server timer drifts by even 100ms. A better strategy is to use Playwright's expect.poll to repeatedly check the indicator visibility until it disappears, with a timeout slightly longer than the decay period.

tests/typing-decay.spec.ts

Presence Decay: Playwright vs Assrt

test('indicator disappears after decay', async ({
  alicePage, bobPage,
}) => {
  const input = alicePage.getByTestId('message-input');
  await input.pressSequentially('hello', { delay: 30 });
  await expect(
    bobPage.getByTestId('typing-indicator')
  ).toBeVisible();
  await expect(
    bobPage.getByTestId('typing-indicator')
  ).not.toBeVisible({ timeout: 7000 });
});
55% fewer lines

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started
3

Multi-User Typing Display

Complex

5. Scenario: Multi-User Typing Display

Goal:Verify that the typing indicator correctly displays one user, two users, and three or more users typing simultaneously. Most chat applications follow a standard pattern: “Alice is typing” for one user, “Alice and Bob are typing” for two, and “3 people are typing” for three or more. Each transition must be tested because the text formatting logic is a common source of bugs (especially around pluralization and name ordering).

Preconditions: Three browser contexts are connected to the same channel. A third user (Charlie) is added to the test fixture.

This scenario requires three browser contexts, which means three separate WebSocket connections to the same channel. The orchestration challenge is triggering typing events from multiple users in a controlled sequence so you can assert each intermediate state of the indicator text.

tests/typing-multi-user.spec.ts
4

Animation State Verification

Moderate

6. Scenario: Animation State Verification

Goal: Verify that the bouncing dots (or pulsing ellipsis) animation is actually running when the typing indicator is visible, and that it stops cleanly when the indicator disappears. This catches a surprisingly common bug where the indicator text appears but the animation is paused, frozen on the first frame, or continues running after the indicator is hidden (wasting CPU cycles).

Preconditions: Both users are in the channel. The typing indicator uses CSS keyframe animations on child elements (the common bouncing dots pattern). The animation name is bounce or similar.

Standard Playwright assertions like toBeVisible and toHaveCSS do not directly tell you whether a CSS animation is running. You need to use page.evaluate to call getComputedStyle and read the animationPlayState property, or check getAnimations() on the element to inspect the running state of each animation.

tests/typing-animation.spec.ts

Animation Check: Playwright vs Assrt

test('dots animation is running', async ({
  alicePage, bobPage,
}) => {
  await alicePage.getByTestId('message-input')
    .pressSequentially('hi', { delay: 30 });
  const indicator = bobPage.getByTestId('typing-indicator');
  await expect(indicator).toBeVisible();
  const states = await bobPage.evaluate(() => {
    const dots = document.querySelectorAll('.typing-dot');
    return Array.from(dots).map(d =>
      d.getAnimations()[0]?.playState
    );
  });
  states.forEach(s => expect(s).toBe('running'));
});
64% fewer lines
5

WebSocket Event Throttling

Complex

7. Scenario: WebSocket Event Throttling

Goal: Verify that the server-side WebSocket throttle correctly limits how frequently typing events are broadcast. Most chat servers throttle typing events to one broadcast per user per 2 to 3 seconds, regardless of how many events the client sends. This prevents a fast typist from flooding the WebSocket channel with hundreds of events per minute.

Preconditions: The server throttle interval is set to 2 seconds. Alice will type continuously across multiple debounce windows. Bob intercepts incoming WebSocket frames to count how many typing events arrive.

The test strategy is to have Alice type continuously for 6 seconds (spanning multiple debounce windows) and then count the typing events that Bob receives. With a 2-second server throttle, Bob should receive at most 3 typing events in that 6-second window, even though Alice's client may have emitted more.

tests/typing-throttle.spec.ts
Throttle Test Output
6

Race Conditions Between Send and Typing Stop

Complex

8. Scenario: Race Conditions Between Send and Typing Stop

Goal:Verify that sending a message immediately clears the typing indicator on all remote clients, even if the “typing:stop” and “message:sent” events arrive out of order on the WebSocket. This is one of the most common race condition bugs in chat applications: the user presses Enter, the message appears in the chat, but the typing indicator lingers for another 2 to 5 seconds because the “typing:stop” event was delayed or dropped.

Preconditions:Alice is actively typing and the indicator is visible on Bob's screen. Alice presses Enter to send.

Well-implemented chat clients clear the typing indicator locally the moment a new message arrives from that user, regardless of whether a “typing:stop” event has been received. The test verifies both the happy path (events arrive in order) and the adversarial case (message arrives before typing:stop).

tests/typing-send-race.spec.ts

Send Race: Playwright vs Assrt

test('send clears typing indicator', async ({
  alicePage, bobPage,
}) => {
  const input = alicePage.getByTestId('message-input');
  await input.pressSequentially('hello!', { delay: 30 });
  await expect(
    bobPage.getByTestId('typing-indicator')
  ).toBeVisible();
  await input.press('Enter');
  await expect(
    bobPage.getByTestId('typing-indicator')
  ).not.toBeVisible({ timeout: 500 });
  await expect(
    bobPage.getByText('hello!')
  ).toBeVisible();
});
43% fewer lines

9. Common Pitfalls That Break Typing Indicator Tests

Typing indicator tests are among the flakiest in any chat test suite. The following pitfalls come from real-world issues reported in open source chat projects, Slack SDK bug trackers, and Socket.IO GitHub issues. Each one has caused CI failures in production teams.

Pitfalls to Avoid

  • Using hardcoded waitForTimeout instead of polling for indicator state. Server timers drift, CI runners are slow, and your test becomes a coin flip.
  • Not accounting for the debounce delay in assertions. If you check for the indicator immediately after typing, the debounce has not flushed yet and your assertion fails.
  • Testing on a single browser context. You cannot verify the remote side of a typing indicator with only one user. Always use two or more contexts.
  • Forgetting that page.fill() does not trigger keydown/keyup events the same way pressSequentially does. Some typing indicator implementations only listen for keydown, so fill() never triggers the typing event.
  • Ignoring WebSocket reconnection during tests. If the WebSocket connection drops and reconnects mid-test, the typing indicator state is lost and your assertions break.
  • Not clearing typing state between tests. A typing event from the previous test can bleed into the next test if the decay timeout has not expired.
  • Asserting exact animation frame positions. CSS animation timing is not deterministic in headless mode. Assert that the animation is running, not that it is at a specific keyframe percentage.
  • Running typing tests in parallel without isolated channels. Two tests writing to the same channel will see each other's typing events and produce false positives.
Common Failure Patterns

Fix: Use display:none, Not visibility:hidden

The orphaned animation bug occurs when the typing indicator is hidden with visibility: hidden or opacity: 0 instead of display: none. CSS animations continue running on elements that are invisible but still in the layout. The fix is to conditionally render the indicator component (remove it from the DOM entirely) rather than hiding it with CSS. This also improves performance because the browser stops computing animation frames for an element that does not exist.

Fix: Increase Timeout with a Multiplier

For the decay timeout flake, do not increase the timeout to an arbitrary large number. Instead, read the decay timeout from your environment configuration and apply a 1.5x multiplier. This keeps the test tightly coupled to the real timeout while accounting for CI runner variance. For example, if TYPING_DECAY_TIMEOUT_MS is 5000, your assertion timeout should be 7500.

helpers/timing.ts

10. Writing These Scenarios in Plain English with Assrt

Every scenario above involves coordinating two or more browser contexts, intercepting WebSocket frames, evaluating computed styles, and managing timeouts with multipliers. The Playwright code is powerful but verbose. Assrt lets you express the same scenarios in plain English, and the engine handles the WebSocket interception, timing assertions, and multi-context orchestration for you.

Below is the debounce verification scenario rewritten as an Assrt file. Notice that the intent is identical: type rapidly, verify the indicator appears (but only after debounce), and confirm only one WebSocket event was emitted. The Assrt engine maps “type rapidly” to pressSequentially with a short delay, automatically intercepts the WebSocket frames, and applies the debounce-aware assertion timing.

scenarios/typing-debounce.assrt

The multi-user scenario becomes even more readable. Instead of manually creating three browser contexts, extending fixtures, and orchestrating the typing sequence, you declare three users and describe the expected indicator text at each stage.

scenarios/typing-multi-user.assrt

Assrt handles the timing complexity that makes raw Playwright tests flaky. When you write “wait for debounce to flush,” Assrt reads your environment configuration, applies the CI margin, and uses polling assertions instead of fixed timeouts. When the chat application changes its debounce or decay values, you update one environment variable and every scenario adapts automatically.

If the typing indicator DOM structure changes (say, the team renames data-testid="typing-indicator" to data-testid="presence-indicator"), Assrt detects the broken selector, locates the new element using its AI locator engine, and opens a pull request with the updated mapping. Your scenario files stay exactly the same.

Start with the debounce scenario. Once it passes in your CI, add the presence decay test, then multi-user display, then animation state, then the WebSocket throttle check, then the send race condition. Within a single afternoon you can have comprehensive typing indicator coverage that most chat applications never achieve through manual testing.

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