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, andPostLaunchInitializerwork together - What runs in pre-launch (blocking) vs post-launch (non-blocking)
- How the
Initializerbase 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
| Phase | Timing | Failure handling |
|---|---|---|
| Pre-launch | Before any UI renders | Show error screen, app cannot continue |
| Post-launch | After first frame paints | Degrade gracefully, app still works |
The whole boot, one step at a time:
The user sees: the native splash
- markDartMain()
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:
// 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:
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:
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:
- Run sequential initializers (in order)
- Run parallel initializers (concurrently via
Future.wait) - Seal
FeatureSDKRegistryand readenabledManifests - Call
manifest.registerRoutes(routeRegistry)on each manifest - Call
routeRegistry.lock(). No more routes after this. - Call
manifest.initialize()on each manifest - Return
InitializerResult.success(router: AppRouter(routeRegistry))
// 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.
// 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 theAppRouter
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 ofbootstrapstart()-- ininitState, before pre-launch runsmarkPreLaunchCompleted()-- after pre-launch succeedsmarkFirstFrameRendered()-- inaddPostFrameCallbackstartPostLaunch()/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
| Concern | Phase | Blocking? | On failure |
|---|---|---|---|
| DI container setup | bootstrap (before runApp) | Yes | Zone error |
| Route registration | Pre-launch | Yes | Error screen |
| Manifest initialization | Pre-launch | Yes | Error screen |
| Router creation | Pre-launch | Yes | Error screen |
| Analytics | Post-launch | No | Degrade gracefully |
| Feature flags | Post-launch | No | Use defaults |
Next steps
- GetIt Dependency Injection -- How global DI and microPackage modules manage dependencies
- Feature Manifests and SDKs -- How features register routes and stay lazy