Skip to content

Race condition: dispatchEvent crashes on uninitialized channels via _checkInitialized in muteStatus #1732

@ilken

Description

@ilken

Summary

StreamChat.dispatchEvent() dispatches incoming WebSocket events to any channel found in client.activeChannels[cid], without checking whether the channel has completed initialization. When client.channel(type, id) is called, the channel is registered in activeChannels immediately with initialized = false. If a message.new event arrives for that channel before .watch() resolves (or if .watch() is intentionally skipped, e.g. with query({ watch: false })), the SDK throws:

Error: Channel <cid> hasn't been initialized yet. Make sure to call .watch() and wait for it to resolve

Confirmed in stream-chat@9.38.0. We're seeing ~20 errors/week across 5 distinct users on stream-chat-expo@8.13.7stream-chat@9.38.0, primarily on iOS in production.

Call stack

_checkInitialized (channel.ts → dist/esm/index.mjs:9644)
muteStatus (channel.ts → 8639)
_countMessageAsUnread (channel.ts → 8950)
_handleChannelEvent (channel.ts → 9330)
dispatchEvent (client.ts → 12867)

_countMessageAsUnread runs for message.new events and calls muteStatus()_checkInitialized(), which throws when initialized === false.

Reproduction

  1. Connect a user to a channel they're a member of (so the WS subscribes and starts receiving events for that channel).
  2. From a second client, send message.new events at a steady rate to that channel.
  3. On the first client, call client.channel(type, id) and intentionally do NOT immediately await .watch(). Either:
    • Defer .watch() for ~500ms, or
    • Call query({ watch: false, state: false }) and never .watch().
  4. Observe the throw in dispatchEvent when a message.new event lands during the window.

In production this is reliably triggered by:

  • Deep-linked navigation into channels — .watch() is async (~100–500ms), and a message.new arriving in that window crashes the dispatch.
  • Screens that fetch member counts via query({ watch: false, state: false }) and never watch — the channel sits in activeChannels permanently in an uninitialized state, so any subsequent message.new event for it crashes.

Proposed fix

Guard dispatchEvent against uninitialized channels — they have no state to update, so skipping is safe:

const channel = cid ? this.activeChannels[cid] : void 0;
-if (channel) {
+if (channel && channel.initialized) {
   channel._handleChannelEvent(event);
}

Why this is safe

  • _handleChannelEvent on an uninitialized channel has no message/member/unread state to update — every meaningful state mutator hits _checkInitialized() and throws.
  • The current behavior (throw) interrupts the entire WS dispatch cycle for the batch, which can mask events for other channels that happen to share the cycle.
  • Once .watch() resolves, the initial state load already includes a fresh snapshot, so no events are lost — they're effectively replayed via the snapshot.
  • Channels that the app never calls .watch() on (intentional read-only patterns) are exactly the ones that can't meaningfully process events anyway.

Workaround

We've shipped a yarn patch applying the one-line guard above to all three dist outputs (dist/esm/index.mjs, dist/cjs/index.browser.js, dist/cjs/index.node.js). Happy to open a PR if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions