Skip to content

feat!: reenable WS fallback - #1889

Open
szuperaz wants to merge 24 commits into
release-v10from
add-back-ws-fallback
Open

szuperaz wants to merge 24 commits into
release-v10from
add-back-ws-fallback

Conversation

@szuperaz

@szuperaz szuperaz commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Breaking changes

  • transport.changed event renamed to connection.fallback_activated

https://linear.app/stream/issue/REACT-1181/reenable-ws-fallback

Reintroduce long-poll fallback if WS connection fails (and fallback is enabled).

How WS fallback works?

  • If WS fails to connect, and we're online, we try the WS fallback
  • Backend has a long-poll endpoint that we call, the sequence:
    1. To create a connection, we send an auth message, and the backend sends back a connection_id
    1. Once we have a connection id, we call again the long-poll endpoint with the id
  • 3.1 If no new events are received, backend returns an empty response after 20secs, and we call the poll endpoint again
  • 3.2 If we receive any event, backend immediately returns with the events, we dispatch the events, and call the poll endpoint again
  • If any error occurs that can be retired, we retry with the same connection id. Backend preserves the connection id for ~60-90s for us, so we reconnect with same connection id. If that id is gone, we get a clear error from backend, and do a full retry with new connection id and dispatch the connection.ok even if that was successful. If an error happens that can be retired, we set state to unhealthy, and wait for an "online" event to reconnect. Since the fallback sets the client.wsConnect.isHealthy flag, the recovery manager can kickstart a state recovery when necessary, so it should work the same way as it does for regular WS.

Implementation details

The main goal was to be as close to v9 as possible, Claude flagged a lot of potential bugs with ws fallback implementation, none of them is fixed on purpose to avoid unnecessary changes. Any change comes from adapting to v10's logic:

  • enableWSFallback -> moved to config service instead of ctr param
  • v9 used a 6s connection fallback when enableWSFallback: true, this is not there in v10, integrators can use the connectTimeoutMs param to lower the default 15secs if they want a shorter trial period
  • The fallback implementation sets isHealthy on the client.wsConnection class, just like on v9 the fallback took WS's place to report this
  • The WS fallback is only started if the network reporter reports we're online. Since the default reporter on Node and RN reports the WS status, we ignore the default reporter on those platforms and start fallback regardless of network status (v9 worked the same way). Since the RN SDK installs its own network reporter, we have accurate info in the RN SDK.

Changes on this branch that are not fallback related:

  • In StableWSConnection we abort an in-progress connection if disconnect was called while we were waiting on the token -> this potentially could've opened a WS connection after disconnect (in case of longpoll it would have meant receiving events on WS and long poll too).
  • disconnectUser will not reset tokenManager if a new connection was started while it waited for WS to close. This is a preexisting bug, nothing to do with this PR, but since Claude likes to report it anytime we have any WS-related change, it's fixed here finally.
  • ApiClient no longer waits for connectionId on requests where connection_id is part of the API request, but no watch/presence flag is set. This only affects stopWatching, connection id is now waited in client instead of ApiClient. Not a customer-facing change. The reason: for longPoll we have to be able to send the connection request without connection_id set.
  • ApiClient waits for in-progress tokens -> not related to this PR, but noticed while testing it, it seems waiting for the token wasn't ported, it doesn't cause issues when you wait for WS connection anyway, so that's why we missed it all this time (same in v9)

…ng after a token load

Two gaps in StableWSConnection, both reachable through closeConnection() while the
token provider is still loading:

- _connect() never re-checked isDisconnected after awaiting the token, so a socket
  was still built after disconnect(). It carried the current wsID, so its callbacks
  were live and it published a connection id and events behind the close.
- _reconnect() called _settleConnectPromises() even when _connect() returned early,
  resolving setUserPromise for a connection that was never made. That defeats
  connectUser's check that its own attempt is still the current one.
Restores v9's WSConnectionFallback at src/connection_fallback.ts and the
enableWSFallback client option, with v9's switching logic back in
client.connect(). The class changes only where v10 removed what it relied
on: it writes client.wsConnection.state instead of dispatching
connection.changed, drives ConnectionIdManager, follows
client.networkConnection instead of window events, polls /api/v2/longpoll,
and sends connection_id in the URL so the request layer's connection-id
gate does not hold its polls and close.
@szuperaz szuperaz changed the title Add back ws fallback feat: reenable WS fallback Sep 28, 2026
@szuperaz
szuperaz marked this pull request as ready for review September 29, 2026 21:31
Comment thread src/connection/networkConnection/NetworkConnectionObserver.ts
Comment thread src/connection/wsConnection/WSConnectionFallback.ts
// hosts without a network API mirrors the WebSocket, so its "offline" only means the socket
// is down, and is ignored.
const { networkConnection } = this.client;
const isDeviceOffline =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually cannot tell reliably if the device is offline if using custom reporter or the default WS reporter. In that case long-poll would be applied only in browsers and not RN (which relies on WS based reporter)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We never rely on default WS reporter here, exactly because that is not reliable. We trust custom reporter, the reasoning for that is in the above comment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that means that the RN SDK will not use WSConnectionFallback unless custom reporter is provided. Is that correct?

@szuperaz szuperaz Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, if the default reporer is used in RN, we will fallback, exactly because we ignore the unreliable offline signal from the default reporter.

RN SDK itself installs a custom reporter, and fallback works with that one too.

Comment thread src/connection/WSConnectionFallback.ts Outdated
Comment thread src/connection/wsConnection/WSConnectionFallback.ts Outdated
Comment thread src/connection/wsConnection/WSConnectionFallback.ts Outdated
Comment thread src/connection/wsConnection/WSConnectionFallback.ts Outdated
Comment thread src/types.ts Outdated
})
| { type: 'connection.recovered' }
// `enableWSFallback` switched from the WebSocket to long-polling.
| ({ type: 'transport.changed' } & { mode: string })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that 'connection.recovered' and 'transport.changed' belong to the same family / group of events meaning related to the WS connection (or just connection). I would probably namespace them the same way so that it is clear they belong together. Word 'transport' sounds ok, but could also be misleading if standing alone referring to any kind of transport protocol not related to maintaining the real-time connection.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same name brought back from v9, I'm not really sure it makes the breaking change to rename it, but if you want to do this and have a name in mind, I can rename it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about 'connection.fallback'. WDYT @szuperaz and @isekovanic ? Even though we are bringing something back, we have a unique opportunity to make it better.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We seem to be using verbs in the past tense here, so in the end used connection.fallback_activated

Comment thread src/connection/wsConnection/WSConnectionFallback.ts Outdated
Comment thread src/api-client.ts
const baseURL =
resolved === LONG_POLL_PATH
? // replace port if present for testing with local API
this.client.baseURL?.replace(':3030', ':8900')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why writing code for test purposes? Is there a purpose beyond fitting the test suite expectations?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have the same thing for WS URL too: https://github.com/GetStream/stream-chat-js/blob/release-v10/src/client.ts#L516 - we need these swaps to run the SDK with a local backend. Both are existing concepts in master too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyways looks smelly to hard-code this stuff. I think this belongs to the config service rather than hard-code some ports in the SDK code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we can do that, but then you have config params just for testing with local backend, because integrators would never need to set this. In any case, I don't think this PR is related. We can track it in Linear if we want to, but I don't think we should fix it here.

Comment thread src/client.ts Outdated
Comment thread src/connection/wsConnection/WSConnectionFallback.ts
Comment thread src/connection/wsConnection/WSConnection.ts
Comment thread src/client.ts Outdated
Comment thread src/connection/wsConnection/StableWSConnection.ts Outdated
Comment thread src/connection/wsConnection/WSConnectionFallback.ts
*
* @internal
*/
disconnect = async (timeout = 2000, connectionId?: string) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to pass the connectionId if we have access to the connectionIdManager? We already access the connectionIdManager here for example: https://github.com/GetStream/stream-chat-js/pull/1889/changes#diff-de6aeb8ccbca31560cf64bbc3f76666ba4dfbf4786de3368cda54d90987269f3R76

@szuperaz szuperaz Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We pass because connection id is wiped as soon as disconnect starts. But we need the connection id to be able to send close request. WS doesn't have the same issue.

Since connection id is reset by client we make this connection explicit by providing this as a method param, instead of relying only on client calling the method after wsFallback doesn't need it anymore, which would be implicit.

@szuperaz szuperaz changed the title feat: reenable WS fallback feat!: reenable WS fallback Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants