Infrastructure SDKs
How to measure Flutter app launch time
Free Reference for launch_tracker_sdk: every variable, option and package it generates. Command: archipelago generate launch_tracker_sdk
Generate the Launch Tracker SDK, record named steps and durations, send launch spans to your monitoring vendors, and read the trace to find the slow step.
What you'll learn
- Generating the Launch Tracker SDK, which ships in release builds
- Checking what the generator already wired into the host app
- Recording named steps and custom durations for performance analysis
- Sending launch spans through your monitoring vendors
- Reading the structured launch trace: which step is slow, which ran in parallel, which blocked the first frame
Prerequisites
- An existing Archipelago monorepo (see Monorepo Scaffolding)
- Monitoring SDK generated, because spans go out through
AppMonitoring
Step 1: Generate the Launch Tracker SDK
archipelago generate launch_tracker_sdkThe prompts are:
- appName: your app name, for example
MyApp - isForMonorepo: leave this as
true
Or use a config file:
{
"appName": "MyApp",
"isForMonorepo": true
}archipelago generate launch_tracker_sdk --config launch_tracker_config.jsonflutter_modular_monorepo always includes the Launch Tracker SDK in Phase 3. Skip this step in a new project.
Step 2: Understand the Generated Structure
The Launch Tracker SDK is a single package. There is no _api and no noop: the tracker ships in release builds too, so build_prepare does not touch it.
infrastructure/
└── app_launch_tracker_impl/ # OTLP-backed implementation
└── lib/
├── app_launch_tracker_impl.dart
└── src/
├── app_launch_tracker_impl.dart
├── launch_trace.dart
└── di/injector.module.dartStep 3: Release builds and consent
build_prepare.yaml has no entry for the tracker. The production flavor leaves it in place, so production builds measure cold launches.
Spans leave the device only through AppMonitoring.recordTransaction. ConsentAwareMonitoring drops them until the user grants diagnostics consent. Each launch sends one app_launch span with up to four child spans, plus one span per initializer. Check your vendor quota (Sentry transactions, Grafana traces) and lower the sample rate in the vendor options if needed.
Step 4: Check What the Generator Wired
You write no lifecycle code. The generated host app already does the following:
- The post-generation hook registers
AppLaunchTrackerImplPackageModuleinlib/di/injector.dart. bootstrap()callsmarkDartMain()first.HostAppWidgetcallsstart()before pre-launch runs andmarkPreLaunchCompleted()after it. It callsmarkFirstFrameRendered()andstartPostLaunch()after the first frame.markPostLaunchCompleted()runs when post-launch finishes. That call pushes the spans.- The initializer runner records one span per initializer.
The tracker is not injected into the launch flow by hand. Do not copy lifecycle calls into your own widgets.
Step 5: Know the Lifecycle Methods
| Method | Phase |
|---|---|
start() | Before pre-launch |
markPreLaunchCompleted() | After pre-launch |
markFirstFrameRendered() | First frame |
startPostLaunch() | Start of post-launch |
markPostLaunchCompleted() | End of post-launch, spans pushed |
Read them in HostAppWidget in lib/host/host_app_widget.dart if you need to change the order.
Step 6: Record Named Milestones
Use recordStep to capture named checkpoints within the launch sequence:
getIt<AppLaunchTracker>().recordStep('remote_config_fetched');
await remoteConfig.fetchAndActivate();
getIt<AppLaunchTracker>().recordStep('remote_config_applied');Use recordDuration when you already have an elapsed time (for example, from a third-party SDK callback):
getIt<AppLaunchTracker>().recordDuration('firebase_init_ms', firebaseInitDuration.inMilliseconds);Step 7: Trace Async and Sync Work
The SDK includes three tracing utilities that wrap work in named spans:
// Async work — returns the result of the block
final config = await traceAsync(
'load_remote_config',
() => remoteConfig.fetchAndActivate(),
);
// Sync work — returns the result of the block
final parsed = traceSync(
'parse_app_config',
() => AppConfig.fromJson(rawJson),
);
// Async work with error forwarding to AppMonitoring
final activated = await safeTraceProcessAsync(
key: 'fetch_remote_config',
block: () => remoteConfig.fetchAndActivate(),
monitoring: getIt<AppMonitoring>(),
);safeTraceProcessAsync checks that AppMonitoring is registered, so it is safe to call before monitoring is fully set up. Use traceAsync and traceSync for plain timing.
Step 8: Read the Launch Trace
The tracker also builds a LaunchTrace (schema v1) of the whole launch. The host initializer runner records one span per initializer. A span holds the phase, the mode and the status. The mode is sequential, or parallel with a shared group. The status is ok, failed or timeout. A span also says whether it blocked the first frame.
Break a slow initializer into sub-steps with traceLaunchStep. It nests under the initializer that called it and needs no DI lookup:
class DatabaseInitializer extends Initializer {
@override
Future<InitializerResult> initialize() async {
await traceLaunchStep('openDatabase', () => database.open());
await traceLaunchStep('runMigrations', () => database.migrate());
return const InitializerResult.success();
}
@override
Future<bool> dispose() async => true;
}Read the trace after post-launch, or listen for it:
final tracker = getIt<AppLaunchTracker>();
tracker.launchTraces.listen((trace) {
final slowest = [...trace.spans]
..sort((a, b) => b.durationMs.compareTo(a.durationMs));
debugPrint('Slowest: ${slowest.first.name} ${slowest.first.durationMs} ms');
debugPrint(jsonEncode(trace.toJson())); // schemaVersion: 1
});Each span has startOffsetMs and durationMs from process start, so it draws straight into a waterfall:
gantt
title Cold launch (ms from process start)
dateFormat x
axisFormat %L
section Blocks first frame
native :done, 0, 182
engine and bootstrap :done, 182, 424
DatabaseInitializer (seq) :424, 520
SessionInitializer (par) :521, 641
ThemeInitializer (par) :521, 565
first frame :milestone, 743, 0
section After first frame
Analytics (par) :760, 971
FeatureFlags (par, timeout) :crit, 760, 1311
See the brick reference for the full JSON shape.
Step 9: See the Native Part of the Launch
Since launch_tracker_sdk 3.3.0 the trace also covers what happens before Flutter draws: the launch request (Android 13+), native app init, the system splash, engine attach, Dart main() and Flutter's first frame. It also says whether the launch was cold, warm or prewarm (trace.launchType).
You need no Android code: the plugin merges a small ContentProvider into your manifest. On iOS the generated AppDelegate already has one guarded line at the top of didFinishLaunchingWithOptions (LaunchTimeTrackerPlugin.markAppInitStart()), and bootstrap() calls markDartMain() first. If you rewrote either file, put those calls back. The iOS one sits inside #if canImport(app_launch_tracker_impl), so it still compiles without the tracker.
If you keep the splash on screen with FlutterNativeSplash.preserve, remove it through the tracker:
removeNativeSplash(FlutterNativeSplash.remove);A full cold launch then reads like this:
gantt
title Cold launch, Android 13 (ms from launch request)
dateFormat x
axisFormat %L
section Native
splash visible :active, 0, 472
native app init :done, 52, 96
native (to engine attach) :done, 38, 215
section Engine and Dart
engine and bootstrap :done, 215, 424
Dart main :milestone, 331, 0
section Pre-launch
pre-launch initializers :424, 713
native first frame (loading) :milestone, 472, 0
section First frame
first frame of content :713, 743
section Post-launch
post-launch initializers :744, 1312
interactive :milestone, 1312, 0
The splash leaves at Flutter's first frame, usually the loading screen shown during pre-launch. The first_frame of the app content comes after pre-launch. On Android the tracker also calls Activity.reportFullyDrawn() at interactive, so the Play Console time to full display matches. On iOS an interactive signpost shows in Instruments (Points of Interest).
See Milestones per platform for how each marker is measured and how accurate it is.
Step 10: See the Spans in Your Monitoring Vendor
Completed spans go through AppMonitoring.recordTransaction. They reach every monitoring vendor you installed, after the user grants diagnostics consent. With the Grafana vendor, open Tempo and look for the app_launch span. With Sentry, look under Performance.
The debug panel also draws the trace. See the Launch tab in App Debugger. Blackbox sessions carry the trace too.
Common Customizations
| Goal | What to do |
|---|---|
| Track a feature flag fetch time | traceAsync('feature_flags', () => flags.fetch()) |
| Show a sub-step in the launch trace | traceLaunchStep('warm_cache', () => cache.warm()) inside an initializer |
| Record SDK init duration | recordDuration('analytics_init_ms', elapsed) |
| Add a named milestone | recordStep('user_profile_loaded') |
Time a splash kept with FlutterNativeSplash.preserve | removeNativeSplash(FlutterNativeSplash.remove) |
| Cut span volume | Lower the sample rate in your monitoring vendor options |
Next Steps
- Set up monitoring to receive the OTLP spans pushed by the tracker
- Configure push notifications as part of your post-launch initialization
- Read the launch timeline to learn how to use the numbers