Monitor Performance of your Flutter App with DevTools: Enhance Tracing

Nitin PoojaryNitin Poojary
10 min read

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::

WidgetRenderObjectResponsibility
Text / IconsRenderParagraphA render object that displays a paragraph of text.
SizedBoxRenderConstrainedBoxImposes constraints on its child.
Center / AlignRenderPositionedBoxPositions its child.
Flex / Row / ColumnRenderFlexArranges and displays its children in a single line.
ImageRenderImagePaints an image using paintImage
StackRenderStackThe 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.

  1. 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.

  1. 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).

  1. 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.

  1. 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.

  1. 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.

  1. 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.

10
Subscribe to my newsletter

Read articles from Nitin Poojary directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nitin Poojary
Nitin Poojary