Skip to content

Lifecycle-aware streaming: pause on background/offline, resume + resync on foreground#7

Merged
cosmos-explorer merged 12 commits into
featbit:mainfrom
Fluent-Health:feat/lifecycle-aware-streaming
Jun 16, 2026
Merged

Lifecycle-aware streaming: pause on background/offline, resume + resync on foreground#7
cosmos-explorer merged 12 commits into
featbit:mainfrom
Fluent-Health:feat/lifecycle-aware-streaming

Conversation

@alexheidl

Copy link
Copy Markdown
Contributor

Closes #4.

Summary

WebSockets don't survive iOS background suspension or Android doze, and the OS may never deliver a clean close. The current transport (BrowserWebSocket, via @featbit/js-client-sdk) keeps firing pings into a dead socket and, on resume, walks a blind reconnect-backoff schedule while serving stale flags.

This adds lifecycle-aware streaming: the stream is dropped shortly after the app backgrounds or goes offline, and re-established with an immediate resync on foreground / when connectivity returns. It's driven by React Native's AppState (and optional NetInfo), enabled automatically, and defaults to always-on, so behaviour is unchanged if no lifecycle signal ever fires. Inert in polling mode. The design mirrors a proven implementation in our native Android port.

What's included

  • LifecycleController — foreground-AND-online gating with a debounce (default 20s) so the transient iOS inactive state (app switcher, etc.) and momentary network blips don't churn the connection.
  • LifecycleAwareWebSocket — wraps BrowserWebSocket. On pause it drops the inner socket; on resume it builds a fresh one (because BrowserWebSocket.close() permanently sets its internal closed flag), reusing a single captured emitter so the data synchronizer's listeners survive the swap. No changes to js-client-sdk required.
  • ConditionalNetInfo — optional @react-native-community/netinfo, loaded the same conditional way as async-storage; AppState alone works without it.
  • README section documenting the behaviour.
  • Bug fix: buildConfig constructed ReactNativeStore with the pre-resolved options, so when an app supplies no logger the store's logger was undefined and NPE'd on its first log path, hanging initialization. It now passes the resolved fallback logger. (Surfaced by the e2e suite below.)

Tests

  • 14 Jest unit tests for the controller and wrapper (debounce, deferred connect while backgrounded, drop-and-rebuild, transient-inactive debounce, listener survival across pause/resume, teardown).
  • A Dockerized end-to-end suite (Testcontainers) driving a real client against a real FeatBit stack (Postgres + api-server + evaluation-server) over both polling and streaming, including a lifecycle test that backgrounds the app, asserts an update is not received while paused, then foregrounds and confirms resync.
  • Both run in CI; the e2e runs on a GitHub-hosted runner. Green run: https://github.com/Fluent-Health/featbit-react-native-sdk/actions/runs/27489242393

npm test runs the unit tests (no Docker). npm run test:e2e runs the e2e (gated by FEATBIT_E2E=1).

Notes

  • The included .github/workflows/ci.yml is optional — happy to drop that commit if you have your own CI conventions.

alexheidl and others added 6 commits June 12, 2026 12:27
…on foreground)

WebSockets don't survive iOS background suspension or Android doze, and the OS
may never surface a clean close. The default transport then fires pings into a
dead socket and, on resume, walks a blind reconnect-backoff schedule while
serving stale flags.

Add a LifecycleAwareWebSocket that wraps the SDK's BrowserWebSocket and drives
it from AppState (and optional NetInfo): the stream is dropped shortly after the
app backgrounds or goes offline and re-established with an immediate resync on
foreground/online. Transitions are debounced (default 20s) so the transient iOS
'inactive' state and momentary network blips don't churn the connection.
Defaults to always-on, so behaviour is unchanged if no lifecycle signal fires;
inert in polling mode.

NetInfo is an optional peer dependency, loaded the same conditional way as
async-storage. Closes featbit#4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers LifecycleController (debounce, foreground+online gating, dispose) and
LifecycleAwareWebSocket (deferred connect while backgrounded, drop-on-background
/ fresh-socket-on-foreground, transient iOS 'inactive' debounce, listener
survival across a pause/resume cycle via the shared emitter, and subscription
teardown on close). Mocks react-native AppState and the SDK's BrowserWebSocket.

14 tests, run with `npm test`. Production build excludes src/__tests__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the app supplies no logger, options.logger is undefined, so the store was
constructed without one and would NPE on its first debug/error log path (e.g.
when persisting the user), hanging initialization. Pass the resolved fallback
logger to the store.

Found via the e2e suite when running with no app-supplied logger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drives a real client built from the RN SDK's buildConfig (ReactNativePlatform +
LifecycleAwareWebSocket + ReactNativeStore) against a real FeatBit stack
(Postgres + api-server + evaluation-server) spun up with Testcontainers. Ported
from the Fluent Health Android SDK's e2e harness.

Three tests: polling (evaluate seeded flag, observe server-side change, identify);
streaming (receive a server-side change as a real-time push); and streaming +
lifecycle (background pauses the stream so an update is NOT received, foreground
reconnects and resyncs).

Gated by FEATBIT_E2E=1 (`npm run test:e2e`) so the default `npm test` never
needs Docker. Node env mocks: controllable react-native (AppState/Platform) and
in-memory async-storage. Postgres init SQL under e2e/initdb/ is vendored from
featbit/featbit (Apache-2.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runs build + unit tests on every push/PR, and the FEATBIT_E2E e2e suite on a
GitHub-hosted runner (which provides Docker for Testcontainers). Pre-pulls the
FeatBit stack images before running.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cosmos-explorer

Copy link
Copy Markdown
Contributor

Hi @alexheidl, I'm reviewing the PR and I would like to push some commit into your branch, is it possible to give me the permission?

}

private resume(): void {
if (!this.started) {

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.

shouldn't this be?

if (this.started)

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.

Thanks for the catch — this one is intentional. resume() is the lifecycle hook that fires on foreground/online, and it should only rebuild the socket if the synchronizer was actually started. So the early-return guard is:

private resume(): void {
  if (!this.started) {
    return; // never started (or already closed) → nothing to resume
  }
  // rebuild socket + connect
}

Inverting it to if (this.started) while keeping the return would bail out exactly when we do want to reconnect, which breaks resume. pause() just above uses the same !this.started guard for symmetry. Happy to wrap the body in if (this.started) { ... } instead if you prefer that style, but the behavior would be identical.

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.

Thanks for explaining

@alexheidl

Copy link
Copy Markdown
Contributor Author

@cosmos-explorer done — I've added you as a collaborator, so you should have push access to the branch now. Looking forward to your commits!

@cosmos-explorer

cosmos-explorer commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

@cosmos-explorer done — I've added you as a collaborator, so you should have push access to the branch now. Looking forward to your commits!

Thank you, I just push the code. The main change is: removed the initd folder and fetch the postgres migration scripts at runtime.

I also resolved the conflicts

cosmos-explorer and others added 5 commits June 16, 2026 09:49
The previous lockfile was generated with optional dependencies omitted, so it
was missing fsevents, cpu-features, nan and buildcheck. `npm ci` is strict about
the lockfile matching package.json's full dependency closure and bailed before
installing, failing both CI jobs (build/unit and e2e) in ~6s.

Regenerated with a plain `npm install`; `npm ci` now resolves cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cosmos-explorer

Copy link
Copy Markdown
Contributor

Hello @alexheidl It seems good to me, if you have no further questions, I'll merge it

@cosmos-explorer cosmos-explorer left a 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.

LGTM

@cosmos-explorer cosmos-explorer merged commit 6261129 into featbit:main Jun 16, 2026
2 checks passed
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.

Pause/resume streaming on app lifecycle & connectivity (iOS-first: background suspension breaks the WebSocket)

2 participants