Flutter Startup Tracer
Measure your Flutter app's startup from process start to "the user can actually use it", see every phase on a waterfall, and get told what to fix.
Summary.
flutter_startup_tracermeasures and explains app startup for any Flutter app. CallStartupTracer.start()on the first line ofmain, wrap your init calls intrace.phase(...)/trace.parallel({...}), start the app withtrace.runApp(...), and mark the moment your first real screen is usable withmarkInteractive('home'). You get one timeline: process start (read natively on Android and iOS, with iOS pre-warm detection) → Dart main → every phase (sync or async, nested, parallel groups with wall time vs sum) → runApp → first frame built / rasterized (engineFrameTiming) → interactive → deferred tasks that run after the first frame. The report prints as an ASCII waterfall, exports to Markdown and JSON, shows up in the DevTools timeline, and comes with advice: slow phases to defer, serial awaits that could run in parallel, a heavy first build, a slow native start, and a warning when you measured in debug mode. No dependencies beyond Flutter.
You add one line at the start of main, wrap your init in phase and parallel, and the package
tells you where your app spends its time, from the moment the OS starts the process until the user
can use the first screen, and what to defer or run in parallel so it opens faster.
✨ What it does
- ⏱ Measures everything from the start of
main: everyphase(sync or async), phases nested inside each other, and forparallelyou get the wall time (what you actually waited) next to the sum of the tasks (what you would have waited if you ran them one after another). - 📱 Native process start time: on Android from
Process.getStartRequestedElapsedRealtime(orgetStartElapsedRealtimebefore API 33), and on iOS fromsysctl(KERN_PROC_PID), together with detection of iOS pre-warming. On web/desktop and in tests it reports "unknown" and doesn't crash. - 🖼 First frame:
StartupTracerScoperecordsrunApp, when the first frame was built, and when the engine actually rasterized it (FrameTiming.rasterFinishWallTime), so you're not just measuring "the code finished", you're measuring "the screen appeared". - ✅ "The user can use it":
markInteractive('home')from the first real screen once its data has arrived. - 💤
deferred: things the first screen doesn't need (ads, analytics…) run after the first frame and are measured separately, outside the critical path. - 📊 The report: in the console as an ASCII waterfall, or
toMarkdown()to put in a PR, ortoJson()to send to any backend. - 🧭 Advice: tells you what to defer, what could be
parallel, whether the first build is heavy, whether the native start is slow, or whether you're measuring in debug in the first place. - 🔬 DevTools: every phase shows up in the Performance view as timeline events.
- 🏷 Banner: a small banner over the app that shows the number for 5 seconds (debug/profile only).
📦 Installation
Add it from pub.dev:
dependencies:
flutter_startup_tracer: ^0.1.0
Then flutter pub get. There is no native setup: the plugin registers itself on Android and iOS.
🚀 Usage
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_startup_tracer/flutter_startup_tracer.dart';
Future<void> main() async {
final trace = StartupTracer.start( // first line: t0 = Dart main
waitForInteractive: true, // finish only after markInteractive()
onReport: (report) => myLogger.info(report.toJson()),
);
WidgetsFlutterBinding.ensureInitialized(); // outside any phase (see FAQ)
trace.phaseSync('parse config', parseBundledConfig);
await trace.phase('firebase', () => Firebase.initializeApp());
await trace.parallel({
'hive': () => Hive.initFlutter(),
'prefs': () => SharedPreferences.getInstance(),
}, name: 'storage');
await trace.phase('session', restoreSession, dependsOnPrevious: true);
trace.mark('before runApp');
unawaited(trace.deferred('ads', () => MobileAds.instance.initialize()));
trace.runApp(const StartupTracerBanner(child: MyApp()));
// same as: runApp(StartupTracerScope(tracer: trace, child: ...))
}
// In the first real screen, once its data is on screen:
StartupTracer.of(context).markInteractive('home');
🧾 What the report looks like
This is a real report from example/ on the iOS simulator (debug, so the numbers are slower than
in reality):
== Startup trace (debug) - total 1154 ms ==
process start -> main : 1625 ms (ios:sysctl(KERN_PROC_PID).p_starttime)
main -> runApp : 708 ms
main -> first frame : 1043 ms (rasterized)
main -> interactive : 1154 ms ('home')
process start -> interactive: 2779 ms
phase start ms |0 1389 ms|
parse bundled config 46 40 | ## | sync
crash reporting SDK 86 228 | ######## |
feature flags 315 93 | ### |
remote strings 407 113 | #### |
[parallel] storage 520 155 | ====== | wall 155 / sum 269 ms
database 522 153 | ##### |
preferences 522 43 | ## |
secure storage 522 73 | ### |
session 675 32 | ## |
* before runApp 708 | | |
> runApp 708 | | |
> first frame built 1036 | | |
> first frame rasterized 1043 | | |
(deferred) ads SDK 1087 302 | ~~~~~~~~~|
(deferred) analytics 1087 122 | ~~~~ |
> interactive: home 1154 | | |
legend: # phase = parallel group ~ deferred | instant * mark > milestone
Advice:
[!] Measured in debug mode (JIT, asserts, no AOT): numbers are several times slower than what users see. Measure with --profile or --release.
[!] 1625 ms passed between the process starting and Dart main. Look at native work before the engine runs: ...
[!] 'crash reporting SDK' took 228 ms on the critical path. If the first screen does not need it, consider deferring it with deferred('crash reporting SDK', ...) or initialising it lazily on first use.
[!] 'crash reporting SDK', 'feature flags', 'remote strings' ran one after another (434 ms in total). If they do not depend on each other, run them with parallel({...}): best case about 228 ms, saving up to 206 ms. ...
🧭 Advice
| Rule | When it fires | Default |
|---|---|---|
debugMode |
The report was produced in debug | Always |
slowCriticalPhase |
A phase before runApp (or a task inside parallel) took a long time → "defer it" |
≥ 100 ms |
serialPhases |
Async phases one after another, none of which says dependsOnPrevious → "run them parallel", with the expected saving |
Sum ≥ 150 ms, and each ≥ 20 ms |
heavyFirstBuild |
A long time from runApp to the first frame |
> 500 ms |
slowNativeStart |
A long time from process start to main |
≥ 1000 ms |
prewarmed |
iOS said the process was pre-warmed, or the gap is ≥ 10 seconds → not counted in the total | — |
failedPhase |
A phase threw an exception | — |
All the thresholds can be changed through StartupAdviceConfig. If a phase really does need the one
before it, pass dependsOnPrevious: true and the parallel rule won't suggest it.
📤 Where to send the report
printReport(on by default outside release) prints the report to the console as soon as it is done.onReportis called once with the final report: sendreport.toJson()to Crashlytics, analytics or your logs.StartupTracer.historyholds the last 5 reports (historyLimit) in memory only. Nothing is written to disk; persisting is up to you, fromonReport.trace.report()can be called at any time to get a snapshot up to that moment.trace.completed(aValueListenable) if you want to build UI on top of the report.
✅ When the report completes
When all of the following have happened: the first frame was built, all deferred tasks finished,
the native start time was read (or found to be unknown), and, if waitForInteractive: true,
markInteractive was also called.
Deferred tasks start as soon as the engine reports that the first frame was rasterized; if it
doesn't report it (tests, headless), they start after deferredFallback (2 seconds by default)
from the first build.
🧪 In tests
StartupTracer.start(clock: myFakeClock): theTracerClockis injectable, so you can test the timings exactly.- In widget tests pass
processStartReader: () async => null: the real platform channel replies outside the fake time oftestWidgets, and the report would never complete. StartupTracer.debugReset()clearscurrentandhistorybetween tests.
❓ FAQ
Why are the numbers so high in debug? Debug runs JIT and asserts, without AOT, so it can be two
or three times slower. Measure with flutter run --profile or --release; the report itself warns
you.
Why can't I call ensureInitialized() inside a phase? A phase runs the code in a new zone so
it knows what is inside what (that's what makes nesting work even with await). Flutter warns if
the binding is created in one zone and runApp runs in another. Call it outside any phase, or
inside phaseSync (which doesn't create a zone).
"process start → main" is huge on iOS? iOS 15+ can launch the process before the user taps the icon (pre-warming). If the OS says so, or the gap is more than 10 seconds, the report marks it "not counted" and leaves it out of the total.
Does the package slow down startup itself? Practically not: each phase is two stopwatch reads and a few objects. The native call happens once, after the first frame, not during launch.
Can I use it in release? Yes. Printing and the banner are off by default in release, but the
measurement and onReport keep working, so you can collect real numbers from users.
I don't use trace.runApp? Just wrap the app in StartupTracerScope(tracer: trace, child: ...).
And if you won't use the scope at all, call markRunApp(), markFirstFrameBuilt() and
releaseDeferred() yourself.
⚖️ Limitations
- t0 is the
StartupTracer.start()line. Anything beforemain(engine loading, native init) only shows up as a single "process start → main" number, without details. - The process start time is computed with the wall clock, so if the clock is adjusted at that same moment the number will be wrong. On Android before API 33 it starts from the fork, not from the launch request.
- "first frame rasterized" needs the engine to send
FrameTiming. In tests and headless it isn't available, and the report says "built". - The
paralleladvice is a guess: the package only knows what depends on what fromdependsOnPrevious. deferredstarts after the first frame, but if its work is heavy on the UI isolate it will still compete with the frames that follow. Split it up or make it lazy.historyis in memory only, and a hot restart clears it.- There is no native start on web/desktop (it is reported as unknown).
- If
waitForInteractive: trueand nobody callsmarkInteractive, the report won't complete (andreport()still works).
🛠 API at a glance
| API | What it does |
|---|---|
StartupTracer.start({...}) |
Starts tracing; options: waitForInteractive, onReport, printReport, printer, timelineEvents, adviceConfig, deferredFallback, clock, processStartReader |
phase(name, body, {dependsOnPrevious}) |
Measures async (or sync) work in a child zone; nests |
phaseSync(name, body) |
Measures synchronous work in the caller's zone |
parallel({name: body}, {name}) |
Runs tasks concurrently; records wall time vs sum; returns results by name |
deferred(name, body) |
Runs after the first frame; returns a future of the task |
mark(name) / markInteractive(name) |
Instants on the timeline; the first markInteractive is the "interactive" milestone |
runApp(app) |
markRunApp() + Flutter runApp wrapped in StartupTracerScope |
StartupTracer.of(context) / maybeOf / current |
Find the tracer |
report() / completed / history |
Snapshot, final report notifier, last N reports in memory |
StartupReport |
toPrettyString(), toMarkdown(), toJson(), advice, total, processStartToMain, ... |
StartupTracerScope |
Captures runApp, first frame built and rasterized |
StartupTracerBanner |
On-screen total for 5 s (debug/profile by default) |
🏗 Project structure
| Path | Role |
|---|---|
lib/src/tracer.dart |
StartupTracer: spans, zones for nesting, deferred queue, completion |
lib/src/scope.dart |
StartupTracerScope: runApp, post-frame and FrameTiming capture |
lib/src/report.dart |
StartupReport: totals, JSON |
lib/src/waterfall.dart |
ASCII waterfall, console and Markdown output |
lib/src/advice.dart |
Advice rules and thresholds |
lib/src/native.dart |
Method channel to the native process-start reader |
lib/src/banner.dart |
StartupTracerBanner |
android/.../FlutterStartupTracerPlugin.kt |
Process.getStart*ElapsedRealtime → epoch micros |
ios/.../FlutterStartupTracerPlugin.swift |
sysctl(KERN_PROC_PID) → epoch micros, ActivePrewarm |
example/ |
App with deliberately slow phases |
🚀 Development
flutter pub get
flutter analyze
flutter test
cd example && flutter test && flutter run --profile
📄 License
👤 Author
Youssef Belal Zakaria · GitHub @youssef336
⭐ If you like this project, give it a star!
Libraries
- flutter_startup_tracer
- Measure and explain Flutter app startup: process start, phases in
main, first frame, interactive, and deferred work, with a waterfall and advice.