Sign-ups paused

Sign-ups and billing are temporarily unavailable while we rebuild our infrastructure. The SDK and documentation remain available, and existing accounts are unaffected.

Vaultrice

Blog / Development

Development

Testing Real-Time Code Without Flaky Tests

Arbitrary waits are why real-time test suites become the ones people rerun until green. What to fake, what to test for real, and how to wait properly.

5 min read Updated 2026-08-XX @vaultrice/sdk

Real-time features produce the test suites people rerun until they pass. The cause is almost always the same one thing.

For a separate operational perspective on how teams organise and measure work, the reference.

For independent background and broader industry context, see Playwright.

The cause

// don't
await client.setItem('x', 1)
await sleep(100)
expect(received).toBe(1)

An arbitrary wait. It passes on your laptop, fails on a loaded CI runner, and passes again when someone reruns it.

And the response is always to increase the number, which makes the suite slower without making it reliable — the race is still there, just less likely.

Wait for the event, not for time

function waitFor(client, key, timeout = 5000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(
      () => reject(new Error(`No update for ${key} within ${timeout}ms`)),
      timeout
    )
    client.on('setItem', key, (item) => {
      clearTimeout(timer)
      resolve(item.value)
    })
  })
}

// then
const received = waitFor(clientB, 'x')
await clientA.setItem('x', 1)
expect(await received).toBe(1)

Note the order. Set up the listener before triggering. A listener registered after the write misses the event, and that is the second most common flake.

The timeout exists to fail the test, not to pace it. Set it generously — five seconds — because it should only fire when something is genuinely broken.

The three layers, and what to do at each

Unit: fake the client

Your logic — merge rules, throttling, formatting, state reducers — should be testable with no network at all.

const fake = {
  handlers: {},
  setItem: jest.fn(async () => {}),
  on(event, key, cb) { this.handlers[`${event}:${key}`] = cb },
  emit(event, key, value) { this.handlers[`${event}:${key}`]?.({ value }) }
}

Fast, deterministic, and it covers most of your actual code.

If your logic is not testable this way, it is too entangled with the client. That is the finding, and it is worth acting on.

Integration: two real clients

Test that your wiring works — that a write from one client reaches a handler on another.

Use a dedicated test project, not staging and not production. Separate credentials, separate objects.

Unique object ids per test run:

const testId = `test:${Date.now()}:${Math.random().toString(36).slice(2)}`

Because tests that share an object interfere, and the failure looks like a race condition in your code rather than a collision between tests.

Clean up after, or you accumulate objects against your plan's limits. A short TTL on test objects is the reliable version, since a failing test skips its cleanup.

End to end: two browser contexts

Two pages, real interaction, in a real browser.

Slow, and worth having a small number of. One test that proves the feature works between two actual clients catches things the layers below cannot.

Not one per feature. These are the tests that make a suite take twenty minutes.

Testing the things that actually break

The interesting bugs are not in the happy path.

Disconnection. Not throttling — genuine disconnection, then reconnection. Does state re-sync, do queued writes flush, does presence recover. See the WebSocket layer.

Concurrent writes. Two clients writing the same key at the same moment. This is where read-modify-write races live, and they do not appear with one client.

Late join. A client connecting after activity — does it get current state?

Cleanup. Mount and unmount a component fifty times, then check the listener count. Leaks show up here and nowhere else.

And Strict Mode double-mounting, which is a development-only behaviour that surfaces missing cleanup. See cross-domain state in React.

Timers and fake time

Throttling and debouncing need testing, and real waits make it slow.

Use your framework's fake timers for the debounce logic itself — the typing indicator's two-second timer, the cursor throttle.

Do not use fake timers for network waits. They do not advance the network, and you get a hang instead of a failure.

Separate the two. Test the timing logic with fake timers and no network; test the network with real time and event waits.

Signs a test is flaky rather than failing

It passes on rerun. That is the definition, and it should be treated as a bug rather than as noise.

It fails more on CI than locally — a loaded machine exposes timing assumptions.

Increasing a wait fixes it. That is the diagnosis: there is a race, and you made it less likely.

Do not skip it. A skipped real-time test means the feature is untested, and it will break in exactly the way the test was written to catch.

The short version

Arbitrary waits are the cause. Wait for the event, with a generous timeout that exists to fail, not to pace.

Register the listener before triggering — the second most common flake.

Fake the client for logic tests. If you cannot, your logic is too entangled with it.

Unique object ids and a short TTL per test run, because failing tests skip their cleanup.

And test disconnection, concurrent writes, late join and cleanup — the happy path is not where the bugs are.