> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/security-observability/firebase-remote-config-feature-flags.md).

# Firebase Remote Config and feature flags

How I turn Firebase Remote Config into a typed snapshot, keep safe fallbacks, and control real-time updates in Flutter

## Result

In my app, widgets no longer read Remote Config directly. I put the Firebase SDK behind a service, convert all parameters into a typed snapshot, and keep that snapshot in global state. The UI, navigation, and side effects therefore read from the same source of truth.

The runtime flow looks like this:

```
Firebase.initializeApp()
          │
          ▼
Remote Config adapter
          │ fetch + activate
          ▼
Parse and validate snapshot
          │
          ├── Valid ────────► Replace the whole config in Store
          │                         │
          │                         ├── Selector/Container ─► UI
          │                         └── Middleware ─────────► Side effect
          │
          └── Invalid ──────► Keep last-known-good or safe defaults
```

The important result is not merely the ability to enable or disable a feature remotely. This structure lets me answer four questions clearly:

* Is the app using a local default, a cached value, or a remote value?
* Has the template only been fetched, or has it also been activated?
* Is the new snapshot valid enough to replace the current state?
* Should this change apply immediately, or wait for a safe checkpoint?

This article applies to Android and iOS. The Dart implementation is shared, while each build still has to select the correct Firebase configuration in the native layer.

## Problem

When I first used Remote Config, I only needed a few `bool` flags. Reading them from the singleton looked straightforward:

```dart
final enabled = FirebaseRemoteConfig.instance.getBool(
  'new_search_enabled',
);
```

This approach became difficult to control as the number of parameters grew:

* Widgets had to know SDK keys and data types.
* Defaults could live at multiple call sites and drift apart.
* One parameter could be a scalar while another contained a JSON object.
* A successful fetch did not mean the new value had been activated.
* A successful activation did not mean the UI should change while the user was interacting with it.
* If each feature listened for real-time updates, the app could create multiple listeners and process the same template more than once.

In the source I reviewed, the app already separates `RemoteConfigService`, hydrates data into a typed model, and replaces the complete snapshot in Redux. The initial fetch has a timeout. If the fetch fails, the adapter still tries to activate cached Firebase data before reading `getAll()`.

However, the source also exposes three failure modes that need to be stated plainly.

### Defaults come from more than one source

The Store starts with an `initValue`, the generated parser has its own defaults, and the feature flag helper returns `false` when a key is missing. Some flags in `initValue`, however, default to `true`.

As a result, these three cases do not necessarily produce the same behavior:

```
The app has not fetched yet
The remote template does not contain the key
The remote template contains the key with the wrong type
```

Testing only whether the server returns `true` or `false` is not enough. I also need a test for a missing parameter.

### One mistyped field can invalidate a large snapshot

The adapter attempts to run `jsonDecode()` on each value, then passes the complete map to a typed model. If one field has the wrong type, hydration can throw.

The initial path currently returns the local `initValue` when hydration fails. The real-time path emits an update failure and the reducer does not replace its state, so the previous snapshot remains active. Both paths avoid putting a partially hydrated object in the Store, but their fallback order is not the same.

### The real-time listener has no clear owner

The same Remote Config initialization action runs when the app opens and can run again after the session changes. Each initialization calls `.listen()`, but the source does not retain a `StreamSubscription` to reuse or cancel it.

The Firebase SDK can reuse the real-time connection, but every Dart listener still has its own callback. A new template can therefore produce multiple update actions when initialization runs more than once.

## Solution

### Separate fetch, activate, validate, and apply

I use a four-step mental model instead of treating `fetchAndActivate()` as the entire lifecycle:

| Step     | Responsibility                                                       |
| -------- | -------------------------------------------------------------------- |
| Fetch    | Ask the SDK for a new template according to timeout and cache policy |
| Activate | Choose which fetched values become the SDK's active values           |
| Validate | Parse raw values into a typed snapshot and verify its schema         |
| Apply    | Choose when to replace state and which side effects may react        |

This boundary is especially important for real-time updates. Firebase automatically fetches a new template before emitting `onConfigUpdated`, but the app still needs to call `activate()` and decide when consumers may see the new snapshot.

```
Remote backend       Firebase SDK        Parser/Validator       Store/Consumer
      │                    │                    │                     │
      ├── new template ───►│ auto fetch         │                     │
      │                    ├── invalidation ───►│                     │
      │                    │                    │                     │
      │                    │◄── activate ───────┤                     │
      │                    ├── raw values ─────►│                     │
      │                    │                    ├── valid ───────────►│ apply
      │                    │                    └── invalid ─────────►│ keep old state
```

The backend decides which template to send. The SDK manages fetch and cache. The parser decides whether the snapshot is valid. The app decides when to apply it.

### Initialize Firebase before the adapter

The reference app declares these dependency versions:

```yaml
dependencies:
  firebase_core: ^4.4.0
  firebase_remote_config: ^6.1.4
```

In the source, `Firebase.initializeApp()` completes before the Store is created. The startup page dispatches the action that loads Remote Config afterward. A public example can reduce this prerequisite to:

```dart
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/widgets.dart';

Future<RemoteConfigCoordinator> prepareRemoteConfig() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  return buildRemoteConfigCoordinator();
}
```

The caller can `await coordinator.start()` on the startup page or behind a loading screen. Config can also load in parallel with startup work that does not depend on it. The rule I keep is not to access `FirebaseRemoteConfig.instance` before `Firebase.initializeApp()` completes.

### Put the Firebase SDK behind a gateway

The Store does not need to know about `RemoteConfigValue` or `RemoteConfigUpdate`. The source interface already separates load and update operations from the Store, but its event stream still returns Firebase's `RemoteConfigUpdate` type. The public example closes that remaining dependency with a neutral contract:

```dart
final class RawConfigSnapshot {
  const RawConfigSnapshot(this.values);

  final Map<String, String> values;
}

final class ConfigInvalidation {
  const ConfigInvalidation({required this.updatedKeyCount});

  final int updatedKeyCount;
}

abstract interface class RawConfigGateway {
  Future<void> configure({required Map<String, dynamic> defaults});
  Future<RawConfigSnapshot> loadInitial();
  Future<RawConfigSnapshot> activateLatest();
  Stream<ConfigInvalidation> get invalidations;
}
```

The gateway only emits the number of changed keys. It does not expose keys, values, or SDK events to the domain layer. This reduces coupling and prevents telemetry from accidentally receiving parameter names that reveal business meaning.

The concrete adapter keeps the complete Firebase API in one place:

```dart
import 'package:firebase_remote_config/firebase_remote_config.dart';

typedef ConfigSignal = void Function(String phase, String outcome);

final class FirebaseRawConfigGateway implements RawConfigGateway {
  FirebaseRawConfigGateway(
    this._remoteConfig, {
    required this.fetchTimeout,
    required this.minimumFetchInterval,
    this.onSignal,
  });

  final FirebaseRemoteConfig _remoteConfig;
  final Duration fetchTimeout;
  final Duration minimumFetchInterval;
  final ConfigSignal? onSignal;

  @override
  Future<void> configure({
    required Map<String, dynamic> defaults,
  }) async {
    await _remoteConfig.setConfigSettings(
      RemoteConfigSettings(
        fetchTimeout: fetchTimeout,
        minimumFetchInterval: minimumFetchInterval,
      ),
    );
    await _remoteConfig.setDefaults(defaults);
  }

  @override
  Future<RawConfigSnapshot> loadInitial() async {
    try {
      await _remoteConfig.fetch();
      onSignal?.call('fetch', 'success');
    } catch (_) {
      // Do not log a raw exception or config payload here.
      onSignal?.call('fetch', 'failed');
    }

    await _remoteConfig.activate();
    return _readActiveSnapshot();
  }

  @override
  Future<RawConfigSnapshot> activateLatest() async {
    final changed = await _remoteConfig.activate();
    onSignal?.call('activate', changed ? 'changed' : 'unchanged');
    return _readActiveSnapshot();
  }

  @override
  Stream<ConfigInvalidation> get invalidations {
    return _remoteConfig.onConfigUpdated.map(
      (update) => ConfigInvalidation(
        updatedKeyCount: update.updatedKeys.length,
      ),
    );
  }

  RawConfigSnapshot _readActiveSnapshot() {
    final values = _remoteConfig.getAll().map(
          (key, value) => MapEntry(key, value.asString()),
        );
    return RawConfigSnapshot(Map.unmodifiable(values));
  }
}
```

When the initial fetch fails, the adapter still calls `activate()` and reads the active snapshot. If the device has fetched values from an earlier run, the app still has a chance to use the cache instead of immediately falling back to local defaults.

In the source, production uses a 12-hour minimum fetch interval, non-production builds use `Duration.zero`, and the fetch timeout is 15 seconds. This is the reference app's build policy, not a mandatory setting for every project.

If you add `Future.timeout()`, remember that it does not cancel the underlying Firebase operation. It only completes the caller's future with a timeout error. Do not record telemetry as though the network request has definitely stopped.

### Use one typed source of defaults

The source currently keeps local defaults in the typed model and does not call `setDefaults()` in the Firebase adapter. To keep the generated parser, helper, and SDK from following different policies, the public example uses a small model as the canonical source:

```dart
final class AppFeatureConfig {
  const AppFeatureConfig({required this.newSearchEnabled});

  static const newSearchKey = 'new_search_enabled';

  static const safeDefaults = AppFeatureConfig(
    newSearchEnabled: false,
  );

  static const sdkDefaults = <String, dynamic>{
    newSearchKey: false,
  };

  final bool newSearchEnabled;

  factory AppFeatureConfig.fromRaw(Map<String, String> values) {
    return AppFeatureConfig(
      newSearchEnabled: _readBool(
        values,
        newSearchKey,
        fallback: safeDefaults.newSearchEnabled,
      ),
    );
  }

  static bool _readBool(
    Map<String, String> values,
    String key, {
    required bool fallback,
  }) {
    final value = values[key];
    if (value == null) return fallback;
    if (value == 'true') return true;
    if (value == 'false') return false;

    // Do not include the actual key or value in the error message.
    throw const FormatException('Invalid boolean config');
  }
}
```

`sdkDefaults` and `safeDefaults` express the same policy. `setDefaults()` gives the SDK in-app defaults, while the typed object keeps the Store and tests independent of Firebase APIs.

For JSON objects, I use a separate parser for each section and validate required fields, enums, and ranges before creating the snapshot. I do not let widgets call `jsonDecode()` themselves.

### Replace state only after the snapshot is valid

The repository keeps the current config as the last-known-good snapshot. It only calls `onChanged` after the parser has created a complete object:

```dart
typedef ConfigChanged = void Function(AppFeatureConfig config);

final class FeatureConfigRepository {
  FeatureConfigRepository({
    required this.gateway,
    required this.onChanged,
    this.onSignal,
  });

  final RawConfigGateway gateway;
  final ConfigChanged onChanged;
  final ConfigSignal? onSignal;

  AppFeatureConfig _current = AppFeatureConfig.safeDefaults;
  AppFeatureConfig get current => _current;

  Future<bool> loadInitial() {
    return _replaceFrom(gateway.loadInitial);
  }

  Future<bool> applyLatest() {
    return _replaceFrom(gateway.activateLatest);
  }

  Future<bool> _replaceFrom(
    Future<RawConfigSnapshot> Function() load,
  ) async {
    late final RawConfigSnapshot raw;
    try {
      raw = await load();
    } catch (_) {
      onSignal?.call('load', 'kept-last-known-good');
      return false;
    }

    try {
      final next = AppFeatureConfig.fromRaw(raw.values);
      _current = next;
      onChanged(next);
      onSignal?.call('decode', 'applied');
      return true;
    } on FormatException {
      onSignal?.call('decode', 'kept-last-known-good');
      return false;
    }
  }
}
```

If the initial load fails, `_current` remains `safeDefaults`. If a real-time update has an invalid schema, the repository keeps the snapshot already in use. Both paths follow the same fallback order:

```
New snapshot is valid
        │ no
        ▼
Current last-known-good
        │ not available
        ▼
In-app safe defaults
```

In the reference app, `onChanged` corresponds to dispatching a success action and replacing the complete Redux config in the reducer. The Redux article below covers state organization, selectors, and middleware, so I do not repeat the Store architecture in this Remote Config article.

{% content-ref url="/pages/flT55X8RgxKD0XpLXtUC" %}
[Redux/Flutter Redux at Scale](/flutter/my-flutter/architecture-state/redux-flutter-redux-large-app.md)
{% endcontent-ref %}

### Keep one real-time listener

I put the listener in a coordinator that lives near the composition root. `start()` must be idempotent, while a refresh after a session change uses a separate method instead of creating another listener:

```dart
import 'dart:async';

final class RemoteConfigCoordinator {
  RemoteConfigCoordinator({
    required this.gateway,
    required this.repository,
    this.onSignal,
  });

  final RawConfigGateway gateway;
  final FeatureConfigRepository repository;
  final ConfigSignal? onSignal;

  StreamSubscription<ConfigInvalidation>? _subscription;
  Future<void>? _starting;
  Future<void>? _updateInFlight;
  var _started = false;
  var _updateQueued = false;

  Future<void> start() {
    if (_started) return Future<void>.value();

    return _starting ??= _start().whenComplete(() {
      _starting = null;
    });
  }

  Future<void> _start() async {
    await gateway.configure(defaults: AppFeatureConfig.sdkDefaults);
    await repository.loadInitial();

    _subscription ??= gateway.invalidations.listen(
      (_) => _scheduleUpdate(),
      onError: (_) => onSignal?.call('listen', 'failed'),
    );
    _started = true;
  }

  Future<bool> refresh() {
    return repository.loadInitial();
  }

  void _scheduleUpdate() {
    _updateQueued = true;
    _updateInFlight ??= _drainUpdates();
  }

  Future<void> _drainUpdates() async {
    try {
      while (_updateQueued) {
        _updateQueued = false;
        await repository.applyLatest();
      }
    } catch (_) {
      onSignal?.call('update', 'failed');
    } finally {
      _updateInFlight = null;
    }
  }

  Future<void> dispose() async {
    await _subscription?.cancel();
    _subscription = null;
    final updateInFlight = _updateInFlight;
    if (updateInFlight != null) {
      await updateInFlight;
    }
    _started = false;
  }
}
```

The coordinator solves two separate problems:

* Calling `start()` twice does not multiply listeners.
* Invalidations that arrive close together are handled serially and coalesced instead of starting multiple activation operations in parallel.

If the session changes and targeting needs a refresh, the caller uses `refresh()`. The listener stays in place. When refresh can run while a real-time update is being applied, route both operations through the same serial executor instead of allowing `fetch()` and `activate()` to overlap.

### Choose an apply policy based on impact

A typed, atomic snapshot is not enough to guarantee a stable user experience. Consumers still need to know which updates may apply immediately.

| Config type                                    | Apply timing I recommend                                         |
| ---------------------------------------------- | ---------------------------------------------------------------- |
| Emergency safety switch                        | Immediately after validation, with telemetry and a rollback plan |
| Content that does not disrupt the current flow | It can apply in real time                                        |
| A layout, navigation state, or form being used | Wait for a screen refresh or safe checkpoint                     |
| An experiment that needs a stable cohort       | Pin it for the session or app launch                             |

The source already distinguishes part of this policy: some navigation config is only applied after initial success, while one safety gate is applied after both initial and real-time updates. The Secure Storage article below is an example of an emergency switch that needs a safe default and a plan for turning the protection back on.

{% content-ref url="/pages/Fbl8YWIUovHWvjb7NUlz" %}
[Secure Storage: Keychain and Keystore](/flutter/my-flutter/security-observability/secure-storage-keychain-keystore.md)
{% endcontent-ref %}

I do not use Remote Config to change a permission the user granted, bypass a platform policy, or replace server-side authorization. A client-side feature flag only controls the client experience; it is not a security boundary.

### Record only neutral telemetry

Remote Config is part of the system that controls the app, so I still need to observe it. However, I do not send the raw snapshot to the logging system.

The following fields are enough for most diagnostics:

* `phase`: `configure`, `fetch`, `activate`, `decode`, or `apply`.
* `outcome`: `success`, `failed`, `unchanged`, `invalid-schema`, or `kept-last-known-good`.
* A latency bucket instead of the config payload.
* `config_source`: local default, cached, remote, or last-known-good.
* The number of keys reported as changed, not the key list.
* The listener count in debug builds or tests to detect duplicate starts.

I do not log:

* Real parameter keys or values.
* The complete JSON snapshot.
* Installation IDs, app IDs, user IDs, or targeting attributes.
* Domains, endpoints, or business identifiers.
* Raw exceptions when their messages may contain input data.

Firebase also warns that end users can access default or fetched parameters available to the client app. Remote Config is therefore not a secret manager, even though data is encrypted in transit.

### Android configuration

The Android app module needs the Google Services plugin and must select the correct Firebase configuration for its build variant. The reference source keeps native configuration files by source set or flavor instead of sharing one file across every build.

I do not read the app ID in Dart or log the native file contents. The adapter only calls `Firebase.initializeApp()` and obtains the singleton associated with the current build.

### iOS configuration

The iOS Runner adds the Firebase plist to its resources through a path controlled by build settings. The Flutter plugin brings in the native Remote Config SDK through CocoaPods.

The check is not merely whether a plist exists. The current build must copy the correct plist into the bundle. Do not copy bundle IDs, project names, or real files into documentation or logs.

The Flavor article covers how to separate native Firebase configuration by environment. This article keeps only that prerequisite so it can focus on the Remote Config lifecycle.

{% content-ref url="/pages/1lpIWX1RW20PkI7TGvxs" %}
[Flavor](/flutter/my-flutter/architecture-state/flavor.md)
{% endcontent-ref %}

### Verify the result

After separating the gateway, parser, repository, and coordinator, most tests do not need a real Firebase backend.

I keep the following parser and fallback cases:

* A valid `bool` parameter is parsed correctly.
* A missing parameter uses `safeDefaults`.
* A parameter with the wrong type does not replace last-known-good.
* An unknown key does not make hydration fail.
* A JSON object without required fields is rejected.
* Initial and real-time failures use the same fallback order.

The coordinator cases are:

* Calling `start()` twice creates only one listener.
* `refresh()` fetches again without creating a second listener.
* `dispose()` cancels the subscription.
* Two nearby invalidations do not run activation twice in parallel.
* A stream error records only a failure category and does not send the raw event to telemetry.

Reducer and widget tests should prove that:

* The snapshot is replaced only when the complete config is valid.
* An update failure preserves the previous state.
* The relevant consumer rebuilds after a successful update.
* An `initial only` side effect does not run again after a real-time update.
* An `apply now` safety switch runs after both initial and real-time updates.

Manual checklist for Android and iOS:

1. Open the app offline for the first time and verify the safe defaults.
2. Open it online, fetch and activate, then restart offline to verify cached values.
3. Publish a neutral change in a test environment and verify that only one callback is processed.
4. Publish a value with an invalid schema and verify that the app keeps last-known-good.
5. Move the app through background and foreground and verify that listeners do not multiply.
6. Verify one flag that applies in real time and one that waits for a checkpoint.
7. Confirm that logs contain only categories, counts, and timing.

The reference source has downstream tests for a widget reacting to an update action and for a safety gate retaining a safe default when a remote key has not been published. I did not find direct automated tests for the adapter, fetch timeout, activation, malformed snapshots, or duplicate listeners.

During this article's verification, two focused tests did not reach their assertions because the local dependency cache and generated source were missing or out of sync. I therefore do not treat that run as evidence that the adapter and lifecycle passed. This remains a coverage gap to close before considering the flow complete.

### Common mistakes and trade-offs

#### A feature turns off when the backend omits its key

This symptom usually appears because the helper returns `false` for a missing parameter while local startup uses a different default. I keep one canonical default policy and test the missing-key case explicitly.

#### One invalid JSON value resets many features

The cause is an oversized Remote Config model combined with an initial failure that replaces the full snapshot with defaults. When config sections have different failure domains, you can split them into independent typed models. The trade-off is that you must define which sections may update independently and avoid creating a half-old, half-new state.

#### One update causes multiple rebuilds

Count listeners and update actions. If initialization runs repeatedly without retaining the subscription, each listener can dispatch the same invalidation. The coordinator must own the listener, and `start()` must be idempotent.

#### The UI changes while the user is entering data

Immediate real-time activation is not always wrong. The problem is an apply policy that does not distinguish impact. For forms, navigation, or major layout changes, I wait for a safe checkpoint instead of replacing the visible snapshot immediately.

#### Treating a timeout as a canceled request

`Future.timeout()` does not cancel the source future. If cancellation is mandatory, inspect the SDK API or use a generation guard to discard late results. Do not infer cancellation from a timeout exception alone.

#### Using Remote Config for secrets or authorization

Client parameters can be inspected. I do not put secrets, internal endpoints, or mandatory access rights in Remote Config. The server still enforces authorization independently of the UI feature flag.

#### Typed models slow down releases

Typed parsing requires the app to declare a schema and test migrations before publishing a new parameter. That is more work than calling `getString()` directly, but it prevents an invalid value from silently reaching widgets and side effects.

### Verified versions

* Flutter: 3.41.2.
* Dart: 3.11.0, constrained below 4.0.
* `firebase_remote_config`: constraint `^6.1.4`, resolved to 6.3.0.
* `firebase_core`: resolved to 4.6.0.
* Platforms: Android and iOS.

APIs and real-time behavior can change between package versions. When upgrading dependencies, I recheck `onConfigUpdated`, activation behavior, cache policy, and the native Firebase SDK instead of looking only at the constraint in `pubspec.yaml`.

### References

* [Firebase — Get started with Remote Config on Flutter](https://firebase.google.com/docs/remote-config/flutter/get-started)
* [Firebase — Understand real-time Remote Config](https://firebase.google.com/docs/remote-config/flutter/real-time)
* [Firebase — Remote Config loading strategies](https://firebase.google.com/docs/remote-config/loading)
* [Firebase — Remote Config templates and versioning](https://firebase.google.com/docs/remote-config/templates)
* [Firebase — Remote Config quotas and limits](https://firebase.google.com/docs/remote-config/quotas-limits)
* [`firebase_remote_config` 6.3.0](https://pub.dev/packages/firebase_remote_config/versions/6.3.0)

## Conclusion

As the number of parameters grew, I stopped treating Remote Config as a collection of `getBool()` and `getString()` calls inside widgets. I moved the SDK behind an adapter, parsed raw values into a typed snapshot, and let the Store replace state atomically.

The harder part is the lifecycle, not the fetch itself. The listener needs one owner, initial and real-time updates need the same fallback policy, and consumers need to distinguish config that can apply immediately from config that must wait for a safe checkpoint.

This approach adds a parser, coordinator, and schema tests. An app with one low-impact flag may not need that much structure. However, when Remote Config controls multiple UI surfaces and side effects, I want an invalid value or duplicate listener to become visible before it changes runtime behavior.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
