Skip to content
Back to archipelago.banuacoder.com

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 ​

bash
archipelago generate launch_tracker_sdk

The prompts are:

  • appName: your app name, for example MyApp
  • isForMonorepo: leave this as true

Or use a config file:

json
{
  "appName": "MyApp",
  "isForMonorepo": true
}
bash
archipelago generate launch_tracker_sdk --config launch_tracker_config.json

flutter_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.

text
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.dart

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 AppLaunchTrackerImplPackageModule in lib/di/injector.dart.
  • bootstrap() calls markDartMain() first.
  • HostAppWidget calls start() before pre-launch runs and markPreLaunchCompleted() after it. It calls markFirstFrameRendered() and startPostLaunch() 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 ​

MethodPhase
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:

dart
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):

dart
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:

dart
// 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:

dart
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:

dart
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:

dart
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 ​

GoalWhat to do
Track a feature flag fetch timetraceAsync('feature_flags', () => flags.fetch())
Show a sub-step in the launch tracetraceLaunchStep('warm_cache', () => cache.warm()) inside an initializer
Record SDK init durationrecordDuration('analytics_init_ms', elapsed)
Add a named milestonerecordStep('user_profile_loaded')
Time a splash kept with FlutterNativeSplash.preserveremoveNativeSplash(FlutterNativeSplash.remove)
Cut span volumeLower the sample rate in your monitoring vendor options

Next Steps ​