Real-time UI failures are different from normal web app failures. A checkout page can be wrong and still be obviously wrong. A chat app, trading dashboard, collaborative editor, or ops console can look healthy while quietly drifting out of sync. The socket reconnects, the browser renders something, and the user assumes the app recovered. In reality, one missed message, one duplicate event, or one stale snapshot can leave the UI in a state that is technically connected but operationally broken.

That is why I treat WebSocket testing as a state recovery problem, not just a transport problem. If I only assert that the socket opens and stays open, I miss the failures that matter. I want to know whether the client can reconnect, resubscribe, replay missed events, deduplicate duplicates, and rebuild live state without user confusion. That means testing the browser, the application protocol, and the failure boundaries together.

This article is a practical guide to how I test WebSocket reconnects in web apps, with a focus on the failure modes that make automation brittle. I will use WebSocket and test automation in the strict sense, but the real goal is operational confidence, not green lights.

The failure modes that matter

A WebSocket connection can fail in many ways, and the app behavior depends on where the failure occurs. I separate the cases because each one needs a different assertion.

1. Hard disconnects

The socket closes cleanly or the network drops. The client notices, reconnects, and resumes. This is the easy case, but even here there are traps:

  • reconnection storms after temporary outages
  • socket re-open before auth tokens are refreshed
  • duplicate subscriptions after reconnect
  • stale UI state left on screen while the app reconnects

2. Silent message loss

The socket remains open, but a server-side queue, proxy, or client bug drops an event. This is harder to catch because the browser thinks the transport is fine. Your test needs a way to prove the event should have arrived.

3. Out-of-order delivery

Real-time systems often use multiple channels, fan-out workers, or retries. A reconnect can produce events in a different order than the original stream. If the UI relies on monotonic state transitions, order matters.

4. Duplicate delivery

A reconnect often replays a window of missed events. If the client applies those events twice, counters, badges, chat messages, or row updates become wrong. I treat idempotency as part of the test contract.

5. Snapshot mismatch after reconnect

The biggest issue is not the event stream itself, it is the state model. After reconnect, the client may need a snapshot plus deltas. If the snapshot is stale, or the delta window starts at the wrong cursor, the app can appear connected but show the wrong state.

A real-time test that only checks “socket open” is a transport test, not a recovery test.

What I verify in a reconnect test

When I design a reconnect test, I look for five assertions:

  1. The app detects the disconnect promptly enough to take action.
  2. The app reconnects with the right backoff and retry policy.
  3. The client re-authenticates or re-authorizes if required.
  4. The app resubscribes and replays missed messages correctly.
  5. The visible UI reaches the right live state, not just a connected state.

That last point is the one teams skip. A socket can reconnect and the UI can still be wrong. For example, a notification count might show 7 when the server state is 9, or a table might fail to insert a row that arrived during downtime.

I test the protocol and the UI separately, then together

A clean strategy is to test the protocol behavior at the application layer, and the visual recovery behavior in the browser. If you only do browser-level testing, debugging becomes expensive. If you only test the socket API, you miss DOM timing issues and state rendering bugs.

A practical split looks like this:

  • Protocol tests, validate reconnect, replay, dedupe, and cursor handling at the message layer
  • Browser tests, validate UI indicators, state restoration, and user-visible consistency
  • End-to-end tests, validate the full chain from server event to rendered state

This structure matters because reconnect bugs often hide in the seams. A server test can pass while the browser loses a render update. A browser test can pass locally and fail in CI because timing changes the sequence of events.

Instrumentation I want in the app

Before I automate anything, I want the app to expose state that is safe to assert against. Usually that means one or more of these:

  • a connection status indicator, such as connecting, connected, reconnecting, offline
  • a monotonically increasing event cursor or sequence number
  • a last-synced timestamp
  • a list of applied message IDs, if the product model allows it
  • a retry counter, if the UI reflects reconnect attempts

Without some internal signal, tests end up relying on vague DOM text or timing guesses. That is how flakiness gets introduced.

A reliable way to simulate disconnects

The best reconnect tests deliberately control the failure. I prefer deterministic failure injection over “wait for the network to be bad.” Random failures produce random confidence.

There are several ways to force a disconnect:

  • close the WebSocket from the server side
  • disable the network interface in the browser automation layer
  • block the WebSocket endpoint with a proxy or test harness
  • send an auth expiration event and require refresh on reconnect

The best choice depends on what you want to prove. If you need to test client recovery logic, server-initiated close events are often enough. If you want to test timeout behavior or browser retry handling, network-level interruption is better.

Example: Playwright network interruption

Playwright is often a better fit than Selenium for this class of test because it gives you more direct control over browser context and network behavior. A simple example is to block the WebSocket endpoint and observe recovery behavior.

import { test, expect } from '@playwright/test';
test('reconnects and restores live state after websocket interruption', async ({ page, context }) => {
  await page.goto('https://app.local/dashboard');

await expect(page.getByTestId(‘connection-status’)).toHaveText(‘connected’);

await context.route(‘/ws/’, route => route.abort());

await expect(page.getByTestId(‘connection-status’)).toHaveText(/reconnecting offline/);

await context.unroute(‘/ws/’);

await expect(page.getByTestId(‘connection-status’)).toHaveText(‘connected’); await expect(page.getByTestId(‘live-count’)).toHaveText(‘9’); });

This is intentionally simple. In a real app, I would also verify that the counter did not briefly jump to the wrong value, that the reconnect delay stays within policy, and that the final state matches the expected server-side source of truth.

Lost message handling is not optional

Lost message handling is where many teams discover they have a messaging layer, but not a state recovery strategy.

A reconnect test should answer three questions:

  1. Did the client detect that it missed events?
  2. Did it request the correct replay window or snapshot?
  3. Did it reconcile the replay without duplicates?

If the app uses sequence numbers, this is straightforward to test. If it uses timestamps only, it is much easier to get wrong because clock drift and same-millisecond events create ambiguity.

A test pattern for replay windows

I like a pattern where the server emits messages with sequence numbers, the test intentionally drops a range, and the client is expected to request replay from the last acknowledged sequence.

Pseudo-flow:

  1. Open the app and establish a socket.
  2. Record the last applied sequence number.
  3. Drop or delay the next few messages.
  4. Reconnect.
  5. Verify that the app requests replay from the correct cursor.
  6. Verify that the UI shows all expected records exactly once.

The exact implementation depends on your protocol. The key is to assert the cursor, not just the final DOM content. If you only assert final content, duplicate application bugs can hide.

If a reconnect test cannot explain how the client knows what it missed, it is probably not testing recovery.

Live state recovery means more than reloading data

A lot of apps recover by refetching the whole page state after reconnect. That can work, but it is not always sufficient. A full refetch can hide protocol bugs, create heavy load, and make the app feel sluggish. It also does not prove that incremental recovery is correct.

I evaluate live state recovery in two modes:

Full snapshot recovery

The client reloads the canonical state after disconnect. This is useful when the app state is small or the stream is hard to replay.

What I test:

  • the snapshot contains the right objects
  • the snapshot clears stale client state
  • the UI does not momentarily show old and new values together
  • the app does not double-apply any pending local edits

Incremental recovery

The client fetches missed events from a cursor and merges them into live state.

What I test:

  • cursor correctness
  • deduplication by event ID or version
  • ordering guarantees
  • conflict resolution when local edits overlap with server updates

Incremental recovery is harder, but it scales better and usually produces a better real-time experience. The tradeoff is complexity. More moving parts means more test coverage is needed.

How I avoid flaky reconnect tests

Reconnect tests are naturally flaky if they depend on arbitrary sleeps. The browser is asynchronous, the network is asynchronous, and the server is asynchronous. If the test does not wait on a meaningful condition, it will fail for the wrong reason.

I do not wait for time, I wait for state

Bad pattern:

typescript

await page.waitForTimeout(5000);

Better pattern:

typescript

await expect(page.getByTestId('connection-status')).toHaveText('connected');
await expect(page.getByTestId('live-count')).toHaveText('9');

State-based assertions reduce false positives and false negatives. They also make the test intent obvious to the next engineer.

I cap retry windows in the app

A client that retries forever can mask a bug and hang the suite. I want predictable backoff limits and a visible failure state after the retry budget is exhausted. That makes both production behavior and test behavior easier to reason about.

I make reconnect logic observable

When I can, I log or expose a small set of structured events in test mode:

  • socket opened
  • socket closed with reason
  • reconnect scheduled
  • replay requested from cursor X
  • snapshot applied
  • UI state synchronized

This is not about building a test-only system. It is about surfacing the app’s own state machine. Without observability, debugging turns into guesswork.

Selenium still works, but be honest about what it gives you

Selenium can absolutely drive these tests, especially if your team already has a large Selenium stack. The browser automation model is enough to verify UI recovery. The limitation is not Selenium itself, it is how much network and browser instrumentation you can access cleanly from your stack.

If I use Selenium, I usually keep the test narrower and rely on application hooks or a controllable backend fixture.

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

driver = webdriver.Chrome() driver.get(‘https://app.local/dashboard’)

wait = WebDriverWait(driver, 10) wait.until(EC.text_to_be_present_in_element((By.CSS_SELECTOR, ‘[data-testid=”connection-status”]’), ‘connected’)) wait.until(EC.text_to_be_present_in_element((By.CSS_SELECTOR, ‘[data-testid=”live-count”]’), ‘9’))

The main tradeoff is that Selenium often gives you less ergonomic control over network interception. That does not make it the wrong tool, but it does mean you need a stronger test harness around it.

What I assert in the DOM

For a real-time app, I avoid assertions that are too vague or too coupled to presentation details. I want the DOM to expose stable, testable state.

Good candidates:

  • connection badge text or status attribute
  • message list length
  • last message ID
  • unread count
  • sync indicator or “up to date” marker

Poor candidates:

  • transient animation states
  • exact CSS classes for loading spinners
  • timing-dependent toast text
  • implementation-specific DOM structure

A test that depends on the exact animation frame is a maintenance cost, not coverage.

CI/CD strategy for reconnect tests

Reconnect tests belong in continuous integration, but not every failure mode belongs in every commit gate. Continuous integration is most useful when you separate fast feedback from deeper stateful scenarios.

I usually structure the pipeline like this:

  • PR gate, one deterministic reconnect smoke test, one state recovery test, basic UI assertions
  • Nightly or scheduled suite, replay windows, duplicate delivery, sequence gaps, auth refresh, longer network interruptions
  • Release candidate suite, broader browser matrix and environment-specific checks

This keeps the pipeline usable. If the reconnect suite is too heavy, teams start skipping it or debugging it only after an incident.

Example GitHub Actions job

name: realtime-tests

on: pull_request: schedule: - cron: ‘0 2 * * *’

jobs: web-realtime: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install –with-deps - run: npm run test:realtime

The important part is not the YAML itself. It is the discipline of separating quick validation from deeper recovery coverage.

Failure cases I specifically try to catch

These are the bugs I expect a serious reconnect test suite to expose:

Duplicate event application after reconnect

Common when the client replays from an older cursor and does not dedupe by event ID or version.

Missing subscription after reconnect

The socket is open, but one channel was never resubscribed, so part of the UI stops updating.

Auth token refresh race

The reconnect succeeds, but the new connection uses an expired token because refresh and reconnect are racing.

Stale cache after live update

The message stream is correct, but the UI is rendering cached data from a previous snapshot.

Incorrect recovery after tab sleep or backgrounding

Browsers can suspend timers or throttle activity. A real-time app should handle returning from background without assuming the socket state is still current.

Split-brain UI state

One component thinks the app is connected, another thinks it is offline, and both are right according to their own local state. This is a design problem, but tests can catch it.

A practical test matrix

If I were building coverage from scratch, I would start with this matrix:

Scenario Failure injection Main assertion
Clean disconnect Server closes socket Client reconnects and resumes
Temporary network loss Block WebSocket endpoint UI shows reconnecting, then recovers
Missed messages Drop a message window Replay request uses correct cursor
Duplicate replay Replay overlap UI applies each event once
Auth expiry Expire token mid-session Client refreshes and resubscribes
Background return Simulate browser throttling UI syncs to live state

That matrix is enough to catch most of the brittle behavior I see in real-time web apps. It is not exhaustive, but it is a good starting point.

My opinionated bottom line

If your app depends on live data, do not test reconnects as if they were a network health check. Test them as a state recovery problem. The message transport is only one part of the story. The harder part is proving that the browser rebuilds the correct state after disruption, without duplicates, gaps, or stale UI.

The teams that do this well usually have three things in common:

  • explicit sequence or cursor handling
  • observable connection and sync state in the UI
  • deterministic failure injection in automated tests

The teams that struggle usually rely on hope, manual verification, and “it reconnected locally” as evidence. That is not enough for production software.

If you are building or maintaining a chat app, dashboard, collaborative tool, or any browser experience that depends on live events, make reconnect testing a first-class part of your automation strategy. The cost of a missed message is almost never the socket itself. It is the corrupted state that follows.

Further reading

A good reconnect suite does not just prove the app can reconnect. It proves the app can recover, reconcile, and stay trustworthy after the connection comes back.