Improve Your App's Performance: 5 Effective Tips

Improving performance isn't about fancy tech — it's about a handful of high-leverage habits, applied consistently. Here are the five that matter most, each with enough code to actually show the mechanism rather than just name it. If any one of these grabs you, I've gone deeper on rebuilds, DevTools, and isolates in separate posts linked at the end.
1. Use const constructors — and see what they actually skip
const doesn't just save a little compile-time work — it tells Flutter the widget and everything inside it are identical to last time, so the whole subtree gets skipped during a rebuild rather than rebuilt and found unchanged. The difference is easiest to see with a debug print on both a const and a non-const sibling:
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
ElevatedButton(
onPressed: () => setState(() => count++),
child: Text('Count: $count'), // not const — reads live state, must rebuild
),
const ExpensiveHeader(), // const — Flutter skips rebuilding this entirely
],
),
);
}
}
class ExpensiveHeader extends StatelessWidget {
const ExpensiveHeader({super.key});
@override
Widget build(BuildContext context) {
debugPrint('ExpensiveHeader build'); // never prints again after the first frame
return const Text('Welcome back');
}
}
Tap the button repeatedly and ExpensiveHeader build prints exactly once — not once per tap. The Text('Count: $count') line necessarily rebuilds because it reads count; ExpensiveHeader doesn't, and marking it const is what lets Flutter prove that to itself and skip the work rather than redo it and get the same answer.
2. Scope rebuilds with Selector, not just "use a state management library"
Reaching for Provider or Riverpod doesn't automatically fix rebuild scope — a widget that calls context.watch<CartModel>() rebuilds on any change to CartModel, even a field it doesn't render. Selector (Provider) and .select() (Riverpod) narrow that down to the one field actually being displayed:
// Rebuilds on ANY change to CartModel, even unrelated ones
Widget buildBadge(BuildContext context) {
final cart = context.watch<CartModel>();
return Text('${cart.itemCount}');
}
// Rebuilds ONLY when itemCount specifically changes
Widget buildBadge(BuildContext context) {
return Selector<CartModel, int>(
selector: (context, cart) => cart.itemCount,
builder: (context, itemCount, child) => Text('$itemCount'),
);
}
If CartModel also tracks a shipping address or a discount code, the first version rebuilds this badge every time either of those changes for no visible reason; the second doesn't. The rule worth remembering: the state management library decides how state is shared, but Selector/.select() is what actually decides how much rebuilds when it changes — and skipping that step is the most common way teams adopt Provider or Riverpod and see no rebuild improvement at all.
3. Profile before you guess — with the actual commands
Performance hiccups are rarely where intuition points. Run in profile mode, not debug mode, since debug mode carries extra overhead that skews the numbers:
flutter run --profile
Open DevTools (via your IDE's "Open DevTools" button, or dart pub global run devtools from the CLI) and go to the Performance tab. Reproduce the slow interaction, then read the frame chart: a bar with a tall UI-thread segment points at your build() methods doing too much; a tall raster-thread segment points at images or shader work instead. That distinction changes which of tips 1, 2, or 4 actually applies — optimizing build() methods won't help a raster-bound problem, and vice versa. [I've gone through a full worked example of this, including catching a real dropped frame, in a separate post on Flutter DevTools.]
4. Streamline assets with the settings that actually matter
FadeInImage makes an image transition look smoother, but it doesn't address the bigger cost: decoding a full-resolution image into memory just to display it at a fraction of that size. cacheWidth/cacheHeight fix the actual cost, not just the visual polish:
Image.asset(
'assets/banner.png',
cacheWidth: 400, // decode at display size, not the source file's full resolution
cacheHeight: 200,
)
A 4000×2000 source image displayed in a 400×200 tile costs roughly 100x the memory to decode without this — cacheWidth/cacheHeight tell Flutter's image cache to decode at the size you'll actually render, which is almost always the single highest-leverage image fix available, ahead of compressing the source file itself.
For genuinely heavy work — image manipulation, large JSON parsing — move it off the UI thread entirely:
Future<Uint8List> resizeInBackground(Uint8List bytes) {
return compute(_resize, bytes);
}
Uint8List _resize(Uint8List bytes) {
// decode, resize, re-encode — all off the UI isolate
return processedBytes;
}
[compute() and its newer counterpart Isolate.run() are covered in more depth, including when the overhead of an isolate is and isn't worth it, in a separate post on Dart isolates.]
5. Simplify the widget tree — and see the difference in the Inspector
A deeply nested tree costs real layout time, since Flutter has to walk every level to compute constraints. The fix is usually flattening redundant wrapper widgets, not adding new abstraction:
// Before: three nested widgets doing the job of one
Container(
padding: const EdgeInsets.all(8),
child: Center(
child: Container(
child: const Text('Hello'),
),
),
)
// After: same visual result, one widget
const Padding(
padding: EdgeInsets.all(8),
child: Center(child: Text('Hello')),
)
Open DevTools' Flutter Inspector, enable Show guidelines, and compare the widget tree depth before and after a change like this on a real screen — a tree that's meaningfully shallower after removing redundant Containers and SizedBoxes is a concrete, visible confirmation that the simplification actually reduced structure, not just line count.
Wrapping up
Five habits, roughly in order of how often they matter: mark static subtrees const, scope rebuilds to the specific field a widget renders rather than the whole model, profile instead of guessing which of the two applies, decode images at display size rather than source size, and flatten a widget tree that's grown more nested than the UI it produces requires. Each one is small on its own; applied together across a real app, they compound.
For more depth than a five-tip roundup can cover: the mechanics of why rebuilds cascade the way they do are in The Hidden Cost of Rebuilds in Flutter, a full DevTools walkthrough with a worked jank-hunting example is in A Friendlier Guide to Taming Flutter Jank with DevTools, and when an isolate is and isn't worth its overhead is covered in the isolates post on this blog.
References





