Most Flutter performance optimization work lands in three places: lists that build more than they show, images that decode larger than they display, and animations that rebuild wider than they move. Everything else is measurement, and measurement is the part people skip.
I build Flutter apps across six platforms from one codebase. BookBed, the property management SaaS I have been running solo since 16 October 2025, ships to iOS, Android, Web, macOS, Linux and Windows out of a single Dart tree on Firebase and Stripe. Its main screen is a Gantt-style booking timeline that scrolls in two directions at once. That screen punishes all three mistakes above, and it punishes them differently on every target.
What Actually Causes Flutter Jank?

Jank comes from blowing a frame budget on one of two threads: the UI thread that runs your Dart, or the raster thread that draws what your Dart produced.
Flutter's performance best practices put a hard number on it. On a 60 Hz display you have 16 ms per frame, split as 8 ms to build and 8 ms to raster. On a 120 Hz panel, and most flagship Android phones sold in the last three years ship one, the entire budget drops under 8 ms.
Which thread blew it matters more than the total. A slow UI thread means your Dart is doing too much inside a frame: rebuilding subtrees that did not change, or sorting a list inside build(). A slow raster thread means the layer tree you handed the engine is expensive to draw, usually from saveLayer(), stacked opacity, or clipping. Flutter's profiling guide is blunt about the second case. You cannot reach into the raster thread, so when it is slow, it is still your Dart code that put it there.
Measure in profile mode, on a physical device. Debug-mode numbers are fiction. For the shortlist of what to actually run while you do that, I keep one in my roundup of Flutter developer tools, and DevTools' Frame Analysis tab is the first stop, because it names the guilty thread before you start guessing.
Why Is My Flutter ListView Slow?

Almost always because something forced it to size or build items it never puts on screen: shrinkWrap, intrinsic sizing, or a non-builder constructor holding every child at once.
That ListView.builder is lazy and ListView(children: [...]) is not is the flutter listview performance advice everyone already has. The interesting failures start after you have done that part.
Take shrinkWrap. The API docs say shrink wrapping "is significantly more expensive than expanding to the maximum allowed size because the content can expand and contract during scrolling" (ScrollView.shrinkWrap). People reach for it to nest a list inside a Column, and the honest fix is almost always a CustomScrollView with slivers instead, because a SliverList composes with other scrollable pieces without needing to know its own height.
Then intrinsic passes, which are quieter and worse. If you want every cell sized to the biggest or smallest cell, the framework has to poll all of them twice. The best-practices page gives two ways out: set cell sizes up front, or nominate one anchor cell and size the rest relative to it. Same idea powers itemExtent and prototypeItem, which the ListView docs describe as "more efficient than letting the children determine their own extent because the scrolling machinery can make use of the foreknowledge of the children's extent."
This is exactly why BookBed's timeline cells are locked at 50, 42, 100 and 60 logical pixels across every platform, with no responsive breakpoints reshaping them. The original reason was drag math, not frames. The scroll win was a side effect I only noticed later, profiling on a mid-range Android handset.
One more thing the docs say out loud and everyone forgets: off-screen children are not recycled the way an Android RecyclerView recycles rows. "When a child is scrolled out of view, the associated element subtree, states and render objects are destroyed." Scroll back and you pay the full construction cost again. If your item widget does real work in its constructor or initState, a fast scroll is a loop of that work.
| Approach | Builds off-screen items? | Extra cost | Reach for it when |
|---|---|---|---|
ListView(children: [...]) | Builds all of them, always | Whole list constructed at startup | Under ~20 static rows and you know it |
ListView.builder | Only the viewport plus a cache band | Per-item construction repeats on scroll-back | A standalone list is the whole screen |
SliverList in CustomScrollView | Same laziness as the builder | Slightly more setup code | The list shares a scroll view with headers, grids, or a collapsing app bar |
ListView(shrinkWrap: true) | Lazy, but re-measures constantly | Size recomputed as the scroll position moves | Genuinely never, if a sliver will do |
The cache band is worth a number. Flutter's viewport keeps a default cache extent of 250.0 logical pixels beyond each edge of the visible area, so a "visible items only" mental model is already slightly wrong.
And that knob changed recently. Flutter 3.44 deprecated cacheExtent and cacheExtentStyle across ListView, GridView, CustomScrollView and Viewport, replacing both with a single scrollCacheExtent taking a ScrollCacheExtent.pixels(500.0) or ScrollCacheExtent.viewport(0.5) object (Flutter breaking changes, 2026). The migration guide sells it as clearer intent. The pull request behind it, merged by chunhtai on 10 February 2026, quietly fixes something nastier: with shrinkWrap: true the main-axis extent is double.infinity, the viewport-style cache calculation produced double.NaN, and the app crashed. Another reason to stop reaching for shrinkWrap.
Images Decode Bigger Than You Think

A decoded image costs roughly width times height times four bytes in memory, regardless of the box you display it in. A 4000 by 3000 photo is about 48 MB resident while it renders into a 120-pixel avatar.
Flutter's ImageCache is an LRU holding "up to 1000 images, and up to 100 MB". Two of those photos and you have consumed most of the cache. Worse, a single image larger than maxByteSize is silently not cached at all, which has been the behaviour since Flutter 1.17 removed the old auto-expansion. So the biggest images in your app are also the ones being re-decoded most often. That is the failure mode: not a crash, just a scroll that gets slower the more photos it has passed.
The fix is decoding at display size instead of source size. cacheWidth and cacheHeight on Image.network and Image.asset, or a ResizeImage wrapper when you are using CachedNetworkImageProvider and cannot pass them directly. Saropa Contacts documented the payoff in November 2024, cutting per-image memory from 1.5 MB to under 250 KB across a screen holding more than 300 images, using WebP plus memCacheWidth and memCacheHeight (Saropa engineering write-up).
Ask the server for dimensions alongside the URL. If your API returns width and height with each image record, every client can size its decode correctly without a round of guessing, and you stop shipping cacheWidth: 300 constants that go stale the first time a designer changes a card layout.
Animations: Scope the Rebuild, Not the Frame Rate
An animation that drops frames is usually rebuilding a subtree that does not animate, sixty times a second, for nothing.
AnimatedBuilder has a child parameter for exactly this. The AnimatedBuilder docs put it plainly: if the builder callback contains a subtree that does not depend on the animation, "it's more efficient to build that subtree once instead of rebuilding it on every animation tick." Pass it as child and it comes back into the builder already built. Move the static half across and the tick gets cheap.
const constructors do the same job one level up. Flutter's best-practices page says they "allow Flutter to short-circuit most of the rebuild work", and the discipline is worth enforcing with flutter_lints rather than remembering. Pair it with keeping setState() low in the tree, since every descendant of the calling State rebuilds whether or not it changed.
RepaintBoundary is where I see the most cargo-culting. It isolates painting, not building and not layout, so it does nothing for a subtree whose problem is rebuild scope. Wrap something whose content changes every single frame and you have added a texture upload per frame to the GPU's workload. Actually, let me back up. It is still the right tool for a small, constantly-repainting widget sitting inside a large static one, like a progress spinner over a rendered document. The mistake is applying it before you know whether paint is the bottleneck.
Rendering itself got quieter here. Impeller is now the only engine on iOS, the default on Android API 29 and above with an OpenGL fallback below that, and as of Flutter 3.47 the default on macOS, Linux and Windows too (Impeller docs). It precompiles shaders at build time, which retired the first-run animation stutter that used to make people think their animation code was wrong when it was fine.
Where Flutter Performance Optimization Actually Starts
Not with a technique. With one janky screen, in profile mode, on the cheapest device you support. Everything above is a hypothesis until a timeline chart tells you which thread is late.
When Does Flutter Need an Isolate?
Only when a computation is long enough to make the UI stutter. Flutter's concurrency guide refuses to give a size threshold and names jank as the sole trigger.
Its list of usual suspects is short and specific: reading from a local database, parsing large data files, processing photos or audio, applying filters to complex lists. Not "any loop over 1000 items."
The reason to be conservative is that isolates are not free. Spawning one costs, and every object you pass in or out gets copied. For repeated small jobs, the copy tax can outrun what you saved.
When it is genuinely warranted:
- Reproduce the jank in profile mode and confirm the UI thread, not the raster thread, is the one over budget.
- Find the function in the CPU profiler's flame chart. Guessing which one it is has a poor hit rate.
- Move it behind
Isolate.run, orcompute()if the app also targets web, where isolates do not exist andcompute()falls back to the main thread. - Re-measure. If the frame time did not move, the work was not the problem and you have added copying overhead for nothing.
- Only if the same job runs repeatedly, replace the short-lived isolate with a long-lived one and a message port.
BookBed's iCal sync is the one place I reach for this. Parsing several aggregator calendars and diffing them against existing bookings is exactly the "parsing and decoding large data files" case, and it happens while the user is looking at a calendar that must keep scrolling.
Pick One List and Time It
Two fixes carry most of the weight for almost no effort. Pass child to your AnimatedBuilder, and set cacheWidth on every remote image that appears in a scrolling list.
For anyone weighing FlutterFlow against hand-written Flutter: none of this changes. FlutterFlow generates Flutter, so a generated list has the same frame budget and the same intrinsic-pass trap. I ship custom widgets into that ecosystem myself, eight templates and 40+ custom widgets on the FlutterFlow Marketplace, and the performance questions buyers send me are the same three every time.
So, which one is it in your app? Open DevTools on your worst screen, scroll it for ten seconds in profile mode, and look at which thread is over 8 ms. I would genuinely like to know how often it turns out to be images.
