The problem
Why it needed building
Most Flutter apps wire Firebase Remote Config straight into their widgets. That works until the team needs a second source of flags from their own backend, a safe offline default, a flag for one country or app version, a 10% rollout, or a way for QA to test the “off” path without touching production config. Each of these usually ends up as custom code spread through the app, and forcing a Firebase version on every app that installs a package causes version conflicts.
My role
What I owned
- Public API design: one FlagFlow class with typed getters
- Merge engine over REST, Firebase Remote Config and local JSON providers
- Audience targeting and stable percentage rollouts
- Reactive widgets and a drop-in QA debug dashboard
- Test suite, docs and 14 releases on pub.dev
The approach
Key decisions
- 1
One engine, one clear order
Apps list their providers in priority order. Every flag resolves the same way: runtime override, then each provider in turn, then the cached or registered default, then the caller’s default. If a provider’s targeting rejects the user, the engine moves on to the next provider instead of returning “off”, so a local default can still apply.
- 2
Adapters instead of dependencies
Firebase is reached through callbacks the app passes in, so the package depends only on http and shared_preferences. REST, Firebase and local JSON providers share a small FlagProvider interface, so teams can plug in any other source.
- 3
Targeting and rollouts from config
Rules live in the flag config, with operators like ==, in and >= over country, platform, app version and custom attributes, so changing an audience needs no new build. A "rollout": 15 value turns the feature on for 15% of users.
- 4
Widgets that keep up
FeatureFlagWidget and FeatureFlagBuilder read the value synchronously on first build, with no flicker, then rebuild when the flag changes. A listenToChanges: false option keeps a layout from shifting mid-session.
- 5
Built for QA
A drop-in debug screen shows the user context and every flag, and lets a tester force a flag on or off. Overrides live only in memory, so a device can’t get stuck in a strange state after a restart.
Challenges
The hard parts
Stable rollouts without a server
Buckets come from a 32-bit FNV-1a hash of the flag key and user ID, mapped to 1–100. The same user lands in the same bucket on every device, and including the flag key means one user is not in the first 10% of every flag.
Remote Config only returns strings
A parser detects booleans, numbers and JSON, and unwraps a value-plus-metadata object so targeting and rollout rules can sit next to the value.
Networks fail
The REST provider retries with exponential backoff and a timeout, then fails quietly so the next provider takes over. No provider ever throws, and typed getters return a safe default instead of crashing on a bad cast.
Refresh without request storms
A mutex makes simultaneous refresh calls share one fetch. Two post-launch releases fixed widgets not rebuilding after a background refresh for flags that exist only in a remote provider.
Testing time-based code
Refresh timers and retry backoff are tested with fake_async and HTTP with a mock client, so the suite is fast and gives the same result every time.
Results
What it achieved
- Published on pub.dev as feature_gate_pro, with 14 releases from 1.0.0 to 1.2.5
- ~1,400 lines of SDK code and 67 test cases across 14 test files
- Only two runtime dependencies: http and shared_preferences
- Runs on all six Flutter platforms; MIT licensed and open source