- Published on
Flutter Performance Part 1: Stop Guessing, Start Profiling
- Authors

- Name
- Phat Tran
Every Flutter developer has lived through this. The transaction history screen looks perfect on your emulator. Then QA installs the build on a mid-range Android phone and reports a stutter when scrolling.
The usual reaction is to start guessing: wrapping random widgets in const, deleting animations, rewriting a screen that was probably fine all along. I want to talk you out of that habit, because guessing is how a one-hour fix turns into a lost week.
Performance work has one rule that outranks all others: measure first, optimize later. Every technique in parts 2 through 5 of this series is useless if you aim it at the wrong problem, so this first post is entirely about diagnosis.
1. Never test performance in debug mode
This is the most common mistake I see. You run the app from your IDE, notice lag, and panic.
Debug mode compiles Dart with the JIT (Just-In-Time) compiler. That is what makes hot reload possible, but it adds heavy runtime overhead: animations stutter and calculations run far slower than they ever will in production. Debug numbers are noise. Ignore them.
Test on a real physical device in profile mode instead. Profile mode uses the same AOT (Ahead-Of-Time) compilation as the builds you ship to the App Store and Google Play, while keeping just enough hooks open for DevTools to observe the app.
# Run this in your terminal with a real device plugged in
flutter run --profile
Emulators are not much better than debug mode for this job. They borrow your desktop's CPU and GPU, so they hide exactly the problems that a three-year-old phone will expose.
2. Reading the performance overlay
With the app running in profile mode, turn on the performance overlay from DevTools, or just press p in the terminal where flutter run is active.
Two graphs appear, and it pays to know which is which:
- The top graph is the raster thread (older docs call it the GPU thread). It turns your widget tree into pixels. When this one spikes, your Dart code is fine but the drawing is too expensive. Think stacked
Opacitylayers,ClipPath, or a heavy shadow on every bank card in a list. - The bottom graph is the UI thread, where your Dart code runs. When it spikes, the work itself is too heavy: parsing a large JSON payload of transactions synchronously, or rebuilding far more widgets than the frame actually needed.
A note on Impeller: since Flutter 3.10 on iOS (and progressively on Android), rendering uses the Impeller engine instead of Skia. Impeller precompiles shaders, which removes most of the first-run shader jank that used to make animations hiccup on their very first play. If your raster graph still spikes under Impeller, the scene itself is genuinely too complex, and no engine will save you from that.
3. The CPU profiler: naming the culprit
Say the UI thread graph went red while the user searched for a beneficiary. Which function did it?
Open DevTools in the browser, go to the CPU profiler tab, hit record, reproduce the laggy action on the phone, and stop. Then read the flame chart:
- Width is time. Look for the widest bars first.
- Drill down from top to bottom until you land in your own code.
The culprit is rarely dramatic. More often it is an innocent-looking utility, like calling NumberFormat.simpleCurrency().format(amount) inside every row of a ListView.builder. Constructing the formatter is the expensive part, and doing it per row can eat 8ms of your 16ms frame budget. Hoist it out of the build method and the spike disappears.
One habit worth building while you are in there: wrap suspicious sections in Timeline.startSync('parse transactions') and Timeline.finishSync() from dart:developer. Your own labels then show up in the DevTools timeline, which makes the next investigation much faster than this one.
Once you have numbers instead of hunches, you can fix things with confidence.
Up next: putting your UI on a diet
Part 2 moves from diagnosis to treatment on the UI thread: taming rebuilds with const, extracting widgets instead of helper methods, RepaintBoundary, and making BLoC and Signals rebuild only what actually changed.