skip to content

A Flutter app keeps its launch screen up for three seconds before the first frame; how do you find and cut what delays it?

level: seniorimportance: should knowfreq 38%

answer

  1. large timeAfterFrameworkInitMicros
  2. awaits before runApp
  3. CPU profile filtered by AppStartUp
  4. deferFirstFrame holds the frame back
  5. defer non-critical work past frame one

basics

~20 s

Trace startup in profile mode; if most time falls after framework init, the delay is your code. Profile the AppStartUp user tag, then shrink what main() awaits before runApp, run it in parallel, and postpone non-critical setup until after the first frame.

solid answer

~50 s

Start with `flutter run --profile --trace-startup`: if `timeAfterFrameworkInitMicros` dominates, the delay is Dart work between binding initialization and the first build, not the engine. Record a CPU profile of the launch and filter it by the `AppStartUp` user tag, which the engine sets on the root isolate at startup and the framework clears after the first frame. The usual culprits: sequential `await`s in `main()` before `runApp` (SDK setup, preferences, database opening, remote config), a synchronous parse of a big asset in the first `initState`, a first screen that builds far more than it shows, and anything holding `deferFirstFrame` such as slow asynchronous localization delegates. Fixes: await only what the first screen truly needs, run the rest in parallel with `Future.wait` or a record's `.wait`, call `runApp` with a lightweight first screen, and start everything else in a post-frame callback.

code

dart · 40 lines
dart
import 'dart:async';

import 'package:flutter/material.dart';

Future<String> loadThemeName() async => 'light';
Future<bool> hasSession() async => false;
void startNonCriticalWork() {
  // Remote config, sync, cache warm-up: nothing the first screen needs.
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Await only what the first screen needs, and in parallel.
  final (String themeName, bool signedIn) =
      await (loadThemeName(), hasSession()).wait;

  runApp(StartupApp(themeName: themeName, signedIn: signedIn));

  // Everything else starts once the first frame is on screen.
  WidgetsBinding.instance.addPostFrameCallback((Duration _) {
    startNonCriticalWork();
  });
}

class StartupApp extends StatelessWidget {
  const StartupApp({super.key, required this.themeName, required this.signedIn});

  final String themeName;
  final bool signedIn;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(child: Text(signedIn ? 'Welcome back' : 'Sign in')),
      ),
    );
  }
}

go deeper

for a junior

Know that everything awaited before runApp keeps the launch screen visible, so main() should do as little as possible.

for a middle

Read the trace-startup keys to tell engine time from your time, and explain parallel awaits and post-frame initialization.

for a senior

Profile launch with the AppStartUp tag, find sequential awaits, heavy first builds and first-frame deferrals, and verify both first frame and first content.

for a principal

Own a startup contract: what may run before the first frame, who approves additions, and how real-device first-frame timings are tracked per release.

## What the three seconds are made of On launch, the platform shows its launch screen until Flutter sends its **first frame**. Everything between the engine starting and that frame is on the critical path: - the engine and Dart VM starting, - your `main()` running, including every `await` before `runApp`, - framework bindings initializing, - the first widget tree building, laying out and painting, - any **deferral** of the first frame. ## Step 1: split engine time from your time Run `flutter run --profile --trace-startup` on a real device and read `build/start_up_info.json`: | Reading | Points at | |---|---| | Large `timeToFrameworkInitMicros` | engine and VM startup, or heavy work in `main()` before the bindings initialize | | Large `timeAfterFrameworkInitMicros` | your code after `WidgetsFlutterBinding.ensureInitialized()`, awaited setup or a heavy first build | | Large gap between built and rasterized | the first frame is expensive to render | In most slow-launch apps the second row dominates: the app spends seconds awaiting setup before it calls `runApp`. ## Step 2: see which code runs Record a CPU profile across launch in DevTools and **filter by the `AppStartUp` user tag**. The engine sets that tag on the root isolate as soon as it starts, and the framework switches back to the default tag once the first frame is rasterized, so the filter isolates exactly the startup window. Look for: 1. **Sequential awaits in `main()`**: SDK initialization, opening a database, reading preferences, fetching remote config, each waiting for the previous one. 2. **Synchronous heavy work**: decoding a large JSON asset or building indexes in `main()` or the first `initState`. 3. **An oversized first build**: a home screen that builds every tab or a long non-lazy list up front. 4. **First-frame deferral**: the framework sends no frame while something holds `RendererBinding.instance.deferFirstFrame()`. `Localizations` does this while asynchronous delegates load, and state restoration does it while restoring. A slow delegate keeps the launch screen up with the CPU idle. ## Step 3: cut the critical path 1. **Await only what the first screen needs.** Theme and whether the user is signed in, perhaps; not analytics, not remote config, not a full sync. 2. **Parallelize what remains.** `await Future.wait([...])`, or with records `final (a, b) = await (loadA(), loadB()).wait;`. 3. **Call `runApp` early** with a light first screen that can show a skeleton while data arrives. 4. **Start non-critical setup after the first frame** with `WidgetsBinding.instance.addPostFrameCallback`. 5. **Move heavy parsing off the main isolate** or cache its result from the previous launch. 6. **Keep localization delegates cheap**; load large translation data lazily. ## Step 4: verify Re-run the trace. Both `timeAfterFrameworkInitMicros` and `timeToFirstFrameRasterizedMicros` should drop, and the AppStartUp-filtered profile should show far less work. Then check the time until **real content** is visible: a fast first frame that shows a spinner for three more seconds only moved the problem. ## Out of scope for this fix Pre-warming an engine before Flutter UI is shown is a technique for apps that embed Flutter in a native app. Budgeting cold start across teams is an architecture decision of its own.

  • Why can a slow LocalizationsDelegate keep the launch screen up even though the CPU is idle?
    While an asynchronous delegate's `load` future is pending, `Localizations` calls `deferFirstFrame()`, so the framework builds frames but does not send them to the engine. Nothing is painted until the delegate completes and `allowFirstFrame()` is called. Make delegates fast or load large translation data lazily.
  • Is moving setup into addPostFrameCallback always safe?
    Only for work the first screen does not depend on. If the first screen reads a value that post-frame setup produces, it must handle 'not ready yet', for example with a placeholder and a listener. Moving a dependency without handling that swaps a slow launch for a broken first screen.

saying these in an interview costs you the question

  • A long launch screen is always the engine's fault.
  • Every SDK must be initialized before runApp.
  • Awaiting each setup call in sequence is as fast as awaiting them together.
  • If the CPU is idle during launch, nothing is holding the first frame back.
  • A fast first frame means users see their content sooner.