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.7 → stream-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
- Connect a user to a channel they're a member of (so the WS subscribes and starts receiving events for that channel).
- From a second client, send
message.new events at a steady rate to that channel.
- 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().
- 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.
Summary
StreamChat.dispatchEvent()dispatches incoming WebSocket events to any channel found inclient.activeChannels[cid], without checking whether the channel has completed initialization. Whenclient.channel(type, id)is called, the channel is registered inactiveChannelsimmediately withinitialized = false. If amessage.newevent arrives for that channel before.watch()resolves (or if.watch()is intentionally skipped, e.g. withquery({ watch: false })), the SDK throws:Confirmed in
stream-chat@9.38.0. We're seeing ~20 errors/week across 5 distinct users onstream-chat-expo@8.13.7→stream-chat@9.38.0, primarily on iOS in production.Call stack
_countMessageAsUnreadruns formessage.newevents and callsmuteStatus()→_checkInitialized(), which throws wheninitialized === false.Reproduction
message.newevents at a steady rate to that channel.client.channel(type, id)and intentionally do NOT immediately await.watch(). Either:.watch()for ~500ms, orquery({ watch: false, state: false })and never.watch().dispatchEventwhen amessage.newevent lands during the window.In production this is reliably triggered by:
.watch()is async (~100–500ms), and amessage.newarriving in that window crashes the dispatch.query({ watch: false, state: false })and never watch — the channel sits inactiveChannelspermanently in an uninitialized state, so any subsequentmessage.newevent for it crashes.Proposed fix
Guard
dispatchEventagainst uninitialized channels — they have no state to update, so skipping is safe:Why this is safe
_handleChannelEventon an uninitialized channel has no message/member/unread state to update — every meaningful state mutator hits_checkInitialized()and throws..watch()resolves, the initial state load already includes a fresh snapshot, so no events are lost — they're effectively replayed via the snapshot..watch()on (intentional read-only patterns) are exactly the ones that can't meaningfully process events anyway.Workaround
We've shipped a
yarn patchapplying 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.