← Articles

Feature flags in Flutter

By Charlin Joe · 28 February 2026

Feature flags let you control which users see which features without deploying new code. You can roll out gradually, run A/B tests, kill-switch a broken feature, and give internal users early access — all without a release.

The approaches

  1. Firebase Remote Config — Built-in Flutter/Firebase integration, good for simple flags
  2. LaunchDarkly / Statsig / PostHog — Dedicated feature flag platforms with advanced targeting
  3. Custom Firestore flags — Full control, works with your existing Firestore setup

Firebase Remote Config approach

See the Firebase Remote Config article for full setup. For feature flags specifically:

class FeatureFlags {
  FeatureFlags(this._config);
  final FirebaseRemoteConfig _config;

  bool get isNewCheckoutEnabled => _config.getBool('new_checkout_v2');
  bool get isChatEnabled => _config.getBool('chat_feature');
  bool get isReferralProgramEnabled => _config.getBool('referral_program');

  String get onboardingVariant => _config.getString('onboarding_ab_variant');
}

final featureFlagsProvider = Provider<FeatureFlags>((ref) {
  return FeatureFlags(FirebaseRemoteConfig.instance);
});

Usage:

final flags = ref.watch(featureFlagsProvider);
if (flags.isNewCheckoutEnabled) {
  return const NewCheckoutFlow();
}
return const LegacyCheckoutFlow();

Custom Firestore feature flags

For flags that need per-user control or immediate server-side updates:

Firestore: feature_flags/{flagId}
  - name: string
  - enabled: bool
  - allowlist: string[]  // user IDs who can see this feature
  - rollout_percentage: number (0-100)
  - environments: string[]  // ['production', 'staging']
class FirestoreFeatureFlags {
  final _db = FirebaseFirestore.instance;
  Map<String, dynamic> _flags = {};

  Future<void> load(String userId) async {
    final snap = await _db.collection('feature_flags').get();
    _flags = {};

    for (final doc in snap.docs) {
      final flag = doc.data();
      final allowlist = (flag['allowlist'] as List?)?.cast<String>() ?? [];
      final rollout = flag['rollout_percentage'] as int? ?? 0;

      if (allowlist.contains(userId)) {
        _flags[doc.id] = true;
      } else if (rollout > 0) {
        // Deterministic hash so the same user always gets the same bucket
        final hash = userId.hashCode.abs() % 100;
        _flags[doc.id] = hash < rollout;
      } else {
        _flags[doc.id] = flag['enabled'] as bool? ?? false;
      }
    }
  }

  bool isEnabled(String flagId) => _flags[flagId] as bool? ?? false;
}

Compile-time flags with dart-define

For flags that should be baked into the binary (CI/CD environment differences):

flutter run --dart-define=ENABLE_ANALYTICS=true --dart-define=FLAVOR=staging
const isAnalyticsEnabled = bool.fromEnvironment('ENABLE_ANALYTICS');
const flavor = String.fromEnvironment('FLAVOR', defaultValue: 'production');

// Can be used as const because it's resolved at compile time
if (isAnalyticsEnabled) {
  AnalyticsService.init();
}

Testing with feature flags

Never test against live flag values — they change. Override in tests:

// With Riverpod
final testFlags = FeatureFlags(
  MockRemoteConfig(overrides: {'new_checkout_v2': true}),
);

widget = ProviderScope(
  overrides: [featureFlagsProvider.overrideWithValue(testFlags)],
  child: const CheckoutScreen(),
);

// Verify new checkout is shown
expect(find.byType(NewCheckoutFlow), findsOneWidget);

Flag lifecycle

Feature flags have a lifecycle: created → rolled out → permanent or removed.

  1. Create flag with enabled: false
  2. Enable for internal users via allowlist
  3. Gradual rollout via percentage
  4. Full release at 100%
  5. Remove the flag and the old code path — this is critical and often skipped

If you never remove old code paths, you accumulate dead code gated by flags that are always true. Set a reminder to clean up flags after full release.

Common pitfalls

Nesting flag checks. if (flagA) { if (flagB) { ... } } creates an exponential number of states to test. Keep flags independent and test each scenario separately.

Loading flags asynchronously on every screen. Load flags once at app start (or sign-in) and cache them. Don't fetch from Firestore on every screen — it adds latency and costs reads.

No default values. If a flag fails to load, what should happen? Always define a safe default. For new features, the default should be false (disabled). For kill-switches, the default should be true (feature enabled).

Sign in to like, dislike, or report.

Feature flags in Flutter — ANN Tech