DEV Community

Cover image for A Flutter App Startup That Never Waits on the Network
Ayesha Iftikhar
Ayesha Iftikhar

Posted on Originally published at aishayy.com AI-assisted

A Flutter App Startup That Never Waits on the Network

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 .env file, SharedPreferences and the SQLite database. Anything that needs the network, such as sync, ads, purchases and account cleanup, is started afterwards with unawaited() or a post-frame callback, and none of it can throw out of main(). 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, ...));
}
Enter fullscreen mode Exit fullscreen mode

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 {
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode
bool isRecoverableFirebaseAuthError(Object error) {
  return error is FirebaseAuthException &&
      error.code == 'network-request-failed';
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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 await on the network before runApp() 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)