Profile with Flutter DevTools: best practices explained
Performance issues in Flutter apps often hide in plain sight—a janky animation, a delayed navigation, or a frozen list scroll. You might suspect the problem lies in your state management or widget tree, but without profiling data, you're essentially guessing. This article walks you through the essentials of profile with Flutter DevTools: best practices explained, showing you exactly where frames are dropped, how to spot expensive repaints, and the concrete steps to measure and fix performance problems in production-like conditions.
Run in profile mode on a real device
Development mode in Flutter adds significant overhead—constant hot reload, JIT compilation, and verbose logging all mask performance reality. When you want to see how your app actually performs for users, you need to run in profile mode on a real device. This mode compiles your Dart code ahead-of-time (AOT), disables debugging features, and gives you a much closer approximation of release-mode performance without stripping away the observability tools you need for diagnosis.
To launch your app in profile mode, use:
flutter run --profileConnect a physical device via USB (or use a simulator, though real hardware reveals hardware-specific bottlenecks). Profile mode is the middle ground: it's not as fast as release mode, but it's dramatically faster than debug and it keeps DevTools profiling capabilities active. Many teams skip this step and try to profile in debug mode, which wastes time chasing phantom performance issues that vanish in production.
Once the app is running, open DevTools:
flutter pub global run devtoolsThen connect to your running app. The DevTools window will open in your browser, and you'll see a suite of profiling tools. Make sure you're looking at the Performance tab—this is where frame timing data lives.
Performance view shows slow frames
The Performance view in DevTools displays a timeline of frame rendering. Each frame should take no more than 16.67 milliseconds on a 60 Hz display (or 8.33 ms on a 120 Hz device). Frames that exceed this threshold appear as red bars in the timeline, making them instantly visible. This visual representation is your first diagnostic tool: a sea of red bars signals systematic performance problems; an occasional spike suggests a temporary hiccup.
When you interact with your app—scroll a list, tap a button, navigate—watch the Performance timeline. You'll see:
- GPU time: How long the Skia rendering engine took to paint pixels
- CPU time: How long Dart code took to build the frame
- Frame identifier: Timestamp and frame count
Slow frames typically fall into two categories: CPU-bound (Dart is doing too much work) or GPU-bound (rendering is expensive). Clicking on a slow frame reveals more detail. Look for the breakdown: if CPU time dominates, your widget rebuild or build logic is the culprit. If GPU time dominates, your shaders, clipping, or transparency effects are expensive.
A real-world example: imagine you're rendering a list of 500 user cards, each with a shadow, gradient, and a circular avatar. The first scroll triggers excessive GPU time because the Skia renderer is compositing hundreds of semi-transparent layers. The Performance timeline shows red frames during the scroll. Clicking a frame reveals GPU time at 25 ms—way over budget. This immediately points you toward optimization: simplify shadows, reduce gradients, or lazy-load content.
Highlight repaints and rebuilds
Repaints and rebuilds are often confused, but they're different problems. A rebuild means Flutter's framework is calling build() on a widget to regenerate its widget tree. A repaint means the GPU is re-rendering pixels. Both are expensive, and DevTools lets you visualize both.
In the Performance view, enable the "Rebuild stats" or "Rebuild widgets" overlay (in the top-right menu of DevTools). This adds a layer of information: widgets that rebuild appear with a colored highlight, typically a blue or green flash. During a scroll or animation, watch for widgets that flash repeatedly—those are being rebuilt every frame, even if their data hasn't changed.
A classic mistake is rebuilding the entire page when only one small part changes. For example:
class UserProfile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
UserHeader(user: user),
Expanded(
child: StreamBuilder<List<Post>>(
stream: postsStream,
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
return PostCard(post: snapshot.data![index]);
},
);
}
return Loading();
},
),
),
],
);
}
}If the postsStream emits new data every 5 seconds, the entire Column rebuilds, including UserHeader. The fix is to extract UserHeader into a separate StatelessWidget and use shouldRebuild or keys to prevent unnecessary rebuilds. Or better, restructure with Provider or Riverpod to isolate state changes to only the widgets that care about them.
For repaints, enable the "Paint" overlay (flutter run with --show-paint). Repaints appear as colored flashes; if you see flashing during idle time (no user interaction), something is continuously triggering repaints. Common culprits include animated containers with changing bounds, images being re-decoded, or complex custom paints.
You can also enable the "Repaint rainbow" visualization in the Flutter inspector: it cycles through colors for each repainted region. A rainbow of colors during an animation is normal; a rainbow across the entire screen when nothing is moving is a red flag.
Track widget rebuild counts
DevTools provides a detailed breakdown of which widgets rebuilt and how many times. In the Performance tab, after recording a timeline, scroll down to see the "Rebuild stats" section. This shows a table of all widgets that rebuilt during the recording, sorted by rebuild count. Widgets at the top of the list are your hotspots.
This data is invaluable for prioritization. Suppose you see that a FutureBuilder rebuilt 150 times in a 10-second recording, but an Image widget only rebuilt once. Focus on the FutureBuilder first.
To investigate why a widget rebuilt, you need to understand the Flutter rebuild contract: a widget rebuilds when:
- Its parent rebuilds and passes different constructor arguments
- Its InheritedWidget ancestor changed
- It's a StatefulWidget and setState() was called
- It's a Consumer/Selector from state management (Provider, Riverpod) and the watched state changed
Let's say you notice a Text widget showing the current time rebuilds 60 times per minute. You might think you have a memory leak, but the real issue is often that an ancestor widget (maybe an AnimatedBuilder or a stream listening to a time ticker) rebuilds every second, forcing all descendants to rebuild. The fix isn't to optimize Text—it's to restructure the tree so the time display is isolated in its own subtree, built by a separate state listener.
Use this pattern to isolate expensive subtrees:
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
// Static header—never rebuilds
const Header(),
// Dynamic content isolated in its own widget
const DynamicContent(),
],
);
}
}
class DynamicContent extends ConsumerWidget {
const DynamicContent();
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(dataProvider);
return data.when(
data: (items) => ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemTile(item: items[index]),
),
loading: () => Loading(),
error: (e, st) => Error(error: e),
);
}
}The Header never rebuilds because it's not listening to any state. DynamicContent rebuilds when dataProvider changes, but Header remains untouched. This layered approach scales: as your app grows, you isolate state listeners at the appropriate depth in the tree.
Common mistakes
Profiling in debug mode: Debug mode adds 2–3x overhead. You might see 30 ms frames and think your app is slow, then release it and find it runs at 60 fps. Always profile in profile mode on a real device.
Ignoring GPU time: If GPU time is high, throwing more CPU cores at the problem won't help. Reduce shadow complexity, simplify gradients, or use RepaintBoundary to limit the repaint scope.
Conflating correlation with causation: A widget rebuilt 500 times; therefore it's slow. Maybe not—if each rebuild is 0.1 ms and there are 1000 frames, that's only 50 μs per frame. The real slow frame is caused by a 15 ms shader compilation that happened once. Focus on total time, not rebuild count.
Not testing scroll performance: List performance is a critical user experience metric and it's easy to miss in small datasets. Test with 1000+ items, slow network, and a mid-range device before shipping.
Ignoring warm-up frames: The first few frames of a new route are often slow due to shader compilation or JIT compilation (in profile mode, AoT should be compiled, but some optimization still happens). Record for 10 seconds, not 1 second, to get representative data.
Key takeaways
- Always profile in profile mode on a real device; debug mode performance is misleading.
- Use DevTools Performance view to identify slow frames and analyze CPU vs. GPU time.
- Enable rebuild highlights and repaint overlays to visualize what's happening during scrolls and animations.
- Review rebuild stats to find hotspot widgets and restructure your tree to isolate state changes.
- Target the highest-impact problem first: if 40% of frame time is GPU, optimize rendering before refactoring Dart code.
- Test with realistic data volumes, network conditions, and devices before concluding your app is performant.






Comments
Loading comments…