Why Your Flutter CustomPainter Repaints Every Frame (and How to Stop It)
You write a CustomPainter, return false from shouldRepaint, add a print to paint() and watch it fire on every frame anyway. The painter isn't broken. shouldRepaint just answers a much narrower question than most people think.
This article covers what actually triggers a repaint, how to see it happening, and the fix for each cause. It also covers the opposite bug: a painter that should repaint but doesn't.
What shouldRepaint really decides
shouldRepaint is called in exactly one situation: the CustomPaint widget was rebuilt and received a new painter instance. Flutter asks the new painter, "compared with the old one, do you look different?" If the answer is false, the swap alone won't trigger a repaint.
It has no say over anything else. Your painter will still repaint when:
- something else in the same paint layer asks to repaint, or
- the painter's own
repaintListenable fires.
The first one is where most mystery repaints come from.
How to see repaints
Before fixing anything, confirm what's repainting:
- Flutter DevTools โ Flutter Inspector โ "Highlight Repaints". Every layer that repaints gets a coloured border that changes colour on each repaint. If your painter's region keeps cycling colours while it should be static, it's repainting.
- From code, set
debugRepaintRainbowEnabled = true;(frompackage:flutter/rendering.dart) inmain()for the same effect. - The blunt way: keep a counter in
paint()anddebugPrintit.
Cause 1: something next to your painter is animating
Flutter doesn't repaint single widgets. It repaints layers. When any render object calls markNeedsPaint(), Flutter walks up the tree to the nearest repaint boundary and repaints everything under that boundary.
So if your CustomPaint sits in the same Row as a spinner, an animated icon or a blinking cursor, every frame of their animation repaints your painter, no matter what shouldRepaint returns.
Here's what a widget test shows with a CustomPaint placed next to a small animated widget, run for 10 frames:
| Setup | paint() calls over 10 frames |
|---|---|
CustomPaint next to an animating sibling |
11 |
Same, with the CustomPaint wrapped in RepaintBoundary |
1 (the initial paint) |
The fix is to give the painter its own layer:
RepaintBoundary(
child: CustomPaint(
size: const Size(300, 200),
painter: ChartPainter(data),
),
)
Now the animation repaints its own region and your painter's output is reused as-is.
A few notes:
- Scrolling lists already do this.
ListView,GridViewand the other builder lists wrap each item in aRepaintBoundaryby default (addRepaintBoundaries: true). - Don't wrap everything. Each boundary is a separate layer with its own memory. Use them where two things change at different rates: a static chart next to a live clock, a canvas next to a loading spinner.
- You can wrap the animating widget instead of the painter. Either side works, as long as they end up in different layers.
Cause 2: shouldRepaint returns true and the parent rebuilds often
The classic copy-paste painter:
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
On its own this is harmless. Combine it with a parent that rebuilds often (a setState driven by a scroll listener, a stream, or a timer) and every rebuild creates a new painter that insists it's different.
The fix is to compare the fields that actually affect the drawing:
@override
bool shouldRepaint(ChartPainter oldDelegate) =>
oldDelegate.data != data || oldDelegate.color != color;
Make sure those comparisons are cheap. shouldRepaint runs on every rebuild, so deep-comparing a 10,000-point list there can cost more than the repaint you're trying to avoid.
Cause 3: animating by rebuilding the widget
This pattern works, but it does more work than necessary:
// Rebuilds the widget subtree on every tick, then repaints
AnimatedBuilder(
animation: _controller,
builder: (context, _) => CustomPaint(painter: CirclePainter(_controller.value)),
)
For an animation the painter has to repaint every frame, so that part is expected. The waste is the rebuild: a new widget, a new painter and a shouldRepaint call 60 times a second.
The fix: hand the animation to the painter's repaint argument. The painter then repaints directly when the animation ticks, with no rebuild at all:
class PulsePainter extends CustomPainter {
PulsePainter(this.animation) : super(repaint: animation);
final Animation<double> animation;
@override
void paint(Canvas canvas, Size size) {
final radius = size.shortestSide / 2 * animation.value;
canvas.drawCircle(
size.center(Offset.zero),
radius,
Paint()..color = const Color(0xFF6366F1),
);
}
@override
bool shouldRepaint(PulsePainter oldDelegate) => oldDelegate.animation != animation;
}
class Pulse extends StatefulWidget {
const Pulse({super.key});
@override
State<Pulse> createState() => _PulseState();
}
class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// build() runs once. The painter repaints on every tick by itself.
return CustomPaint(size: const Size(120, 120), painter: PulsePainter(_controller));
}
}
Read animation.value inside paint(), not in the constructor. Reading it in the constructor captures a single frame's value.
To repaint on several sources, combine them with Listenable.merge([animationA, animationB]).
The same idea for drawing apps
Finger-drawing apps often call setState on every onPanUpdate, which rebuilds on every pointer event. A ValueNotifier used as the repaint source avoids that:
class StrokePainter extends CustomPainter {
StrokePainter(this.points) : super(repaint: points);
final ValueNotifier<List<Offset>> points;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..strokeWidth = 3
..strokeCap = StrokeCap.round;
final p = points.value;
for (var i = 0; i < p.length - 1; i++) {
canvas.drawLine(p[i], p[i + 1], paint);
}
}
@override
bool shouldRepaint(StrokePainter oldDelegate) => oldDelegate.points != points;
}
class Sketchpad extends StatefulWidget {
const Sketchpad({super.key});
@override
State<Sketchpad> createState() => _SketchpadState();
}
class _SketchpadState extends State<Sketchpad> {
final _points = ValueNotifier<List<Offset>>([]);
@override
void dispose() {
_points.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanUpdate: (d) => _points.value = [..._points.value, d.localPosition],
child: CustomPaint(painter: StrokePainter(_points), size: Size.infinite),
);
}
}
In a test, two updates to the notifier produced two repaints and zero rebuilds. Copying the list on every event is fine for short strokes. For very long ones, a ChangeNotifier that appends in place and calls notifyListeners() avoids the copy.
The opposite bug: it doesn't repaint
This one is quieter and more confusing:
class DotsPainter extends CustomPainter {
DotsPainter(this.dots);
final List<Offset> dots;
@override
void paint(Canvas canvas, Size size) {
for (final d in dots) {
canvas.drawCircle(d, 4, Paint());
}
}
@override
bool shouldRepaint(DotsPainter oldDelegate) => oldDelegate.dots != dots;
}
// Somewhere in a State:
setState(() => dots.add(newDot)); // the new dot never appears
dots.add(...) changes the list in place. The new painter and the old painter hold the same list object, so oldDelegate.dots != dots is false and Flutter skips the repaint. A widget test confirms it: zero paint() calls after the setState.
Fix it by creating a new list (dots = [...dots, newDot]), or by driving the painter with a repaint notifier as shown above.
Checklist
When a painter repaints too often:
- Turn on Highlight Repaints and confirm it's really your painter.
- Is something animating nearby? Add a
RepaintBoundary. - Does
shouldRepaintreturntrueunconditionally? Compare real fields. - Are you rebuilding to animate? Use
super(repaint: animation).
When a painter doesn't repaint:
- Are you mutating a list or object in place? Create a new one, or use a
repaintnotifier. - Is
shouldRepaintcomparing the right fields?
Related articles
Explore Our Projects
Beyond the shape tools โ two more things we build and use every day. Free to try, no signup.
Keyboard Arena
Typing practice that plays like a game
Real-time WPM tests, mechanical switch audio, A-Z finger drills and 40+ structured lessons. Race a ghost of your best run instead of grinding random words.
- โ Live WPM & Accuracy
- โ Mechanical Switch Audio
- โ 40+ Guided Lessons
Flutter Log Explorer
Finally, Flutter logs you can read
Zero-config, real-time log capture from the Debug Adapter and Dart VM Service, with smart classification, filters and deduplication โ all inside VS Code.
- โ Zero-Config Capture
- โ Smart Filters & Search
- โ Noise-Free Deduplication
Ready to Design Your Own Shapes?
Open the editor and start creating professional Flutter CustomPainter code visually.
Open the Editor