What runs before runApp() in Mindleau, in what order, and why every one of those awaits is local.
Short answer: In Mindleau, everything awaited before
runApp()reads only from the device: Firebase's local config, the.envfile, SharedPreferences and the SQLite database. Anything that needs the network, such as sync, ads, purchases and account cleanup, is started afterwards withunawaited()or a post-frame callback, and none of it can throw out ofmain(). The result is an app that opens the same way on a train with no signal as it does on Wi-Fi.
Most Flutter apps grow their main() one await at a time. Each line is reasonable on its own: initialize Firebase, load config, set up analytics, sign in, fetch remote config, start the ad SDK. Then one day someone opens the app in a lift and stares at the splash screen for twenty seconds.
Mindleau is a journaling app. People open it when their head is crowded, which is often when they're somewhere inconvenient. So the startup has one rule:
Nothing before runApp() is allowed to wait on the network.
Here's how the startup is structured to keep that rule, line by line.
The whole startup
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FlutterError.onError = ...; // crash routing (below)
PlatformDispatcher.instance.onError = ...;
await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false);
await dotenv.load();
final screenshotMode = dotenv.getBool('MINDLEAU_SCREENSHOT_MODE', fallback: false);
final analytics = AnalyticsService(FirebaseAnalytics.instance, screenshotMode: screenshotMode);
final purchases = await PurchaseService.initialize(screenshotMode: screenshotMode, analytics: analytics);
final ads = await AdService.initialize(adsRemoved: purchases.adsRemoved);
purchases.addListener(() => ads.adsRemoved.value = purchases.adsRemoved);
final notifications = NotificationService();
await notifications.initialize();
final syncService = await FirebaseSyncService.initialize();
final database = AppDatabase();
final repository = SanctuaryRepository(database, syncService: syncService);
await repository.initialize();
purchases.bindIdentity(...);
runApp(App(repository: repository, analytics: analytics, ads: ads, ...));
}
Here's what each await in that function actually touches:
| Await | Touches |
|---|---|
Firebase.initializeApp |
Bundled config only. No network round trip. |
setCrashlyticsCollectionEnabled, dotenv.load
|
Local files and flags. |
PurchaseService.initialize, AdService.initialize, FirebaseSyncService.initialize
|
SharedPreferences, plus SDK setup that is either local or explicitly not awaited. |
notifications.initialize, repository.initialize
|
Local plugins and the SQLite database. |
The network work happens, but none of it is on this list.
Rule 1: optional SDKs initialize to "disabled", never to an exception
Ads, purchases and cloud sync are all optional from the user's point of view. Journaling works without any of them. So their initializers are written to never throw:
/// Never throws: a missing API key, unsupported platform or SDK failure
/// yields a disabled service, and the app keeps showing ads as before.
static Future<PurchaseService> initialize({...}) async {
Inside, every external call is wrapped, logged and turned into a state:
try {
await Purchases.configure(PurchasesConfiguration(apiKey));
service._configured = true;
Purchases.addCustomerInfoUpdateListener(service._applyCustomerInfo);
unawaited(service.refresh()); // network: not awaited
} catch (error, stackTrace) {
log('RevenueCat configuration failed', name: 'mindleau.purchases',
error: error, stackTrace: stackTrace);
}
return service;
The UI asks purchases.enabled and hides the buttons that can't work. A broken purchases SDK costs the business some revenue for a while. It never costs the user their journal.
The same pattern covers a missing .env key: dotenv.getBool(..., fallback: false) instead of the bare getBool, which throws when the key is absent.
Rule 2: heavy SDKs wait for the first frame
The Google Mobile Ads SDK is the heaviest thing Mindleau starts. Its initialization bootstraps a WebView-backed renderer that competes with Flutter for the GPU, and on a slow device or network it can take seconds. So AdService.initialize returns immediately and schedules the real work for after the first frame:
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(service._bootstrapSdk());
});
return service;
The launch screen dismisses, the first frame paints, and then ads warm up. And for users who bought Remove Ads, the SDK is never started at all, as I described in the in-app purchase article.
Rule 3: the database is the only thing the first screen needs
final database = AppDatabase();
final repository = SanctuaryRepository(database, syncService: syncService);
await repository.initialize();
AppDatabase opens SQLite lazily through Drift, running any pending schema migrations on first access. repository.initialize() seeds the bundled guided-session catalogue into the database, then starts the network work in the background:
Future<void> initialize() async {
await _seedLearnSessions(); // local
unawaited(_syncService?.retryPendingAccountDeletion()); // network
if (await _canAttemptCloudSync()) {
unawaited(syncNow()); // network
}
}
Because the UI only reads from the local database, the first screen is complete as soon as SQLite answers. When sync finishes later, Drift's query streams push the new rows to the screen on their own.
Rule 4: unfinished work is retried on every cold start
Some work can't finish when it's started. The clearest example is account deletion. If the connection is too slow when someone deletes their account, the local data is wiped immediately, and the server-side deletion is recorded as pending in SharedPreferences.
Every cold start retries it, unawaited, before anything else touches the server:
unawaited(_syncService?.retryPendingAccountDeletion());
Sync also checks it first, so new data is never pushed into an account that's supposed to be gone. (The details of that flow are in my Hashnode post on offline-first sync.) The general pattern: persist the intent locally, then retry until it succeeds, on startup and when connectivity returns. Don't make the user wait for the network to agree.
Rule 5: crash reporting is off until the user agrees, and quiet about the network
await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false);
Crashlytics starts disabled. The welcome screen asks for consent, and Settings has a toggle. A journaling app shouldn't send anything anywhere before the user has said yes.
The error handlers are installed before anything else can fail, and they filter one specific kind of noise:
FlutterError.onError = (details) {
if (isRecoverableFirebaseAuthError(details.exception)) {
log('Ignored recoverable Firebase auth connectivity error.',
name: 'mindleau.crashlytics');
return;
}
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
};
bool isRecoverableFirebaseAuthError(Object error) {
return error is FirebaseAuthException &&
error.code == 'network-request-failed';
}
Firebase Auth refreshes tokens in the background and reports network-request-failed as an unhandled error when the device is offline. For a local-first app that's expected behaviour, not a crash. Without the filter, the crash dashboard fills with "the user was on a plane", and the real crashes are harder to see. The filter is deliberately narrow: one exception type and one error code.
Rule 6: build-time modes are flags, not code branches
Store screenshots need deterministic data, no ads, no analytics and no review prompts. Rather than a separate build flavour, that's one .env flag read once in main():
final screenshotMode = dotenv.getBool('MINDLEAU_SCREENSHOT_MODE', fallback: false);
It's passed into the services that care: analytics turns itself off, purchases skip RevenueCat, and the repository seeds fictional fixture data. No service reads the flag on its own, so there's exactly one place to look when screenshots behave differently from the real app.
Rule 7: everything is passed in, so everything can be tested
Every service is constructed in main() and handed to App through its constructor, then exposed with provider. No service is a global singleton that reaches for FirebaseAuth.instance from inside a widget. The repository takes its SyncService as an interface:
final repository = SanctuaryRepository(database, syncService: syncService);
Tests construct the same repository with AppDatabase.forTesting(NativeDatabase.memory()) and a fake sync service that can be made slow, offline or failing on demand. The startup order in main() is the same dependency graph the tests build, just with real implementations.
How to check your own startup
Two quick tests show whether your startup has a network dependency hiding in it:
- Cold start in flight mode. Force-quit, enable flight mode, open the app. It should reach a usable first screen as quickly as it does online.
-
Cold start on a terrible connection. Use the Network Link Conditioner (iOS) or an emulator's network throttling at "Very Bad Network" with packet loss. This is harsher than flight mode, because requests start and then hang instead of failing fast. Any
awaiton the network beforerunApp()shows up here as a long splash screen.
If either one is slow, search main() for every await and ask what it touches. Usually it's one line, and the fix is an unawaited() and a state the UI can show.
You can read more about the app in the Mindleau case study, or see the related posts on one-time in-app purchases and offline-first sync failure cases. If you'd like your app's architecture reviewed, that's what my technical product consulting is for.
Top comments (0)