Performance Optimization

Isolating Paint with RepaintBoundary

15 min Lesson 5 of 12

Isolating Paint with RepaintBoundary

Flutter renders UI through a multi-stage pipeline: build → layout → paint → composite. The final two stages—paint and composite—are where pixels actually get drawn to the screen. Understanding how Flutter batches and isolates these stages is essential for building smooth, high-frame-rate applications.

RepaintBoundary is a widget that instructs the Flutter compositor to place its subtree on a separate compositing layer. This means that when anything inside that boundary repaints, the rest of the widget tree is completely unaffected—it is read from the cached layer directly without re-executing its own paint operations.

Note: Every widget in Flutter participates in the paint phase. Without RepaintBoundary, a single frequently-animating widget (such as a progress indicator or a custom clock) can force its siblings and ancestors to repaint on every frame, wasting GPU time and potentially causing jank.

How the Compositor Uses Layers

Flutter’s compositor is similar to a graphics engine that manages a stack of layers. Each layer is a rasterized bitmap cached by the GPU. When a layer is marked dirty, only that layer is re-rasterized; all clean layers are composited from their cached bitmaps. RepaintBoundary creates a new leaf layer, so its subtree can be rasterized independently.

  • Without RepaintBoundary: Animating child → parent dirty → entire sibling subtree repaints
  • With RepaintBoundary: Animating child lives in its own layer → only that layer re-rasterizes → siblings read from their GPU cache
  • The compositor then blends all layers together, which is extremely cheap compared to re-rasterizing pixels

Visualizing the Layer Tree

You can inspect compositing layers at runtime using the Flutter DevTools Layer Explorer or by enabling debugRepaintRainbowEnabled = true in your main function. Widgets that repaint on every frame will flash with a cycling rainbow overlay, making hot spots immediately obvious.

Enabling Repaint Visualization

import 'package:flutter/rendering.dart';

void main() {
  // Highlight widgets that repaint each frame with a rainbow border
  debugRepaintRainbowEnabled = true;
  runApp(const MyApp());
}

Basic Usage

Wrapping a widget in RepaintBoundary is syntactically trivial. The key is knowing where to place it for maximum benefit.

Isolating a Frequently Animating Widget

import 'package:flutter/material.dart';

class LiveDashboard extends StatelessWidget {
  const LiveDashboard({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Static content: only paints once and is cached by the GPU
        const _ReportSummaryCard(),

        // Isolated: the animated ticker repaints every frame
        // but the ReportSummaryCard above is never invalidated
        RepaintBoundary(
          child: _LiveTickerWidget(),
        ),

        // Also static: safe from the ticker's constant repaints
        const _FooterLinks(),
      ],
    );
  }
}

class _LiveTickerWidget extends StatefulWidget {
  @override
  State<_LiveTickerWidget> createState() => _LiveTickerWidgetState();
}

class _LiveTickerWidgetState extends State<_LiveTickerWidget>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    )..repeat();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return CustomPaint(
          size: const Size(double.infinity, 80),
          painter: _TickerPainter(_controller.value),
        );
      },
    );
  }
}

class _TickerPainter extends CustomPainter {
  final double progress;
  _TickerPainter(this.progress);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.teal
      ..strokeWidth = 2.0
      ..style = PaintingStyle.stroke;
    final x = size.width * progress;
    canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
  }

  @override
  bool shouldRepaint(_TickerPainter old) => old.progress != progress;
}

When to Use RepaintBoundary

RepaintBoundary is a targeted optimization tool, not a default wrapper. Apply it when:

  • A widget animates continuously (e.g., progress indicators, live charts, particle effects)
  • A widget uses a CustomPainter that repaints frequently based on streams or timers
  • A large, complex subtree is expensive to rasterize but changes rarely (e.g., a data-heavy table that only updates every 30 seconds)
  • You have confirmed via DevTools that unwanted repaints are actually occurring
Tip: Flutter automatically inserts RepaintBoundary around certain high-level widgets such as MaterialApp, Navigator route transitions, and Scrollable viewports. You only need to add it manually for custom hotspots you identify through profiling.

The Cost-Benefit Trade-Off

RepaintBoundary is not free. Each boundary allocates a separate GPU texture (an OffscreenLayer). Overusing it can degrade performance by:

  • Increasing GPU memory consumption (each layer holds a rasterized bitmap)
  • Adding compositor overhead to blend many small layers per frame
  • Potentially triggering more expensive offscreen rendering passes
Warning: Wrapping every widget in a RepaintBoundary is an anti-pattern. GPU memory is finite. A screen with dozens of tiny boundaries can consume more memory than a screen with a single paint layer, and the blending cost may exceed the savings from avoiding repaints. Always profile first—never add RepaintBoundary speculatively.

Decision Checklist

Before adding a RepaintBoundary, ask:

  • Is this widget actually repainting at an unexpectedly high frequency? (DevTools → Performance tab)
  • Does the repainting widget have expensive siblings that are being invalidated?
  • Is the GPU memory cost of an extra texture worth the repaint savings?
  • Can the problem instead be solved by making shouldRepaint() return false more often?

Summary

RepaintBoundary is a precise surgical tool for Flutter’s paint pipeline. It creates an independent compositing layer that prevents a repainting subtree from invalidating its neighbors, reducing redundant GPU work. Use it deliberately: first profile with debugRepaintRainbowEnabled or DevTools, identify genuine hotspots, then wrap only those widgets. Overuse inflates GPU memory and adds compositor overhead, potentially making things worse. The best RepaintBoundary is one that is placed at exactly the right layer boundary—neither too broad nor too narrow.