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
- Firebase Remote Config — Built-in Flutter/Firebase integration, good for simple flags
- LaunchDarkly / Statsig / PostHog — Dedicated feature flag platforms with advanced targeting
- 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.
- Create flag with
enabled: false - Enable for internal users via allowlist
- Gradual rollout via percentage
- Full release at 100%
- 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.