Flutter performance guide: profile jank in DevTools, read UI vs raster threads, tame Impeller, cut rebuilds, optimize lists and images, and reliably hit 120fps.
"It's smooth on my phone" is the most expensive sentence in mobile development. I've shipped Flutter apps to production for over six years, and almost every serious performance bug I've chased started with that exact false confidence — usually mine, said on a top-tier device with a warm cache and nothing else running. The framework makes it trivially easy to build a 60fps UI, and just as easy to quietly tank it with a rebuild you never noticed, a blur you thought was free, or an image four times too big for the box it sits in.
Flutter performance optimization is not a dark art. It's a loop with a small number of moving parts, and once you internalize the parts, most jank becomes obvious in minutes instead of days. This is the workflow I actually use on real apps at Shpper — not the theory, not the "wrap everything in const and pray" advice. It's the sequence of decisions I make when a screen feels wrong, the DevTools tooling behind each one, and the mental model that ties them together. If you take one habit away, make it this: diagnose before you treat. Nearly every hour I've wasted on Flutter performance came from jumping to a fix I hadn't earned with a measurement.
Before anything else, two rules I never break.
Profile in profile mode, on a real device. Debug mode ships an unoptimized build with assertions on, service extensions running, and a slow allocator. The frame numbers you get there are fiction — I've seen debug builds report 40ms frames that render in 6ms in profile. Never benchmark in debug, and never trust the simulator or emulator for frame timing, because they run on your desktop GPU and hide exactly the raster costs that hurt on-device.
flutter run --profile -d <device-id>
And run it on a mid-range physical phone, not your flagship. I keep a deliberately unimpressive Android device on my desk — something a couple of years old with a 60Hz panel and modest thermals — because that's where the median user actually lives. If it's smooth there, it's smooth everywhere. If you only ever test on the newest iPhone, you're shipping to a device your users don't own.
Once you're in profile mode, open Flutter DevTools and go to the Performance tab. Your target is a budget, not a vibe: at 60Hz you have ~16.6ms per frame, and at 120Hz you have ~8.3ms. That budget covers both threads — the UI thread building and laying out, and the raster thread painting. Miss it on either one and you've dropped a frame, full stop. Write those two numbers on a sticky note. Almost every decision below is really the question "does this fit in 8.3ms?"
One more habit worth building early: capture a baseline. Scroll the suspect screen for a few seconds with the timeline recording, note the worst frames, then change exactly one thing and re-record. Performance work without a before-and-after number is just superstition with extra steps.
If you take one technical thing from this post, take this: Flutter renders on two threads, and confusing them is the single biggest time-waster in performance work.
build(), synchronous JSON parsing, or layout thrash shows up.saveLayer, huge images, and overdraw show up.DevTools colors these two separately in the frame chart, and they mean completely different things. A tall UI-thread bar and a tall raster-thread bar have opposite fixes. I've watched sharp engineers micro-optimize Dart for two days when the real cost was a single saveLayer on the raster side — no amount of const was ever going to touch it. Read which thread is blown first, then decide what to do. Diagnosis before treatment.
A quick heuristic for the common cases: if the frame spikes when your state changes (a new item arrives, a value updates), suspect the UI thread. If the frame spikes purely from scrolling or animating pixels that already exist, suspect the raster thread. It's not a law, but it points you at the right half of the timeline nine times out of ten.
Impeller is Flutter's rendering engine — the default on iOS and Android now — and the headline thing it fixes is shader compilation jank. Those first-run stutters, where the very first time an animation or a particular effect hit the screen you'd get a visible hitch? That was the old Skia backend compiling shaders on demand, at runtime, at exactly the moment you needed the GPU not to stall. Impeller precompiles its shaders ahead of time, so that whole category of "janky the first time, fine after" bugs mostly disappears.
Practically, this changes your checklist. The old workaround — capturing an SkSL warm-up trace with --cache-sksl and bundling it so the first run wasn't cold — is no longer the right move on Impeller. If you're still carrying that warm-up file around from a 2022-era project, delete it; it does nothing now and just adds ceremony to your build.
What you still owe the engine is discipline around genuinely expensive GPU work. Impeller made shaders cheap; it did not repeal physics.
saveLayer allocates an offscreen buffer and composites it back. It's one of the most expensive things you can trigger, and you often trigger it by accident — Opacity over a multi-child subtree, certain ShaderMask and blend-mode combinations, and some clip shapes all reach for it internally.Opacity(opacity: 0.5, child: bigThing) forces a saveLayer. If you're just fading a single image, Image has a color/colorBlendMode path, and for animations FadeTransition and AnimatedOpacity are smarter than a raw Opacity widget rebuilt by hand.The fix for a static-but-expensive effect is almost always the same idea: compute it once, not every frame. A blurred background that never changes should be rendered to an image a single time and drawn as a plain bitmap while the list scrolls over it. You'd be amazed how many "the scroll is janky" bugs are really "we're re-blurring a static background sixty times a second." When you genuinely need a repeated expensive effect — a soft shadow on many cards, say — reach for cheaper approximations first: a pre-baked shadow asset or a BoxDecoration gradient often reads identically to the eye at a fraction of the raster cost.
When the Performance timeline shows a spiky frame, walk it top to bottom.
Step one: which thread? Click the spiky frame. DevTools splits it into UI and raster. Whichever bar blew the budget is the one you investigate. Everything else is a distraction until you've answered this.
Step two, if it's the UI thread: turn on Track Widget Builds. It shows you exactly what rebuilt and how many times. A widget rebuilding 120 times a second while nothing about it changed is your smoking gun. Nine times out of ten it's a setState or a provider notification firing too high in the tree.
Step three, if it's the raster thread: open the Flutter inspector and turn on the Repaint Rainbow. Repainting regions flash through colors. A widget that strobes on every frame while visibly nothing moves is repainting for no reason, and almost always wants a RepaintBoundary around it — or the expensive-effect treatment from the section above.
Two more toggles I lean on constantly:
showPerformanceOverlay: true on MaterialApp, or via DevTools) draws two live graphs right on the device — UI thread on top, raster on the bottom — with a bar marking the frame budget. It's crude, but it's the fastest way to feel which thread is struggling while you scroll a real screen with your thumb.IntrinsicHeight, an unconstrained Column doing extra passes, or a poorly-sized Table.Do this in order and you stop guessing. The timeline tells you the thread, the toggles tell you the widget, and only then do you write code. It's worth internalizing this as a literal checklist, because under deadline pressure the temptation to skip straight to "I'll just add const everywhere" is enormous — and it's exactly how you spend an afternoon optimizing the thread that was never the problem.
The cheapest frame is the one you never build. Most avoidable jank in Flutter is unnecessary rebuilds, and the fix is boring: build fewer widgets, less often.
const. A const widget is instantiated once, and on rebuild the framework compares it by identity and short-circuits — it doesn't even walk into the subtree. Turn on prefer_const_constructors and prefer_const_literals_to_create_immutables in analysis_options.yaml so this is enforced by the linter, not left to memory. const you have to remember is const you'll forget under deadline.setState scoped to the smallest widget that actually changes. setState at the top of a screen rebuilds the entire screen. Pull the changing bit into its own StatefulWidget so the blast radius is tiny.const subtree instead, which genuinely is reused.The single most useful pattern here is the child escape hatch on the builder widgets. It exists specifically so an animation rebuilds only its wrapper, never its contents:
// The heavy child is built exactly once; only the Transform rebuilds each frame.AnimatedBuilder( animation: _controller, builder: (context, child) { return Transform.rotate( angle: _controller.value * 2 * math.pi, child: child, // passed through untouched — not rebuilt ); }, child: const ExpensiveStaticContent(),);At 120fps that builder runs 120 times a second. Without the child hoist, ExpensiveStaticContent gets rebuilt 120 times a second for a rotation that never touches it. The same pattern applies to ListenableBuilder, ValueListenableBuilder, and AnimatedContainer — anything with a child parameter is telling you "put the stable subtree here."
And subscribe narrowly. In provider-style state, this is the difference between a screen that rebuilds on any change and one that rebuilds only when the field it draws actually moves:
// Rebuilds only when `unreadCount` changes — not on every field of the model.Selector<InboxModel, int>( selector: (_, model) => model.unreadCount, builder: (context, unread, child) => Badge(count: unread),);
Riverpod's select, BLoC's buildWhen, and context.select are all the same idea: don't wake the whole screen because one number changed. This matters more the deeper your widget tree goes — a badly-scoped subscription near the root of a busy screen can turn a single keystroke into a full-tree rebuild, and you'll feel it as input lag long before DevTools spells it out for you.
In demos, jank comes from contrived animations. In production, it comes from two things: long lists and unoptimized images. This is where I spend most of my performance budget.
Always use the lazy builders — ListView.builder, GridView.builder, SliverList — and never a ListView(children: [...]) for anything data-driven, because the children list builds every row up front, on the frame the list appears. A 500-item list built eagerly is 500 widgets constructed in one frame; that's a guaranteed hitch on entry.
Key (a real id, not the index) so reorders and insertions don't rebuild the whole world.ListView.builder adds a RepaintBoundary per item by default — respect that, don't fight it.RepaintBoundary so its repaints don't invalidate its neighbors.Column of items inside a SingleChildScrollView masquerading as a list. It looks fine with ten items and dies at two hundred, because it's eager. If it scrolls and it's data-driven, it wants a builder.CustomScrollView + SliverList/SliverGrid). Slivers give you lazy building across the whole scroll view, not just one section, and they're what you want the moment a screen has more than one scrolling region.Images are the silent killer, and the failure mode is worse than jank — it's an out-of-memory crash on the exact low-end devices you were supposed to be protecting. A 4000×3000 photo decoded to fill a 200px thumbnail doesn't just waste raster time; it holds a large decoded bitmap in memory for a box the size of a stamp. Stack a few dozen of those in a scrolling grid and you've got a crash waiting for a low-RAM phone.
The fix is to decode at display size, not source resolution:
Image.network( url, // Decode to roughly the pixel size you'll actually show (account for DPR). cacheWidth: 400, fit: BoxFit.cover,)
Note that cacheWidth/cacheHeight are in physical pixels, so multiply your logical widget width by the device pixel ratio when you pick the number — a 200-logical-pixel thumbnail on a 2x screen wants roughly cacheWidth: 400, not 200.
For anything real, reach for cached_network_image, which gives you disk + memory caching, memCacheWidth/memCacheHeight for the same decode-at-size trick, and placeholder/error builders so a slow network doesn't leave holes in your list:
CachedNetworkImage( imageUrl: url, memCacheWidth: 400, // decode small; don't hold the full-res bitmap fit: BoxFit.cover, placeholder: (context, url) => const ColoredBox(color: Color(0x11000000)), errorWidget: (context, url, error) => const Icon(Icons.broken_image),)
If you serve your own images, do the resizing server-side or at your CDN and never ship the original resolution to a phone. The cheapest pixel to decode is the one you never sent. And prefer a modern format — WebP or AVIF — over baseline JPEG/PNG where your CDN supports it; smaller payloads mean less to download, cache, and decode on the devices least able to afford it.
Everything above gets you to a rock-solid 60. Pushing to 120 is mostly the same discipline with half the margin — but there are a few things that only bite at the higher rate.
Confirm the panel is actually running at 120. On a chunk of Android hardware, high-refresh is opt-in, and if you don't enable it you're computing 120 frames of work that a 60Hz panel throws half of away — pure waste that looks like jank because you're now missing the tighter budget for no benefit. The flutter_displaymode package lets you query and request the high-refresh mode explicitly:
import 'package:flutter_displaymode/flutter_displaymode.dart';Future<void> enableHighRefresh() async { try { await FlutterDisplayMode.setHighRefreshRate(); } catch (_) { // Not supported on this device/OS — fall back silently to whatever it gives us. }}Then hold the ~8.3ms budget with the things that only matter when the budget is tight:
build() or an animation callback that fires 120 times a second feeds the garbage collector, and GC pauses show up as periodic UI-thread spikes — a little tooth every second or so in the timeline. Hoist those allocations out; reuse Paint, Path, and controllers. In a CustomPainter especially, allocating paints and paths per paint() call is a classic source of these regular little spikes.compute() or a long-lived isolate:// Parse off the UI thread so a big payload never stalls a frame.final items = await compute(parseItems, jsonString);List<Item> parseItems(String raw) => (jsonDecode(raw) as List).map((e) => Item.fromJson(e)).toList();
AnimationController math where you can. AnimatedContainer, TweenAnimationBuilder, and the transition widgets are tuned, they respect the ticker, and they're less likely to allocate on every tick than a naive custom builder.120 isn't a different game from 60. It's the same game with less room to be sloppy — the same rebuild discipline, the same raster hygiene, just with a budget that punishes the mistakes you got away with before.
const will never touch a saveLayer.SkSL warm-up files — they're dead weight now.const with lints, scope setState tight, use the child hoist on builder widgets, and subscribe narrowly with select/buildWhen.cacheWidth/memCacheWidth, mind the DPR) and resize at the CDN so you never OOM a cheap phone.Flutter performance isn't magic and it isn't luck. It's a loop: **profile on a real, mid-range device in profile mode; read the DevTools timeline; decide UI-thread vs raster-thread before you touch anything; fix that one specific thing; measure again.** Let Impeller handle shader jank and drop your old SkSL warm-up. Enforce const with lints, scope setState tight, and subscribe narrowly to state. Build lists lazily with stable keys, and decode images at display size so you never OOM a cheap phone. Confirm the panel is really at 120 before you optimize for it, and keep the hot paths free of allocations.
Do that consistently and 120fps stops being aspirational. It becomes the default you'd have to actively work to break — and "it's smooth on my phone" turns from a liability into something you've actually measured.