Crashlytics, Remote Config, and App Check in Flutter: A Production Readiness Guide
Wire up Firebase Crashlytics, Remote Config, and App Check in a Flutter app the production way: Flutterfire CLI setup, crash handlers, non-fatal errors, fetch and activate cycles, attestation providers, and what enforcement actually breaks.
Published on • October 4, 2026
AI Assistant

Crashlytics, Remote Config, and App Check in Flutter: A Production Readiness Guide
A Flutter app is not production-ready when it compiles. It is production-ready when you know it is crashing, when you can change its behavior without waiting on an app store review, and when only your real binaries can touch your backend. Those three questions map to three Firebase products: Crashlytics for stability, Remote Config for control, App Check for integrity. All three ship as FlutterFire plugins, and all three have one sharp edge that surprises teams on the day they matter most.
This guide walks the actual setup path for each, using the APIs from the official Firebase Flutter documentation, and ends with the failure modes that bite after launch.
Key technologies: firebase_crashlytics, firebase_remote_config, firebase_app_check, firebase_analytics, the Flutterfire CLI (flutterfire configure), FirebaseRemoteConfig.fetchAndActivate(), FirebaseAppCheck.instance.activate(), and the App Check providers DeviceCheck, Play Integrity, and the Debug provider.
Project Setup with the Flutterfire CLI
Start from a project that already has Firebase initialized. From the project root:
flutter pub add firebase_crashlytics && flutter pub add firebase_analytics
flutter pub add firebase_remote_config
flutter pub add firebase_app_check
flutterfire configure
flutter run
flutterfire configure is not optional paperwork. It generates firebase_options.dart with your per-platform app configuration, keeps it current as you add platforms, and on Android it adds the Crashlytics Gradle plugin your build needs to process native crash symbols.
Two plugin dependencies are worth calling out:
- Google Analytics is recommended with Crashlytics because it powers breadcrumb logs — the record of screens and events leading up to a crash. Enable it during project creation, or later under Firebase console Settings > Integrations.
- Google Analytics is required with Remote Config for conditional targeting of app instances against user properties and audiences.
If you build with --split-debug-info (and optionally --obfuscate), you need readable stack traces back. On Apple platforms, use Flutter 3.12.0+ and Crashlytics Flutter plugin 3.3.4+ so dSYM files are generated and uploaded automatically. On Android, upload symbols with Firebase CLI v11.9.0+ before you report a crash from an obfuscated build:
firebase crashlytics:symbols:upload --app=FIREBASE_APP_ID PATH/TO/symbols
FIREBASE_APP_ID is the Firebase Android App ID (for example 1:567383003300:android:17104a2ced0c9b9b), not your package name. The path is the same directory you passed to --split-debug-info.
Crashlytics: Catching What Actually Breaks
Crashlytics only helps if errors reach it. Wire the two handlers Flutter does not forward by itself:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FlutterError.onError = (errorDetails) {
FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
};
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}
FlutterError.onError catches framework errors; PlatformDispatcher.instance.onError catches asynchronous errors the framework never sees. For errors raised outside the Flutter context entirely, attach a listener to the current isolate with Isolate.current.addErrorListener(...) and call recordError(..., fatal: true) from its port.
Force a test crash
Setup is not finished until a report lands in the console. Add a button that throws:
TextButton(
onPressed: () => throw Exception(),
child: const Text("Throw Test Exception"),
),
Run the app, tap it, then check the Crashlytics dashboard. If nothing appears after five minutes, enable debug logging and check whether the app is sending reports at all.
Non-fatal errors, keys, and logs
Not everything should kill the process. Record caught exceptions as non-fatal:
await FirebaseCrashlytics.instance.recordError(
error,
stackTrace,
reason: 'a non-fatal error',
information: ['further diagnostic information about the error', 'version 2.0'],
);
Fatal reports are sent in real time without a restart. Non-fatal reports are written to disk and sent with the next fatal report or the next app start — and Crashlytics only keeps the most recent eight non-fatal exceptions until one of those flushes.
Attach context with keys, logs, and identity:
FirebaseCrashlytics.instance.setCustomKey('checkout_flow', 'v2');
FirebaseCrashlytics.instance.setCustomKey('cart_items', 3);
FirebaseCrashlytics.instance.log("Checkout screen entered");
FirebaseCrashlytics.instance.setUserIdentifier("12345");
| Instrument | API | Hard limit |
|---|---|---|
| Custom keys | setCustomKey(...) | 64 key-value pairs max, 1 kB each |
| Custom logs | log(...) | 64 kB per session, oldest entries dropped |
| Non-fatal buffer | recordError(...) | 8 most recent exceptions |
| User identity | setUserIdentifier(...) | cleared by resetting to a blank string |
Never embed a unique value (user ID, timestamp) directly in an exception message — group reports by message and Crashlytics will start limiting your reports. Use a custom key instead. And if you need opt-in collection, disable automatic collection natively (FirebaseCrashlyticsCollectionEnabled = false in Info.plist, or firebase_crashlytics_collection_enabled meta-data in AndroidManifest.xml) and flip it per user with FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(true); the override persists across launches.
Remote Config: Ship Behavior Without Shipping a Build
Get the singleton and configure fetch behavior before anything else:
final remoteConfig = FirebaseRemoteConfig.instance;
await remoteConfig.setConfigSettings(RemoteConfigSettings(
fetchTimeout: const Duration(minutes: 1),
minimumFetchInterval: const Duration(hours: 1),
));
await remoteConfig.setDefaults(const {
'new_checkout_enabled': false,
'checkout_max_items': 10,
'promo_banner': 'Welcome!',
});
In-app defaults matter: the app behaves correctly before it ever connects, and if the backend has no value configured, you still get something sane. Do not store confidential data in parameter keys or values — anything on the client is readable by the client.
Read values with the typed getters: getBool(), getDouble(), getInt(), getString().
Fetch, then activate
fetch() downloads backend values into the SDK; activate() makes them visible to your app. Splitting the two lets you choose the moment of switchover — typically the next app open — so config changes never land mid-interaction:
await remoteConfig.fetch();
// ...later, at a safe boundary:
await remoteConfig.activate();
Or do both at once with await remoteConfig.fetchAndActivate();.
Throttling
The default minimum fetch interval is 12 hours: no matter how many times you call fetch(), the backend is hit at most once in that window. Exceed it and remoteConfig.lastFetchStatus becomes RemoteConfigFetchStatus.throttle. During development you can drop the interval to five minutes with setConfigSettings() for a small team, but pushing that build to thousands of testers will hit service-side quota limits.
Real-time updates
Since the Flutter SDK v4.0.0, you can listen and bypass the fetch interval entirely:
remoteConfig.onConfigUpdated.listen((event) async {
await remoteConfig.activate();
// New values are live here.
});
Real-time Remote Config signals connected devices when a new version is published and fetches it automatically. It is not available on web, and activation timing is still your responsibility.
App Check: Prove the App Is the App
Register every app in the Firebase console under Security > App Check first, using the Play Integrity, DeviceCheck, and reCAPTCHA providers. Registration order matters: once enforcement is enabled for a product, only registered apps can access that product’s backend resources.
Then add the plugin and initialize it after Firebase.initializeApp() but before you use any Firebase service:
await FirebaseAppCheck.instance.activate(
webProvider: ReCaptchaV3Provider('recaptcha-v3-site-key'),
androidProvider: AndroidProvider.playIntegrity,
appleProvider: AppleProvider.appAttest,
);
| Platform | Default provider | Alternatives in the activate() enums |
|---|---|---|
| Android | Play Integrity | AndroidProvider.debug, SafetyNet |
| iOS / macOS | DeviceCheck | AppleProvider.debug, App Attest (iOS 14.0+/macOS 14.0+), App Attest with Device Check fallback |
| Web | reCAPTCHA v3 | ReCaptchaEnterpriseProvider |
You can optionally set a token TTL between 30 minutes and 7 days. Shorter TTLs are more secure but force more attestation, which adds latency to requests and burns quota faster; the library refreshes tokens at roughly half the TTL.
What breaks when you turn on enforcement
Immediately after installing the SDK, the app sends an App Check token with every request — but Firebase does not require it yet. Before enforcing, watch the App Check request metrics for Realtime Database, Cloud Firestore, Cloud Storage, Authentication, and Cloud Functions. Then enable enforcement per product.
What enforcement actually removes: requests from unregistered apps, modified or re-packaged binaries, and anything running outside a valid attestation path. That is the point, and it is also how you lock out your own users if you registered only one platform build, shipped a CI-built artifact, or left a debug build in the field.
Debug builds and emulators
Emulators and CI machines have no trustworthy attestation. Use the Debug provider for those environments instead of a real provider. For certain Android devices you must also enable “Meets basic device integrity” in the Google Play console, or legitimate installs will fail attestation.
Common Pitfalls
- Skipping
flutterfire configure. Without it,firebase_options.dartis stale or missing and the Crashlytics Gradle plugin is absent on Android. - Only wiring
FlutterError.onError. Async errors escape silently; you needPlatformDispatcher.instance.onErrortoo. - Putting unique values in exception messages. It fragments grouping and gets your reports rate-limited. Use
setCustomKey. - Calling
fetch()and assuming values are live. Fetched values apply only afteractivate()(orfetchAndActivate()). - Setting
minimumFetchIntervalto zero in production. Expect throttling and quota exhaustion; the 12-hour default exists for a reason. - Enforcing App Check before registering every app. Unregistered builds lose access to Firestore, Storage, Auth, and Functions immediately — this is the most common self-inflicted outage.
- Forgetting the debug provider in CI. Your pipeline will fail attestation against every protected resource.
Wrapping Up
Production readiness is three habits: catch every error path into Crashlytics with enough keys and logs to triage it, treat Remote Config as a fetch/activate contract with sane defaults and deliberate activation timing, and roll App Check out in monitor-then-enforce order so you know what enforcement will cut off before it does. Get the CLI setup right once, and the three plugins stay out of your way for the life of the app.