Monitor Performance of your Flutter App with DevTools: Enhance Tracing

Prerequisites
This blog is part of the “Monitor Performance of Your Flutter App with DevTools” series. I recommend reading the previous blogs first, as I will be using concepts introduced earlier. You can find the entire series here.
Counter app Example
Trace layout adds an event to the timeline for each RenderObject Layout. Let's consider the default counter app.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Counter App',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.blue),
home: const MyHomePage(title: 'Counter Page'),
);
}
}
class MyHomePage extends StatefulWidget {
final String title;
const MyHomePage({super.key, required this.title});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Run the app in Profile mode, go to the Performance tab, and enable Trace layouts.
Now, increase the counter and check the Frame Analysis and Timeline Events.
The layout took 1.5ms. Let's look at the events it recorded.
It printed out all the RenderObject names for that frame. Unlike build events, we don't immediately recognize these RenderObjects by their names, and we don't know what each one does. So, let's first explore common RenderObjects, understand their meanings, and see which widgets they are connected to.
Common Widgets and Their Underlying RenderObjects
Understanding which widget corresponds to which RenderObject can help make sense of timeline events. Here are a few common widgets::
Widget | RenderObject | Responsibility |
Text / Icons | RenderParagraph | A render object that displays a paragraph of text. |
SizedBox | RenderConstrainedBox | Imposes constraints on its child. |
Center / Align | RenderPositionedBox | Positions its child. |
Flex / Row / Column | RenderFlex | Arranges and displays its children in a single line. |
Image | RenderImage | Paints an image using paintImage |
Stack | RenderStack | The children are positioned on top of each other in the order. |
The RenderObjects for a Container depend on the parameters used.
To find out which RenderObject is connected to a widget, we can use the Flutter Inspector in DevTools, available when running the app in Debug mode. Click on any widget and go to the Widget Details Tree tab.
Limitations of DevTools (and How to Work Around Them)
Now, we know which widgets are responsible for the timeline events during the layout phase. However, these are not as helpful as build events, where we can identify the widget causing jank and check for any complex computations in its build method.
DevTools doesn't explain why a layout is slow. Below are some points I think are worth looking into when fixing a layout issue.
Avoid Repeated MediaQuery Calls in Lists
It's common for developers, especially those new to Flutter, to use MediaQuery
or screen size utilities from packages to make their UIs "responsive." Many assume that using screen dimensions with MediaQuery
will make their UI responsive, so they set explicit sizes for widgets, including items in scrollable lists. They often use MediaQuery
directly or through helper packages. However, there are important reasons to avoid relying on this method, especially when multiplying dimensions (like MediaQuery.sizeOf(context).width * 0.8
) for layout.
While it might seem like a straightforward way to create responsive layouts, using MediaQuery
inside every list item can negatively affect performance, especially in scrollable views.
A New Layout Pass for That Subtree
When you use MediaQuery
inside a widget:
That widget and its children depend on device constraints (like screen size).
Flutter will often re-run the layout phase for the entire subtree rooted at that widget.
This happens during rebuilds, screen rotations, keyboard pops, or any event that affects size.
So, if each list item uses MediaQuery.sizeOf(context).width * 0.8
, Flutter will:
Treat each item as a unique layout tree.
Call
RenderObject.layout()
on each item.Do this up to 500 times in a
ListView.builder(itemCount: 500)
.
Recalculation of Constraints for Every Item
Normally, widgets inherit layout constraints from their parent, allowing Flutter to optimize layout passes.
But if you inject hard dimensions like:
Container(
width: MediaQuery.sizeOf(context).width * 0.8,
...
)
...you’re breaking that natural constraint flow. This results in:
Flutter needing to resolve size constraints per item.
Extra layout effort, especially with nested widgets like
Row
,Column
, or text that needs to wrap.Wasted computation on layout that could’ve been shared or made lazy.
Prefer
const
Prefer using const
constructors whenever you can. They help reduce layout work by letting Flutter reuse existing instances, which is much more efficient than creating new widgets every frame.
Avoid IntrinsicHeight and IntrinsicWidth in Performance-Critical Views
IntrinsicHeight: For a widget that would otherwise take infinite height, this will allow the widget to take a reasonable amount of height. Additionally, when a Row
is wrapped with IntrinsicHeight
, all the children of the Row
will take the height of the tallest child.
IntrinsicWidth: For a widget that would otherwise take up infinite width, IntrinsicWidth
allows it to have a reasonable amount of width. Additionally, when a Column
is wrapped with IntrinsicWidth
, all the children of the Column
will take the width of the widest child.
Here's an example from the Flutter team showing how IntrinsicHeight
can increase layout costs, along with a more efficient alternative. Watch the video here.
Prefer ListView.builder or Slivers
Use ListView.builder
to display large lists of items. It's an obvious choice for a single list, but what if we need to display two lists or a list and a grid, like in this example shown by the Flutter team? Some people put both lists or a list and a grid in a Column
wrapped with SingleChildScrollView
, using shrinkWrap: true
on lists or grids without understanding what shrinkWrap
does and its impact on performance.
shrinkWrap: ListView
or GridView
has an unbounded height, so using ListView
inside a Column
causes an unbounded height exception. The shrinkWrap
property allows ListView
or GridView
to use only the space needed for their children. To do this, ListView
or GridView
must know the exact dimensions required by their children. In the worst-case scenario, if not all children in a list are the same height, ListView
will need to build and lay out all its children to determine the space needed. This affects performance, especially if the list is long.
This can be greatly improved by using slivers, which let us combine multiple scrollable items with lazy-loading, avoiding costly layouts. Let's look at an example where a common mistake causes performance issues, and then learn how to fix it using slivers.
Bad Example:
Column(
children: [
ListView.builder(
shrinkWrap: true, // forces layout of all children
physics: const NeverScrollableScrollPhysics(),
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(title: Text('List item $index'));
},
),
GridView.builder(
shrinkWrap: true, // same issue applies here
physics: const NeverScrollableScrollPhysics(),
itemCount: 50,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemBuilder: (context, index) {
return Card(
child: Center(child: Text('Grid $index')),
);
},
),
],
),
Here, all 100 list items and 50 grid items are built and laid out right away, even if they are off-screen.
Good Example:
CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('List item $index')),
childCount: 100,
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, index) => Card(
child: Center(child: Text('Grid $index')),
),
childCount: 50,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
),
],
),
Here, only the visible items are built and laid out.
Now that we've looked at some common layout mistakes and the limitations of what DevTools can show during the layout phase, along with practical solutions, let's bring it all together with a complete example. We'll go through a sample layout that has some of the issues mentioned above, see how it behaves in DevTools, and then improve it step-by-step to make it more efficient.
Putting It All Together: A Real Layout Fix
To start, let's look at a layout that seems simple but performs poorly. We'll use DevTools to understand why it's slow and then apply the techniques we've discussed to improve it step by step.
class LayoutExample extends StatelessWidget {
const LayoutExample({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Layout Example"),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
children: List.generate(
500,
(index) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.star),
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Title $index',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
const Text(
'This is a long description that causes layout to compute line breaks, wrap text, and apply padding per item. It repeats across all 500 rows.',
),
],
),
),
],
),
);
},
),
),
),
),
);
}
}
Here's how the UI looks on the device. While it seems fine visually, it doesn't perform smoothly when scrolling.
To understand what's happening, let's open Flutter DevTools and check the performance. We can see there's a noticeable jank spike during the layout phase.
Next, we enable the "Track layouts" option, repeat the activity, click on a jank frame, and then check the timeline to gain more insight into which RenderObjects are taking time. This helps us identify where the layout is becoming costly.
As we can see, RenderFlex
shows up as the longest bar in the timeline, indicating it's a major cause of the layout jank. From our earlier discussion, we know that Row
and Column
widgets use RenderFlex
internally. In this case, the RenderFlex
is linked to the Column
inside the SingleChildScrollView
, which ends up building and laying out all 500 children at once.
The timeline clearly shows this, as we can see each child's RenderObject
being processed. This eager layout is inefficient for large lists. To fix this, we should replace the Column
with a ListView
, which only builds the visible items as needed. Let’s make that change and compare the results.
class LayoutExample extends StatelessWidget {
const LayoutExample({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Layout Example"),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: ListView(
children: List.generate(
500,
(index) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.star),
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Title $index',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
const Text(
'This is a long description that causes layout to compute line breaks, wrap text, and apply padding per item. It repeats across all 500 rows.',
),
],
),
),
],
),
);
},
),
),
),
);
}
}
Here, I have removed SingleChildScrollView
and replaced Column
with ListView
. Although we should use ListView.builder
, I'm doing this to demonstrate that even ListView
builds its children lazily.
We no longer see the long red bars from before, but a few short red spikes still show up occasionally. Although the overall performance is much improved, some frames still take a bit longer, and it's unclear what causes these outliers.
Below is the timeline for the janky frame we encountered.
Using ListView
created too many RenderObjects, but we can see that RenderSliverList
only has RenderObjects for 7 children, meaning it only laid out 7 of its 500 children. Since ListView
also loads its children lazily, let's go ahead and use ListView.builder
as it seems more suitable.
class LayoutExample extends StatelessWidget {
const LayoutExample({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Layout Example"),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 500,
itemBuilder: (context, index) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.star),
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Title $index',
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
const Text(
'This is a long description that causes layout to compute line breaks, wrap text, and apply padding per item. It repeats across all 500 rows.',
),
],
),
),
],
),
);
},
),
),
);
}
}
Let's look at the results.
This time we didn’t got a janky frame, but the occasional red bar still appears randomly even with the ListView.builder
.
Summary
We explored the RenderObjects of some common widgets, learned how to spot layout-related jank using Flutter DevTools, understood the limits of layout timeline events, and figured out how to work around them by better understanding Flutter’s rendering pipeline. From avoiding the overuse of MediaQuery
to preferring const
, ListView.builder
, and Slivers
, we covered practical layout fixes and optimizations. Finally, we applied this knowledge in a complete before-and-after example to show how these small changes can greatly improve layout performance.
Subscribe to my newsletter
Read articles from Nitin Poojary directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
