Flutter jank is measurable, so measure it in CI
- Flutter
- Performance
- Testing
Cross-platform frameworks get their reputation for stutter from teams who never put a number on it. A frame budget, enforced on real devices, on every pull request.
"Flutter feels janky" is the objection every proposal meets, and it is not baseless — it is just imprecise. Flutter renders at 60 or 120fps perfectly well. What it does not do is stop you from building a widget tree that cannot.
The difference between teams whose Flutter apps feel native and teams whose do not is almost never framework knowledge. It is whether anyone is measuring.
Two threads, two ways to miss the frame
A frame has a UI phase (your Dart: build, layout, paint) and a raster phase (the GPU turning that into pixels). At 60fps each frame has 16.7ms for both. Profile output separates them and you have to read which one blew the budget, because the fixes have nothing in common.
- UI thread over budget — too much work in build(), an expensive computation inline, a rebuild scoped far too wide. Fix in Dart.
- Raster thread over budget — overdraw, unnecessary saveLayer calls, large unclipped shadows, opacity animations on big subtrees. Fix in the widget tree's shape.
The measurement is a real test on a real device
Integration tests can drive an interaction and report frame timings. Run them on a physical mid-range Android — not the newest iPhone, and never an emulator, which has entirely different performance characteristics and will pass things a real device fails.
testWidgets('product list scrolls within frame budget', (t) async {
await t.pumpWidget(const App());
final timings = await t.binding.traceAction(
() async {
// Several long flings — one small scroll measures nothing.
for (var i = 0; i < 6; i++) {
await t.fling(find.byType(ListView), const Offset(0, -600), 4000);
await t.pumpAndSettle();
}
},
reportKey: 'scroll_timeline',
);
final summary = TimelineSummary.summarize(timings);
// 90th percentile, not the average. An average hides exactly the
// stutter users notice — one 90ms frame in a smooth run vanishes
// into the mean and is the only frame anybody felt.
expect(summary.computePercentileFrameBuildTimeMillis(90),
lessThan(16.0));
expect(summary.computePercentileFrameRasterizerTimeMillis(90),
lessThan(16.0));
});Assert on the 90th percentile. The average frame time of a janky app is fine — that is the whole problem with measuring the average.
The four causes worth checking first
Almost every jank report we have investigated came down to one of these, and they are quick to rule out before anyone opens a profiler.
- Rebuild scope. A setState near the root rebuilds a subtree that did not change. Push state down, or use a scoped rebuild (Riverpod's Consumer, ValueListenableBuilder) around only the widget that depends on it.
- Work inside build(). Sorting, filtering, parsing, or date formatting in build runs on every frame of an animation. Hoist it, memoise it, or move it to an isolate.
- Unbounded lists. ListView(children: [...]) builds every child immediately. ListView.builder builds only what is visible — this single substitution has fixed more scroll jank than everything else combined.
- Opacity and shadows on large subtrees. Animating Opacity forces a saveLayer; AnimatedOpacity on a big tree, or a soft shadow behind a full-width card, is raster-thread expense that is invisible in Dart profiling.
Make it a gate, not a report
A performance number nobody blocks a merge on is a number that drifts. Ours fails the build past 16ms at p90 on the scroll-heavy screens, which means a regression is caught by the person who wrote it, in the pull request, rather than by a client three months later describing it as 'feeling a bit slow'.
That last phrase is the real cost of unmeasured jank. It does not arrive as a bug report you can act on; it arrives as a vague loss of confidence in the whole app, and by then it is a hundred small things rather than one.