Skip to content

feat: synchronous feature flag reads via Dart-side fetch#328

Draft
brunovsiqueira wants to merge 2 commits intoPostHog:mainfrom
brunovsiqueira:feat/dart-feature-flags
Draft

feat: synchronous feature flag reads via Dart-side fetch#328
brunovsiqueira wants to merge 2 commits intoPostHog:mainfrom
brunovsiqueira:feat/dart-feature-flags

Conversation

@brunovsiqueira
Copy link
Copy Markdown
Contributor

@brunovsiqueira brunovsiqueira commented Mar 6, 2026

Problem

The PostHog Flutter SDK wraps native iOS/Android SDKs via method channels, making all feature flag reads async (Future<bool>, Future<Object?>). This forces Flutter developers into FutureBuilder/setState patterns just to read a flag value:

// Current — awkward for UI code
FutureBuilder<bool>(
  future: Posthog().isFeatureEnabled('new-onboarding'),
  builder: (context, snapshot) {
    if (snapshot.data == true) { ... }
  },
)

There are other workarounds, like creating your own cache on top of the SDK when querying flags. However, these are all workarounds.

Most other PostHog SDKs (web, node, python, Android, iOS) provide synchronous flag reads. Flutter is the outlier. This is tracked across multiple issues: #241 (sync API), #231 (callback with data), #51 (pure Dart SDK).

Solution

Fetch feature flags directly from PostHog's /flags/?v=2&config=true API on the Dart side using package:http, cache results in memory, and expose synchronous reads:

// New — use directly in build()
if (Posthog().isFeatureEnabledSync('new-onboarding')) {
  // show new flow
}

New API

// Sync reads (Dart cache) — safe in build()
Posthog().isFeatureEnabledSync('my-flag')       // bool
Posthog().getFeatureFlagSync('my-flag')          // Object? (bool or variant string)
Posthog().getFeatureFlagResultSync('my-flag')    // PostHogFeatureFlagResult?
Posthog().getAllFeatureFlagResults()              // Map<String, PostHogFeatureFlagResult>
Posthog().isFeatureFlagsLoaded                   // bool

// New callback with flag data
config.onFeatureFlagsLoaded = (flags) { setState(() {}); };

// Existing API — completely unchanged
await Posthog().isFeatureEnabled('my-flag')      // still calls native
config.onFeatureFlags = () { ... };              // still works

Architecture: Additive (not replacement)

The Dart cache runs alongside the native SDK — it does not replace it:

Async methods (isFeatureEnabled, etc.) Sync methods (isFeatureEnabledSync, etc.)
Source Native iOS/Android SDK (unchanged) Dart-side HTTP cache (new)
Populated by Native SDK lifecycle Dart HTTP call to /flags/?v=2
If Dart HTTP fails Still works from native Returns false/null

Why additive instead of replacing native reads?

  1. Defensive — if the Dart HTTP layer has a bug, existing integrations still work via native
  2. Observable — no hidden behavioral change for existing users
  3. Reversible — easy to switch later once the Dart path proves stable

The tradeoff is one extra HTTP call on startup (both native and Dart fetch flags independently). Open to feedback on whether we should instead replace the async methods to read from the Dart cache (single source of truth, fewer HTTP calls).

Key Behaviors (following Android SDK patterns)

  • Request coalescing: concurrent reloadFeatureFlags() calls are queued, not duplicated
  • Error merging: when errorsWhileComputingFlags=true, only non-failed flags are merged into cache (preserving cached values for failed flags)
  • Quota limiting: skips cache update when feature_flags is quota limited
  • Zero breaking changes: all existing APIs and signatures preserved

Test plan

  • 33 new unit tests (parsing, sync reads, HTTP, coalescing, error merging, quota limiting, callbacks)
  • All 126 tests pass (93 existing + 33 new)
  • dart analyze clean (only pre-existing warning)
  • Manual test on iOS/Android example app
  • Verify flags load on startup and sync reads work in build()
  • Verify identify() triggers flag reload via native callback

Fetch feature flags directly from PostHog's /flags/?v=2 API on the Dart
side, caching results in memory for synchronous reads. This eliminates
the need for FutureBuilder/setState patterns when reading flags in
build() methods.

New sync API on the Posthog singleton:
- isFeatureEnabledSync(key)
- getFeatureFlagSync(key)
- getFeatureFlagResultSync(key)
- getAllFeatureFlagResults()
- isFeatureFlagsLoaded

New onFeatureFlagsLoaded callback receives the full flags map.

The existing async API (isFeatureEnabled, getFeatureFlag, etc.) remains
unchanged and still calls the native iOS/Android SDK. Both native and
Dart caches are populated independently — if the Dart HTTP call fails,
async methods still work from the native cache.

Key behaviors (following Android SDK patterns):
- Request coalescing: concurrent reloads are queued, not duplicated
- Error merging: partial failures preserve cached values for failed flags
- Quota limiting: skips update when feature_flags is quota limited
@brunovsiqueira brunovsiqueira requested a review from a team as a code owner March 6, 2026 15:29
Copy link
Copy Markdown
Contributor

@dustinbyrne dustinbyrne left a comment

Choose a reason for hiding this comment

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

Hey @brunovsiqueira, thanks for the pull request! 👏

Just jotting down some initial thoughts here. From my perspective, I'd like to understand if it's necessary to introduce an additional feature flags client in Dart, or if we can cleanly get cached flag values into Dart from the native side such that we can serve them synchronously from a thin cache without requiring an additional client/request.

Open to hearing thoughts here.

}

Future<void> _doLoadFeatureFlags(_LoadParams params) async {
final url = '$_host/flags/?v=2&config=true';
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 can drop &config=true here, this appends remote configuration to the response, and we're only considering the default flags response.

Comment on lines +100 to +103
// Fire legacy callback immediately (backward compat)
_onFeatureFlagsCallback?.call();
// Also reload Dart-side flags; onFeatureFlagsLoaded fires when done
_reloadDartFlags();
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 wonder if it's better to read in native cached flag values here and avoid the duplicate call to /flags

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.

I'm thinking of implementing an opt-in-based approach, where we would add a new flag to the config, something like enableSyncFeatureFlags, that would switch between Native wrappers (current approach), to Dart-only Feature flags (new approach).

So we keep it without breaking changes, and also avoid duplicate calls in the case the user opted out of sync FFs.

Wdyt?

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.

My idea here

try {
final distinctId = await getDistinctId();
if (distinctId.isNotEmpty) {
await featureFlags.loadFeatureFlags(distinctId);
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're missing the anonymous ID and additional evaluation properties here. This would cause some flags to evaluate differently in dart than in native.

@brunovsiqueira
Copy link
Copy Markdown
Contributor Author

brunovsiqueira commented Mar 6, 2026

Hey @dustinbyrne 👋🏽
Thanks for promptly reviewing my PR.

This approach would work as well, but it still keeps the SDK as a wrapper around the native SDKs for Android/iOS, which means Desktop (Windows/Linux/macOS) would remain unsupported.

Since the calls to fetch the flags are simple HTTP calls, and given the upside that doing it in pure Dart would also enable Feature Flags to be used in other platforms like Desktop (given that Flutter is multi-platform), I lean towards the proposed approach.

I think the upsides here overcome the downsides.

Thoughts?

@brunovsiqueira brunovsiqueira changed the title feat: synchronous feature flag reads via Dart-side cache feat: synchronous feature flag reads via Dart-side fetch Mar 10, 2026
@marandaneto
Copy link
Copy Markdown
Member

marandaneto commented Mar 10, 2026

Hey @dustinbyrne 👋🏽 Thanks for promptly reviewing my PR.

This approach would work as well, but it still keeps the SDK as a wrapper around the native SDKs for Android/iOS, which means Desktop (Windows/Linux/macOS) would remain unsupported.

Since the calls to fetch the flags are simple HTTP calls, and given the upside that doing it in pure Dart would also enable Feature Flags to be used in other platforms like Desktop (given that Flutter is multi-platform), I lean towards the proposed approach.

I think the upsides here overcome the downsides.

Thoughts?

desktop support is coming thru this PR #313
the flutter sdk is still a wrapper for android/ios/macos, and it has a dart sdk for win/linux
we try to share as much code as possible, so we don't have to implement so many features for all UI frameworks
i'd say the proper fix for this PR would be something different to keep back compatibility

suggestion:
native sdks expose a getAllFlags() and notify the flutter sdk via a callback that flags are loaded and ready
flutter sdk calls getAllFlags via native channels and cache flags in memory, so need of calling method channels anymore
to make all feat flags methods sync and not async, that would be a major breaking change, and i'd rather do this once ffi is stable so we can reliable call all native sdks natively, and we dont have to reimplement everything again for flutter

@dustinbyrne
Copy link
Copy Markdown
Contributor

Appreciate the dialogue here, and I'd like to add an additional thought:

I think the upsides here overcome the downsides.

We have to consider that each call to /flags is billable, so the extra request could effectively 2x a customers usage. The opt out improves this, but it doesn't really address the core issue of issuing two requests if it's enabled.

@brunovsiqueira brunovsiqueira marked this pull request as draft March 10, 2026 18:55
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