Why Flutter’s RepaintBoundary is Your Secret Weapon Against Jank 🎨

Is your Flutter app suffering from jank — that frustrating stutter during animations or scrolling? This common issue often signals…

Back to all articles

Is your Flutter app suffering from jank — that frustrating stutter during animations or scrolling? This common issue often signals unnecessary UI repainting, which wastes processing power, drains battery, and degrades the user experience when Flutter struggles to maintain a smooth 60 frames per second.

Often, the fix is surprisingly simple and fast using Flutter’s RepaintBoundary widget. This widget is key to building polished, efficient applications because it lets you control exactly what gets repainted.

At Saropa, we recently identified over 15 distinct performance bottlenecks across our apps, and found that applying RepaintBoundary was usually a trivial fix — taking less than 10 minutes in most cases — that significantly reduced jank.

💥 This guide delves into why uncontrolled repainting causes jank, explains the precise mechanism of RepaintBoundary using clear analogies, and provides a practical strategy — including rules, examples, and debugging tips — for applying it effectively (and knowing when not to).

We consider this in “Jank Part 2: A Developer’s Guide to Stabilizing UI Performance”, which includes more solutions and a helpful python script to detect jank smells in your Flutter project: ➡️ found here.

Why Does Repainting Even Matter?

Every repaint consumes CPU and GPU resources. Striving for 60 FPS means Flutter has only ~16ms per frame. Unnecessary redraws cause missed deadlines, leading to jank. This wastes processing cycles, increases battery consumption, and can make devices warmer, especially as UIs grow complex.

Furthermore, as modern UIs grow more complex with animations, dynamic lists, and custom graphics, the potential for these inefficient repaints increases significantly. A small change in one area can inadvertently trigger redraws across large, visually unchanged portions of the screen without careful management.

Before
After RepaintBoundary

After RepaintBoundary

Understanding RepaintBoundary

Abstract concepts are often easier to grasp with analogies. Let’s try a few to understand what RepaintBoundary does under the hood.

The Mini-Whiteboard (Isolation & Caching)

Imagine drawing static UI elements (like a background) on one large transparent sheet and frequently changing elements (like a loading spinner) on a smaller sheet placed on top. RepaintBoundary tells Flutter that the spinner is on its own sheet (layer).

This allows Flutter to only redraw that small sheet when the spinner changes and often reuse a cached version of the untouched background sheet, saving rendering work. This isolates the drawing process and enables caching of unchanged graphics.

The Toll Booth (Cost/Overhead)

Each RepaintBoundary introduces a small performance cost, like a toll booth. When Flutter’s rendering process reaches a boundary, it performs an extra check: have the contents visually changed, or can a cached image be reused?

Managing this separate graphical layer and its cache also consumes a small amount of time and memory.

This overhead is why you don’t sprinkle RepaintBoundary everywhere. Too many toll booths on short, simple roads where they aren’t needed just slow down the overall traffic (reduce performance) rather than helping it.

From: 10 Flutter Widgets Probably Haven’t Heard Of (But Should Be Using!)
From: 10 Flutter Widgets Probably Haven’t Heard Of (But Should Be Using!)

Rules for Using RepaintBoundary Wisely

With these analogies in mind, here are practical rules…

Rule 1: WRAP Continuous/Indefinite Animations

You should WRAP widgets like looping Lottie animations or indeterminate CircularProgressIndicator. These repaint constantly, so their high repaint cost significantly outweighs the boundary’s negligible overhead, making isolation beneficial.

Rule 2: WRAP Known Expensive Painting

It’s wise to WRAP widgets with significant per-repaint costs, such as complex CustomPaint widgets (drawing charts or intricate shapes), video players, or complex platform views. Even if they don’t repaint every frame, ensuring they only redraw when necessary and allowing Flutter to cache their output provides a substantial performance benefit.

Rule 3: WRAP Widgets Forcing Repaints (shouldRepaint: true)

Consider WRAPPING widgets that constantly force repaints, like a CustomPainter whose shouldRepaint method always returns true (perhaps mistakenly).

If a widget bypasses Flutter’s usual optimizations this way, a RepaintBoundary can act as damage control, mitigating the impact on the rest of the UI, but you should also investigate why shouldRepaint is always true.

Rule 4: AVOID Wrapping Static Content

You should AVOID wrapping widgets that rarely or never change once built, such as Text, Icon, Container with simple colors, or Padding. Since these rarely repaint, adding a boundary introduces overhead with no performance benefit.

Rule 5: AVOID Wrapping Short, Simple, Cheap Animations

Similarly, AVOID wrapping brief (< 500ms) and simple animations like fades or slides on basic widgets.

The total repaint cost during these animations is often minimal, and the RepaintBoundary’s overhead might actually be greater than the rendering cost you’d save.

Rule 6: CONSIDER Wrapping Moderately Complex Animations

This is a grey area where you should CONSIDER wrapping. Examples include animations changing multiple properties (e.g., size, position, opacity) simultaneously, those with noticeable durations (1–2 seconds), or animating gradients.

These involve more complex painting than simple fades but aren’t continuously running like loaders. Evaluate if the repaint cost seems significant enough to justify the boundary’s overhead; profiling is often the best way to decide.

Rule 7: Apply Correct Placement If Wrapping

When you do Apply a RepaintBoundary, ensure correct placement to maximize benefit and minimize cost. Place it as tightly as possible around only the dynamic or expensive widget(s) you want to isolate, excluding static parent widgets like Padding or Center from the boundary. Wrap TIGHTLY.

Show Me the Code: Examples in Action

Let’s see how these rules translate into code structure.

Example 1: Continuous Animation (Loader)

// GOOD: Boundary around the continuous animation
Column(
  children: [
    Text('Loading data...'), // Static
    // --- RepaintBoundary NEEDED ---
    RepaintBoundary( // <--- WRAP HERE
      child: CircularProgressIndicator(), // Indeterminate, loops constantly
    ),
    ElevatedButton(onPressed: () {}, child: Text('Cancel')), // Static
  ],
)

🚀 Example 2: Expensive Static Content (Chart)

// GOOD: Boundary around the expensive CustomPaint
Column(
  children: [
    Text('Sales Data'), // Updates infrequently
    // --- RepaintBoundary NEEDED ---
    RepaintBoundary( // <--- WRAP HERE
      child: CustomPaint( // Assumes painter draws a complex, slow chart
        size: Size(300, 200),
        painter: ComplexChartPainter(chartData), // shouldRepaint likely compares data
      ),
    ),
    Text('Last updated: ...'), // Updates infrequently
  ],
)

Example 3: Short/Cheap Animation (Button Fade on Tap)

// GOOD: No boundary needed for cheap, short animation
class MyButtonWithTapFade extends StatefulWidget {
  final Widget child;
  MyButtonWithTapFade({required this.child});

@override
  _MyButtonWithTapFadeState createState() => _MyButtonWithTapFadeState();
}
class _MyButtonWithTapFadeState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation _opacity;

  @override
  Widget build(BuildContext context) {
    // --- RepaintBoundary NOT NEEDED here or around GestureDetector ---
    return GestureDetector(
      onTapDown: _handleTapDown,
      onTapUp: _handleTapUp,
      child: FadeTransition(
        opacity: _opacity,
        child: widget.child, // Assumes child is relatively simple
      ),
    );
  }
}

Why: The FadeTransition runs briefly on tap. The painting cost is likely minimal. Adding a RepaintBoundary would introduce overhead probably larger than the savings.

Example 4: Correct Placement (Excluding Static Parent) — Rule 7

// GOOD Placement: Tightly around the animator
Padding( // Static framing parent - OUTSIDE the boundary
  padding: EdgeInsets.all(8.0),
  child: RepaintBoundary( // <--- WRAP HERE (Tightly around dynamic part)
    child: AnimatedBuilder( // The part that actually changes frequently
      animation: controller,
      builder: (context, child) {
        // Build something that moves/changes based on controller
        return Transform.translate(
          offset: Offset(controller.value * 100, 0),
          child: Container(width: 50, height: 50, color: Colors.red),
        );
      },
    ),
  ),
)

// BAD Placement: Too high, includes static Padding in the layer
// RepaintBoundary( // <--- WRAPPING TOO MUCH (includes static parent)
//   child: Padding(
//     padding: EdgeInsets.all(8.0),
//     child: AnimatedBuilder(
//       animation: controller,
//       builder: (context, child) {
//         // ... same builder as above
//       },
//     ),
//   ),
// )

Why: The Padding widget itself doesn’t change. Including it inside the RepaintBoundary increases the size of the layer Flutter needs to manage and potentially cache, adding unnecessary overhead. Wrap only the widget(s) that actually benefit from the boundary.

Beyond the Rules: Profiling is Key

These rules and examples provide a strong starting point for using RepaintBoundary effectively. However, they are guidelines, not absolute laws. Performance characteristics can depend on the specific widgets involved, the target device, and the complexity of the surrounding UI.

When you encounter jank or suspect a performance bottleneck, profiling your app with Flutter DevTools is the ultimate source of truth.

  • Use the Performance View to see frame build times (both UI and Raster threads, often shown in the “Performance Overlay”) and identify costly frames that exceed the ~16ms budget.
  • Use the CPU Profiler to dig deeper into which Dart methods are consuming the most time during frame rendering.
  • Use the Widget Inspector to explore the widget tree and understand its structure.

“Users feel performance. Jank isn’t just a dropped frame; it’s a broken promise of a smooth experience. Optimize those crucial interactions.”
— Addy Osmani (Engineering Manager at Google)

But specifically for identifying repaint issues, Flutter offers two fantastic visual tools:

Visually Debugging Repaints

The easiest way to see unnecessary repaints is using the “Highlight Repaints” toggle in the Flutter Inspector tab of DevTools. Turning it on draws cycling colored borders around widgets as they repaint.

Its main advantage is interactivity: toggle it on/off easily while your app runs to see exactly what redraws when you interact with the UI. This is ideal for focused debugging.

NOTE: An older method involves setting the debugRepaintRainbowEnabled flag in your code, but this requires code changes and a restart, making it less convenient than the DevTools toggle.

Use “Highlight Repaints” to confirm which widgets are causing jank and to verify that adding a RepaintBoundary correctly contains the repainting to within its bounds. Keep in mind this highlights painting; layout changes can still affect parents outside a boundary.

VSCodium > Dev Tools > Inspector > Highlight Repaints
VSCodium > Dev Tools > Inspector > Highlight Repaints

Repaint Highlights in Saropa Contacts

This is what it looks like in a real world app:

Illustration from article
Illustration from article
Showing repaint boundaries in Saropa Contacts

Showing repaint boundaries in Saropa Contacts

Paint Smarter, Not Harder

RepaintBoundary isn’t magic, but it’s a vital tool in your Flutter performance toolkit. By understanding why unnecessary repainting hurts performance and how RepaintBoundary helps by isolating rendering and enabling caching, you can make informed decisions about where to use it.

Using RepaintBoundary judiciously helps Flutter work smarter, not harder. It leads to smoother animations, lower resource consumption, and ultimately, a much better experience for your users.

Apply these principles, profile your app, and eliminate that jank!

“Achieving consistent 60fps in Flutter isn’t magic. It requires understanding what causes rebuilds and repaints, and strategically using tools like RepaintBoundary to isolate the hotspots.” — Filip Hráček (Flutter Developer Relations)

References

[edit: remove excessive emoji use]


Final Word 🪅

Illustration from article
saropa.com
Share this article

Your feedback is essential to us, and we genuinely value your support. When we learn of a mistake, we acknowledge it with a correction. If you spot an error, please let us know at blog@saropa.com and learn more at saropa.com.

Originally published by Saropa on Medium on April 24, 2025. Copyright © 2025