Skip to content
Back to archipelago.banuacoder.com

Architecture Deep Dives

Understanding Two-Phase Initialization ​

What you'll learn ​

  • The complete boot sequence from main() to first frame
  • How bootstrap.dart, HostAppWidget, PreLaunchInitializer, and PostLaunchInitializer work together
  • What runs in pre-launch (blocking) vs post-launch (non-blocking)
  • How the Initializer base class supports sequential and parallel execution

The problem ​

Flutter apps need to initialize many things at startup: DI containers, routers, analytics, feature flags, remote config. Two bad extremes exist:

  • Block on everything -- The splash screen hangs for seconds while analytics and remote config load.
  • Make everything async -- The app renders before routes are registered. Navigation crashes.

The solution: two phases ​

PhaseTimingFailure handling
Pre-launchBefore any UI rendersShow error screen, app cannot continue
Post-launchAfter first frame paintsDegrade gracefully, app still works

The whole boot, one step at a time:

startupBlock on what the first screen needs, then do the rest

Step 1 of 5, bootstrap: main() calls bootstrap, which does the few things that must exist before any widget: error handlers, consent, the dependency container. Then runApp mounts HostAppWidget. Monitoring starts without being awaited.

The user sees: the native splash

  1. markDartMain()
apps/template_app/lib/bootstrap.dart
markDartMain();Flavor.status = flavor;ensureAppBinding(isInteractiveElement: isUiKitInteractive);installGlobalErrorHandlers();await ConsentInitializer.initialize(GetIt.I);// Firebase.initializeApp(), guarded …await AppLocaleController.restore();await initializeInjector();unawaited(initializeMonitoring());// …runApp(  HostAppWidget(    initializerController: PreLaunchInitializerController.create(),    builder: (context, result) => builder(result),    // …  ),);

The bootstrap entry point ​

Everything starts in bootstrap.dart. It runs inside runZonedGuarded for top-level error handling:

dart
// apps/template_app/lib/bootstrap.dart (trimmed)
Future<void> bootstrap({
  required FlavorStatus flavor,
  required AppBuilder builder,
}) async => runZonedGuarded(
  () async {
    markDartMain();
    Flavor.status = flavor;
    ensureAppBinding(isInteractiveElement: isUiKitInteractive);
    installGlobalErrorHandlers();
    await ConsentInitializer.initialize(GetIt.I);
    // Firebase.initializeApp(), guarded …
    await AppLocaleController.restore();
    await initializeInjector();
    unawaited(initializeMonitoring());
    // …
    runApp(
      HostAppWidget(
        initializerController: PreLaunchInitializerController.create(),
        builder: (context, result) => builder(result),
        // errorBuilder: …
      ),
    );
  },
  (error, stackTrace) {
    safeReportFatalCrash(error, stackTrace);
    // …
  },
);

Consent is installed first, because nothing may resolve monitoring or analytics before its gate exists. initializeInjector() runs before runApp(). When HostAppWidget mounts, every DI module has run. Each feature has registered its FeatureManifest with FeatureSDKRegistry. Feature SDKs are lazy, so none is built yet.

The Initializer base class ​

Both pre-launch and post-launch initializers extend Initializer:

dart
abstract class Initializer {
  const Initializer();

  bool get canRunInParallel => true;
  Future<InitializerResult> initialize();
  Future<bool> dispose();
}

The canRunInParallel flag controls execution: sequential initializers run first (in order), then parallel initializers run concurrently via Future.wait.

InitializerResult carries success status and an optional AppRouter:

dart
class InitializerResult {
  const InitializerResult.success({this.router});
  const InitializerResult.failure(this.error);

  final bool isInitializationSuccessful;
  final AppRouter? router;
  final Object? error;

  AppRouter get requireRouter => router!;
}

Phase 1: PreLaunchInitializer (blocking) ​

PreLaunchInitializerController wraps PreLaunchInitializer and is passed to HostAppWidget. The initialize() method in HostAppWidget.initState() triggers this sequence:

  1. Run sequential initializers (in order)
  2. Run parallel initializers (concurrently via Future.wait)
  3. Seal FeatureSDKRegistry and read enabledManifests
  4. Call manifest.registerRoutes(routeRegistry) on each manifest
  5. Call routeRegistry.lock(). No more routes after this.
  6. Call manifest.initialize() on each manifest
  7. Return InitializerResult.success(router: AppRouter(routeRegistry))
dart
// Simplified from PreLaunchInitializer.initialize()
final registry = FeatureSDKRegistry.instance..seal();
final manifests = registry.enabledManifests;

final routeRegistry = getIt<RouteRegistry>();
for (final manifest in manifests) {
  manifest.registerRoutes(routeRegistry);
}
routeRegistry.lock();

for (final manifest in manifests) {
  await manifest.initialize();
}

return InitializerResult.success(router: AppRouter(routeRegistry));

Sealing the registry first makes a manifest that registers late fail loudly in debug builds. The real code wraps each manifest call in a degradable runner. One manifest that throws is reported to monitoring and does not block the others. The phase has a 5 second budget. Going over it logs a warning and a monitoring breadcrumb.

Bricks add their own initializers between the archipelago:pre_launch_initializers:start and :end markers in pre_launch_initializer.dart. Do not edit inside the markers by hand.

If any sequential or parallel initializer fails, initialization aborts and returns the failure result.

Phase 2: PostLaunchInitializer (non-blocking) ​

Post-launch runs after the first frame is painted, scheduled via addPostFrameCallback in HostAppWidget.initState(). It follows the same sequential-then-parallel pattern as pre-launch, but failures here do not crash the app.

dart
// apps/template_app/lib/host/initializers/post_launch_initializer.dart (trimmed)
factory PostLaunchInitializer.create({
  required BuildContext context,
  required AppRouter router,
}) => PostLaunchInitializer._([
  _AnalyticsPostLaunchInitializer(),
  _FeatureFlagPostLaunchInitializer(),
  _DeeplinkPostInitializer(router: router),
  _AuthStorageClearInitializer(),
  // archipelago:post_launch_initializers:start
  PartnerManifestInitializer(),
  JourneyTrackerInitializer(),
  // archipelago:post_launch_initializers:end
]);

The default list holds the analytics, feature-flag and deep-link steps. Bricks such as journey_tracker add theirs between the archipelago:post_launch_initializers markers. A remote flag named post_launch_enabled gates the whole phase. It defaults to on. Turn it off remotely to skip a bad post-launch step without a store release.

HostAppWidget: the orchestrator ​

HostAppWidget ties both phases together. Pre-launch runs in initState(), and the result drives a FutureBuilder with AnimatedSwitcher for smooth transitions:

flowchart TD
  init["HostAppWidget.initState()"]
  subgraph pre["PreLaunchInitializerController.initialize()"]
    p1["Sequential initializers (in order)"] --> p2["Parallel initializers (concurrent)"]
    p2 --> p3["Manifest route registration"] --> p4["RouteRegistry.lock()"]
    p4 --> p5["Manifest initialization"] --> p6["Return InitializerResult with AppRouter"]
  end
  complete["Completer.complete(result)"]
  switcher["AnimatedSwitcher: loading → app (FadeTransition)"]
  subgraph post["addPostFrameCallback()"]
    f1["markFirstFrameRendered()"] --> q1
    subgraph postInit["PostLaunchInitializer.create().initialize()"]
      q1["Sequential initializers"] --> q2["Parallel initializers"] --> q3["markPostLaunchCompleted()"]
    end
  end
  init -->|1| p1
  init -->|2| complete
  init -->|3| f1
  p6 -.->|result| complete
  complete -->|FutureBuilder rebuilds| switcher

The widget shows three states via AnimatedSwitcher:

  • Loading (default CircularProgressIndicator) -- while pre-launch runs
  • Error -- if pre-launch fails, shows error message
  • App -- on success, renders builder(context, result) with the AppRouter

The AnimatedSwitcher uses a FadeTransition by default (300ms). Each state sits in a KeyedSubtree with a ValueKey ('loading', 'error', 'app'). The keys let the switcher animate between states.

Launch tracker ​

HostAppWidget records the timing milestones through AppLaunchTracker (app_launch_tracker_impl), when one is registered:

  • markDartMain() -- first statement of bootstrap
  • start() -- in initState, before pre-launch runs
  • markPreLaunchCompleted() -- after pre-launch succeeds
  • markFirstFrameRendered() -- in addPostFrameCallback
  • startPostLaunch() / markPostLaunchCompleted() -- around post-launch

Each initializer also reports a span, so a slow step shows up by name. Pre-launch has a 5 second budget and post-launch 10 seconds. Going over one logs a warning and a monitoring breadcrumb. It never fails the phase.

Summary ​

ConcernPhaseBlocking?On failure
DI container setupbootstrap (before runApp)YesZone error
Route registrationPre-launchYesError screen
Manifest initializationPre-launchYesError screen
Router creationPre-launchYesError screen
AnalyticsPost-launchNoDegrade gracefully
Feature flagsPost-launchNoUse defaults

Next steps ​