Monitor Performance of your Flutter App with DevTools: Enhance Tracing

Nitin PoojaryNitin Poojary
6 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.

Why Paint Phase Matters

Paint is the final phase of the UI thread in Flutter’s rendering pipeline, which follows the order Build → Layout → Paint. In this phase, the visual appearance of your widgets is drawn on the screen.

Although paint doesn't rebuild or relayout widgets, it is still crucial for performance. A poorly implemented paint phase can lead to dropped frames, especially during animations or scrolling.

In this blog, we’ll explore how to monitor paint performance using DevTools, understand its limitations, and apply best practices like RepaintBoundary to optimize rendering.

Counter app Example

If you have been following the series, you know what to do, right? Run the app in Profile mode, then enable Trace paints. Click on any of the frames and check its timeline events.

We got a list of RenderObjects that were painted in this frame. Although this view might not be very intuitive at first, it provides clues about what’s being rendered. Let’s focus on the children of RenderCustomMultiChildLayoutBox to better understand which widgets are involved. For more details on how widgets map to RenderObjects, refer back to the layout tracing blog where we covered these relationships more deeply.

The first child of the RenderCustomMultiChildLayoutBox corresponds to the Center widget, followed by the Column and two Text widgets.

The second child contains RenderObjects for the AppBar.

The third child contains RenderObjects for the FloatingActionButton.

In this interaction, we tapped the FloatingActionButton, which updated one Text widget and triggered a ripple animation in the FloatingActionButton. Even with this small change, the entire screen, including the AppBar, was repainted. Let's explore why this happens and how we can prevent unnecessary repaints.

How Widgets Get Repainted Together

Flutter paints RenderObject as part of a layer. If any RenderObject in that layer is marked as dirty, all the other RenderObjects in the same layer will repaint.

While I couldn't find the official documentation with detailed information on how these layers are built, we can identify which widgets are part of the same layer. This helps us understand why unrelated parts of the UI might still be repainted.

In summary, if multiple widgets share a layer, any repaint request within that layer affects all of them.

Visualizing Repaints with Rainbow Debugging

To observe this behavior, Flutter offers a helpful debug flag called debugRepaintRainbowEnabled. This flag draws a border around each layer on the screen and changes the border's color for any layer that gets repainted. To use the flag, add debugRepaintRainbowEnabled = true in the main function before runApp. Alternatively, run the app in debug mode, go to the Flutter Inspector in DevTools, and enable Highlight Repaints.

As you can see, a colored border appears around the entire screen. Each click on the FloatingActionButton causes the whole screen to repaint, even just for the ripple effect on the button. To optimize this, we can separate specific parts of the widget tree into their own layers so that only those parts repaint when needed. Flutter provides a built-in widget for this purpose: RepaintBoundary.

Let's explore how it works and when to use it.

Reducing Unnecessary Repaints Using RepaintBoundary

RepaintBoundary works by placing its child widgets into their own layer. In our example, let's wrap the animating button with the RepaintBoundary widget.

Here, RepaintBoundary creates a separate layer for the FloatingActionButton. This ensures that the ripple effect repaint is limited to the button’s own layer, and the rest of the screen repaints only once when the text is updated.

While I used the simple counter app to show how RepaintBoundary works, the Flutter team has shared a more realistic example that highlights its benefits even better. You can check it out here.

Although RepaintBoundary helps avoid unnecessary repaints, using it too much can increase CPU and memory usage. Instead of wrapping everything in a RepaintBoundary, it's better to first identify repaint issues and assess their performance impact.

As a quick exercise, try inspecting the timeline events in DevTools: compare the first frame after tapping the button with the frames that follow from the ripple animation.

Widgets that could cause Paint Jank

Flutter offers a wide range of powerful widgets that generally perform very well. However, some of them can cause performance issues during the paint phase if they are overused or used unnecessarily. Let's examine a few of these widgets and learn how to use them more effectively.

Opacity

The Opacity widget makes its child partially transparent. While it seems harmless, it can be performance-intensive for values between 0.0 and 1.0, as it requires painting the child into an offscreen buffer before compositing.

  • Opacity = 0.0: child is skipped (no paint cost).

  • Opacity = 1.0: child is painted directly.

  • Opacity ∈ (0.0, 1.0): child is painted into a buffer that is more expensive.

  1. Better Alternative for Images

If you're trying to make an image transparent, it's faster to use color and BlendMode directly on the Image widget:

Preferred:

Image.asset(
  'assets/asset-image.jpeg',
  color: Color.fromRGBO(255, 255, 255, 0.5),
  colorBlendMode: BlendMode.modulate,
),

Less Efficient:

Opacity(
  opacity: 0.5,
  child: Image.asset('assets/asset-image.jpeg'),
),
  1. Avoid Animating Opacity Directly

Animating an Opacity widget triggers rebuilds and repaints every frame, which is not ideal. Instead, use:

  • AnimatedOpacity: animates opacity internally without rebuilding every frame.

  • FadeTransition: more efficient, driven by an animation.

BackdropFilter

The BackdropFilter widget applies a filter (like blur or color transforms) to everything painted beneath it, then paints its child on top of the filtered background. This can be expensive, especially when applying non-local filters like blur.

Prefer ImageFiltered When Possible

If you're filtering just a single widget or image (not the entire background), ImageFiltered is a more efficient alternative.

Preferred:

 Widget buildFilter() {
   return ImageFiltered(
     imageFilter: ui.ImageFilter.blur(sigmaX: 6, sigmaY: 6),
     child: Image.asset('image.png'),
   );
 }

Less Efficient:

 Widget buildBackdrop() {
   return Stack(
     children: <Widget>[
       Positioned.fill(child: Image.asset('image.png')),
       Positioned.fill(
         child: BackdropFilter(
           filter: ui.ImageFilter.blur(sigmaX: 6, sigmaY: 6),
         ),
       ),
     ],
   );
 }

Summary

We explored the paint phase and how to inspect it using the Trace Paint feature in DevTools.

Since DevTools only shows RenderObjects during paint traces, we learned how to interpret them with visual debugging tools like debugRepaintRainbowEnabled. This helped us understand which parts of the UI are being repainted.

We also learned how RepaintBoundary can optimize repaints by creating separate layers, reducing unnecessary work by the raster thread.

Lastly, we discussed some widgets that are more paint-intensive, like Opacity and BackdropFilter, and considered more efficient alternatives.

While these widgets don’t always cause performance issues, misusing or overusing them in critical areas (like inside a scroll view) can lead to dropped frames. Always test and profile using Flutter DevTools when unsure.

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