Engineering

Flutter performance: the rules we follow.

By Zuhair09 Feb 20267 min read
Flutter is fast. It is also fast to make slow. Every Flutter project we've shipped — Zetpin, our internal tools, our partner work — has hit the same wall at some point, and the way out has been a short list of rules we now apply from day one. Here they are. 1. const everything that can be const. The single biggest source of Flutter jank is widgets that should be const but aren't. A const widget is constructed once and reused across rebuilds. A non-const one is rebuilt every time its parent rebuilds, even when nothing about it has changed. Make const the default. If you can't, that's a code review conversation, not a default. We have a lint rule that flags non-const widget constructors when all arguments are const-able. It catches more issues than any profiling session. 2. Profile on a budget Android device every milestone. The iPhone you tested on does not represent your users. We keep two budget phones on the desk — a sub-15k Android with 3GB RAM, and a 2-year-old mid-range. Every milestone, the build runs on those phones before it ships to staging. If the budget phone drops below 55fps in any common screen, we don't move forward. 3. Avoid setState above the visible tree. The further up the tree a setState lives, the more children it forces to rebuild. State should live as low as possible. We treat any setState that lives above the screen-level widget as a smell. Lift state up only when two siblings actually need to share it; lower it back down the moment they don't. 4. Never put more than one ListView.builder inside a CustomScrollView. This one bites every Flutter team eventually. CustomScrollView with multiple ListView.builders nested inside Slivers will recompute layout on every scroll frame. Use SliverList directly. The reason ListView.builder works at all is that it's a special case; nest it and that special case becomes a worst case. 5. Use Hero only when you actually own the route lifecycle. Hero animations look great in demos. They also break in subtle ways when the destination route isn't pushed cleanly — when it's a tab change, a deep link, or a system-driven re-route. We use Hero on a small number of intentional, owned transitions. Everywhere else we use a plain fade or a sized AnimatedContainer. 6. Lazy-decode images via cacheWidth. A 2000-pixel image rendered into a 200-pixel slot is decoded as 2000 pixels. On a list of cards, this is the difference between 60fps and 20. Set cacheWidth (and cacheHeight if you know it) on Image.network and Image.asset. We have a small wrapper that does this automatically based on the parent's constraints; it's saved us more than once. 7. Offload anything CPU-heavy to a compute() isolate. Flutter's UI thread is one thread. Any synchronous work that takes more than a few milliseconds — JSON parsing on a large payload, image processing, regex on a long string — should run in compute(). The cost is a copy across the isolate boundary, which is usually negligible compared to the cost of dropping frames on the main thread. 8. Assume the user has 200 ms of network jitter. This is not really a Flutter rule. It's a product rule that shows up in Flutter code. Every tap that triggers a network call should show a loading state by the next frame. Every network call should have an optimistic-update path where it's safe to do so. Every error should explain itself, and every retry should be one tap away. We don't ship screens where the user can wonder what just happened. None of these are clever. All of them are habits. We learned most of them the hard way — usually by watching the FPS overlay tank on a screen we were certain was fine. The rules above are the ones that survived shipping.
FlutterPerformanceMobile

Have something ambitious in mind?

We reply to every email within 48 hours. Call or async, whichever you prefer.