Flutter SDK
Read flags in Flutter apps, Flutter web and Dart programs with the Dart SDK, which evaluates them on the device and keeps working offline.
The Dart SDK fetches your environment’s client flags and evaluates them on the device, so your users’ attributes never leave the app. It runs in Flutter apps on iOS, Android, desktop and the web, and in plain Dart programs. With Flutter, widgets rebuild when a flag changes and the app starts from the last flags it saw, even offline.
Install
| Package | What |
|---|---|
featflags_flutter |
Flutter bindings (Flutter 3.22+): the app’s lifecycle drives the client, the last flags are kept on the device, and FeatFlagsProvider and FlagSelector rebuild widgets. Includes featflags. |
featflags |
The client, pure Dart 3.4+ with package:http as its one dependency: Flutter, Flutter web and Dart programs. |
featflags_openfeature |
An OpenFeature provider for the official Dart client SDK (needs Dart 3.10+). |
flutter pub add featflags_flutter # a Flutter app
dart pub add featflags # a Dart program without Flutter
Create a client
import 'package:featflags_flutter/featflags_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final flags = FlagClient(FlagClientOptions(
key: 'pk_…',
cdnUrl: Uri.parse('https://flags.featflags.io'),
eventsUrl: Uri.parse('https://events.featflags.io'), // evaluation counts and experiments
context: {'key': user.id, 'plan': 'pro'},
storage: await SharedPreferencesStorage.load(),
anonymousKeyStorage: await NoBackupStorage.load(),
));
runApp(FeatFlagsProvider(client: flags, child: const App()));
}
Make one client per key, for the life of the app. await flags.waitForReady() waits (2 s at most) for the first fetch instead of evaluating the stored flags, the bootstrap or the fallbacks.
Client keys only
An app holds the environment’s client key (pk_…). It reaches the flags you mark client-visible (and the flags they need as prerequisites), with target keys hashed and private segments behind tokens your backend issues.
The client refuses a server key (sk_…): FlagClientOptions throws an ArgumentError, on Flutter web as on phones. Anything shipped in an app or a web bundle can be extracted, and a server key reaches every flag of the environment, its rules in the clear and the secret that unlocks private segments for any user. Keep server keys on your servers, with a server SDK.
An environment’s allowed origins don’t apply: native apps have no origin, and on Flutter web this SDK doesn’t check the list either. Only the TypeScript SDK enforces it in browsers.
Options
FlagClientOptions |
Default | What |
|---|---|---|
key |
required | The client key, pk_…. |
cdnUrl |
required | https://flags.featflags.io. |
eventsUrl |
null | https://events.featflags.io. Evaluation counts, experiment exposures and track events are off without it. |
context |
{} (an anonymous user) |
The context to evaluate with until identify. |
membershipTokens |
{} |
Private segment tokens for context; see Lists and private segments. |
experimentSeeds |
null | Experiment seeds for context, from your backend; see Experiments. |
experimentKey |
null | The environment’s experiment key, xk_…, for anonymous users; public once in the app. |
bootstrap |
null | A Document (Document.parse, such as one bundled as an asset) to evaluate before the first fetch. |
storage |
null | Where the last flags are kept between launches: SharedPreferencesStorage in Flutter, or your own FlagStorage. Nothing is kept when null. |
anonymousKeyStorage |
null | Where the anonymous key is kept: NoBackupStorage in Flutter, or your own FlagStorage. In memory, one per launch, when null. |
readyTimeout |
2 s | How long waitForReady waits. |
pollInterval |
60 s | Time between checks for changes in the foreground, ±10%. Duration.zero turns polling off. |
flushInterval |
60 s | Time between reports. The first waits up to one extra interval at random. |
onExposure |
null | Called the first time each (context key, flag, variant) is evaluated, for your own analytics. |
hooks |
[] |
Run around every evaluation and identify; see Hooks. |
httpClient |
one the client makes and closes | The http.Client for every request. |
now |
the system clock | Epoch milliseconds for rollout steps and date conditions. |
log |
print |
Where warnings go: failing hooks, failed reports. |
Evaluate flags
In a widget, FeatFlagsProvider.of(context) gives you the client and rebuilds the widget whenever evaluations may have changed:
class CheckoutButton extends StatelessWidget {
const CheckoutButton({super.key});
@override
Widget build(BuildContext context) {
final flags = FeatFlagsProvider.of(context);
return flags.boolValue('new-checkout', false) ? const NewCheckout() : const Checkout();
}
}
boolValue, stringValue, numberValue (a double) and jsonValue (any decoded JSON value) return the flag’s value for the client’s context, or the fallback when the flag is missing, the context is invalid or the value has another type. Their …Detail variants return a Detail with value, variant and the reason: Reason.rule with ruleId, Reason.prerequisiteFailed with prerequisite (Reason.default_ is default, a reserved word in Dart). Every getter also takes a context: to evaluate another context than the identified one.
final d = flags.stringDetail('pricing-page', 'v1');
print('pricing-page: ${d.value} (${d.reason.wire}, rule ${d.ruleId ?? '-'})');
Getters are synchronous and read flags in memory: call them in build.
Rebuild only what changed
FlagSelector is a ValueListenable of one value read from the client, which notifies only when that value changes:
final newCheckout = FlagSelector(flags, (f) => f.boolValue('new-checkout', false));
ValueListenableBuilder<bool>(
valueListenable: newCheckout,
builder: (context, enabled, _) => enabled ? const NewCheckout() : const Checkout(),
);
It follows the client only while it has listeners, so it needs no disposing. Without Flutter, flags.changes is a broadcast Stream<void> of the same changes, done when the client closes.
Contexts and identify
A context is a JSON-like map:
const user = {'key': 'u-alice', 'email': 'alice@acme.com', 'groups': ['qa', 'eng']}; // a user, the default kind
const org = {'kind': 'org', 'key': 'acme', 'plan': 'team'};
const both = {'kind': 'multi', 'user': {'key': 'u-alice'}, 'org': {'key': 'acme', 'plan': 'team'}};
Attribute values are strings, numbers, booleans, or lists of those; null, maps and nested lists count as missing. An invalid context (no key, a bad kind) gets the fallback with reason error.
The client evaluates one context at a time. Call identify when the user signs in or out, or switches organisation:
await flags.identify({'kind': 'multi', 'user': {'key': user.id}, 'org': {'key': org.id}});
The context changes at once; identify completes once the context’s lists have loaded (below), so you needn’t wait for it.
A user without a key gets the device’s anonymous key, so a signed-out user stays in the same rollout buckets across launches. NoBackupStorage keeps it apart from the cached flags, where clearing the cache doesn’t reach it and backups don’t either, so a backup restored on another device doesn’t share it: a file in noBackupFilesDir on Android, a file under Application Support marked excluded from backup on iOS, localStorage on the web, and the platform’s preferences on desktop.
Lists and private segments
Targets and segment lists of up to 64 keys travel with the flags, hashed. Longer ones, up to millions of keys, are split into small files: the client fetches the identified context’s part, one request per context kind, when the context is set and when new flags need new parts. Until they arrive, list lookups don’t match and evaluations that reach them report Reason.membershipPending; new flags that need new parts are applied once those have loaded, so a context in a list stays in it.
Private segments can only be read with a token for the user. Your backend computes it with a server SDK (membership_token(context) in Python, Ruby and Rust, MembershipToken in Go and .NET, membershipToken in Java and PHP) and hands it to the app at sign-in:
await flags.identify({'key': user.id}, membershipTokens: session.featflagsTokens); // {'user': '8ecc…'}
A token applies to the identified context only. How lists work.
Updates, offline and the app’s lifecycle
- The client fetches the flags when it starts, then checks for changes every minute. It never goes back to older flags, and a failed fetch keeps the last good ones.
FeatFlagsProviderfollows the app’s lifecycle: when the app is hidden or paused (a hidden browser tab included), it callsflags.pause(), which stops polling and sends the client’s reports; when the app resumes,flags.resume()polls again and fetches at once, at most every 5 seconds. Without the provider, call them yourself.- When a fetch fails because the network is down, the client tries again every 5 seconds while in the foreground, so it catches up as soon as the network is back, with no connectivity plugin.
- With
storage, the last flags are kept on the device, so a cold start offline evaluates the last flags the app saw;isCachedis true until a fetch answers. - On Flutter web, checks for changes go through the browser’s HTTP cache. Rollout buckets are the same as on every other platform.
versionsays which version of your flags is in use, null before the first.
What the client sends: counts of which variant each flag served, with the reason, rule and version of your flags, never the context. What SDKs send.
Experiments
When an environment runs experiments, the client reports which variant each user saw and the events you track for the experiments’ metrics:
final on = flags.boolValue('new-checkout', false); // reported, if a running experiment's audience served it
flags.track('checkout.completed', order.total); // [a-z0-9_.:-]{1,64}, an optional number; nothing else
flags.track('page.error');
track is for the identified context. A bad event key or value is logged once and dropped, never thrown. An event no running experiment counts sends nothing, so tracking freely costs nothing.
Users are reported under IDs made with the environment’s experiment key, which featflags never holds. The app doesn’t need it:
-
Signed-in users: your backend computes the user’s seeds with a server SDK (
experimentSeedsin JS, Java and PHP,experiment_seedsin Python, Ruby, Rust and Elixir,ExperimentSeedsin Go and .NET) and hands them over at sign-in with the membership tokens:await flags.identify({'key': user.id}, membershipTokens: session.featflagsTokens, experimentSeeds: ExperimentSeeds.fromJson(session.experimentSeeds));Seeds apply to the identified context only. Seeds made with a key you’ve since rotated are ignored, with one error logged, until your backend hands over new ones.
-
Anonymous users, or an app without a backend: pass
experimentKey. Anything shipped in an app can be extracted, so the key is then public.
Exposures and events are sent with the counts: every minute, on pause() and close(), and as soon as 5,000 are waiting. Each carries how long ago it happened, so events recorded offline are dated right. Nothing is kept on the device, and past 50,000 waiting, more are dropped. track runs no hooks.
Hooks
A hook runs around every evaluation and identify: beforeEvaluation in registration order, afterEvaluation in reverse with the Detail the caller gets; beforeIdentify and afterIdentify around identify (status error when the client was closed first). Extend Hook and override the stages you need. A stage that throws is logged and skipped. The hooks contract.
class FlagTimer extends Hook {
@override
String get name => 'timer';
@override
SeriesData beforeEvaluation(EvaluationSeries series, SeriesData data) => {...data, 'start': DateTime.now()};
@override
SeriesData afterEvaluation(EvaluationSeries series, SeriesData data, Detail<Object?> detail) {
final took = DateTime.now().difference(data['start'] as DateTime);
print('${series.method} ${series.flagKey}: ${detail.reason.wire} in ${took.inMicroseconds} µs');
return data;
}
}
OpenTelemetry
There’s no OpenTelemetry hook for Dart yet: OpenTelemetry has no official Dart API. If you use a Dart OpenTelemetry library, write a hook over it as above.
OpenFeature
FeatFlagsOpenFeatureProvider, in featflags_openfeature, is a provider for the OpenFeature Dart client SDK. Each context OpenFeature sets identifies the client:
import 'package:featflags_openfeature/featflags_openfeature.dart';
import 'package:openfeature_dart_client_sdk/openfeature_dart_client_sdk.dart';
await OpenFeatureAPI.instance.setEvaluationContextAndWait(EvaluationContext(targetingKey: user.id, attributes: {'plan': 'pro'}));
await OpenFeatureAPI.instance.setProviderAndWait(FeatFlagsOpenFeatureProvider(flags)); // you own the client
final client = OpenFeatureAPI.instance.getClient();
client.getBooleanValue('new-checkout', false);
OpenFeature’s Hook has the same name as featflags’s: in a file that imports both, import one with a prefix. Without a kind attribute, an evaluation context is a user; kind: 'multi' makes each map attribute the member of the kind it’s named after. The provider emits READY once the client has flags (setProviderAndWait throws while it has none, offline on a first launch) and CONFIGURATION_CHANGED when evaluations may have changed. How reasons map to OpenFeature’s.
Shut down
flush() sends counts, exposures and events now. close() stops polling, ends changes, flushes, and waits for storage writes in flight.