If one Flutter screen drops frames, open DevTools in profile mode on a physical device and scroll that screen for ten seconds before you change any code. 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 step people skip.
Flutter is what I use for BookBed, the property management SaaS I built solo from one Dart codebase for iOS, Android, Web, macOS, Linux and Windows. Its case study documents a Gantt-style booking timeline whose cells are locked at 50, 42, 100 and 60 logical pixels on every platform, with no responsive breakpoints reshaping the drag math. That constraint was chosen for drag behaviour. The list advice below explains why fixed sizes also help scrolling, and I'd treat that as an inference rather than a measured result for BookBed. Every documented behaviour here comes from Flutter's own docs, which I re-read on 30 September 2026.
What actually causes Flutter jank?
Jank comes from missing the frame deadline on one of two threads: the UI thread that runs your Dart, or the raster thread that draws the layer tree your Dart produced.
Flutter's performance best practices give the numbers. On a 60 Hz display you have 16 ms for building and 16 ms for rendering, and if latency matters you should aim for 8 ms or less for each. As 120 fps devices become more common, the same page says to render frames in under 8 ms total for the smoothest result. It also says that if frames finish well under 16 ms in profile mode, you likely don't need to worry about performance.
Which thread missed matters more than the total. A slow UI thread means your Dart is doing too much inside a frame: rebuilding subtrees that didn't change, or sorting a list inside build(). A slow raster thread means the layer tree is expensive to draw, and Flutter's performance overlay guide is direct about it: you can't reach the raster thread, so if it's slow, it's a result of something your code did.
Measure in profile mode on a real device, because the build modes page says performance can be janky in debug mode and to measure on an actual device in profile mode. The DevTools Performance view shows a frames chart. Select a slow frame, shown in red, and the Frame analysis tab gives hints about expensive operations. That tells you which thread to look at before you start guessing. For the rest of my toolbox, see my list of Flutter developer tools.
Why is my Flutter ListView slow?
Most often because something forces the list to measure or build items it never shows: shrinkWrap, intrinsic sizing, or a constructor that holds every child at once.
That ListView.builder is lazy and ListView(children: [...]) isn't is the advice everyone already has. The best-practices page confirms it, noting that with a lazy list only the visible portion of the screen is built at startup. The interesting failures start after you've done that part.
shrinkWrap and intrinsic passes
The API docs say shrink wrapping the content is significantly more expensive than expanding to the maximum allowed size, because the content can expand and contract during scrolling and the size has to be recomputed whenever the scroll position changes. People reach for it to nest a list inside a Column. My recommendation is a CustomScrollView with slivers instead, since a SliverList shares one scroll view with other pieces without needing to know its own height.
Intrinsic passes are quieter. The best-practices page describes them: if you want every cell to match the biggest or smallest cell, the framework asks every cell, not just the visible ones, for its intrinsic size, then visits them all a second time. You can spot this by enabling the Track layouts option in DevTools and looking for events labelled intrinsics. The documented ways out are fixed cell sizes up front or an anchor cell that the others are sized against. itemExtent and prototypeItem apply the same idea, and the ListView docs say they're more efficient than letting children determine their own extent because the scrolling machinery can use foreknowledge of it.
One more documented behaviour that people forget: off-screen children aren't recycled the way an Android RecyclerView recycles rows. The same ListView page says that when a child scrolls out of view, its element subtree, states and render objects are destroyed and recreated when it scrolls back. If an item widget does real work in its constructor or initState, a fast scroll repeats that work.
| Approach | Builds off-screen items? | Extra cost | Reach for it when |
|---|---|---|---|
ListView(children: [...]) | Builds all of them up front | Whole list constructed at startup | A short, static list |
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 | A little more setup code | The list shares a scroll view with headers, grids or a collapsing app bar |
ListView(shrinkWrap: true) | Lazy, but re-measures as it scrolls | Size recomputed as the scroll position changes | Rarely, when a sliver will do |
The cache band and its 2026 rename
The viewport builds slightly beyond the visible area. The API reference gives a default cache extent of 250.0 logical pixels, so a "visible items only" mental model is a little off. The knob also changed: Flutter 3.44 deprecated cacheExtent and cacheExtentStyle on ListView, GridView, CustomScrollView and Viewport, replacing both with one scrollCacheExtent property. The migration guide shows cacheExtent: 500.0 becoming scrollCacheExtent: const ScrollCacheExtent.pixels(500.0). If your code still sets the old properties, that's a mechanical fix.
Images decode larger than you display them
A decoded image costs roughly width times height times four bytes of memory, which is my arithmetic for uncompressed RGBA pixels and not a figure from the docs. On that basis a 4000 by 3000 photo is about 48 MB while it renders into a 120-pixel avatar.
That matters because Flutter's ImageCache is documented as a least-recently-used cache of up to 1000 images and up to 100 MB. Two of those photos would use nearly half of it. There's a second effect in a breaking change note: since Flutter 1.17, the cache no longer grows its maxByteSize to fit a large image. My reading is that an image bigger than the limit doesn't stay cached, which would mean your heaviest images get decoded again and again. That would show up as a scroll that slows down the more photos it passes, but I haven't measured it.
The fix is to decode at display size. Image.network and Image.asset accept cacheWidth and cacheHeight, and ResizeImage wraps any ImageProvider with a target width and height when you can't pass them directly. If your API returns width and height with each image record, every client can size its decode correctly, and you avoid cacheWidth: 300 constants that go stale the next time a designer changes a card.
How do you stop animations and heavy work from dropping frames?
Scope the rebuild to the part that actually animates, and move a computation off the UI thread only when it's long enough to cause visible jank.
Scope the rebuild, not the frame rate
An animation that drops frames is often rebuilding a subtree that doesn't animate, on every tick. AnimatedBuilder has a child parameter for exactly this. The AnimatedBuilder docs say that if the builder contains a subtree that doesn't depend on the animation, it's more efficient to build that subtree once and pass it as child, which the builder then receives already built.
const constructors work one level up. The best-practices page says they let Flutter short-circuit most of the rebuild work, and it suggests enabling the recommended lints from flutter_lints so you're reminded automatically. It also advises localizing setState() to the part of the subtree that needs to change, since every descendant of the calling State rebuilds.
RepaintBoundary deserves care. Its API page says it creates a separate display list for its child, which can help when the subtree repaints at different times than the surrounding tree. It isolates painting, not building and not layout, so it does nothing for a rebuild-scope problem. Reach for it after profiling shows paint is the cost.
Rendering has changed underneath all of this. The Impeller docs say it's the only supported engine on iOS, enabled by default on Android API 29 and up with an OpenGL fallback below that, and enabled by default on macOS, Linux and Windows as of Flutter 3.47. Flutter on the web still uses Skia. Impeller compiles its shaders at engine-build time, so they don't compile at runtime.
When does Flutter need an isolate?
Only when a computation is large enough to cause visible UI jank. Flutter's concurrency guide says heavy computations can cause "UI jank" and calls it impossible to create an exhaustive list of when to consider isolates, then names common cases: reading from a local database, parsing and decoding large data files, processing or compressing photos, audio and video, and applying filters to complex lists.
Isolates cost something. Each has its own memory, and messages sent with SendPort.send are copied. Isolate.run and compute use Isolate.exit, which sends a reference instead. Here's the order I'd follow:
- Reproduce the jank in profile mode and confirm the UI thread, not the raster thread, is over budget.
- Find the function in the CPU profiler instead of guessing.
- Move it behind
Isolate.run, orcompute()if the app also targets the web, where the guide says isolates aren't supported andcompute()runs on the main thread. - Re-measure. If frame time didn't move, that work wasn't the problem.
- If the same job runs repeatedly, consider a long-lived isolate with message passing.
An isolate can't touch rootBundle or dart:ui, per the same guide, so UI work stays where it is.
Pick one list and time it
Two fixes carry a lot of weight for little effort: pass child to your AnimatedBuilder, and set cacheWidth on every remote image that appears in a scrolling list. If you build custom widgets for the FlutterFlow ecosystem, as in my FlutterFlow Marketplace portfolio of eight templates and 40+ custom widgets, those widgets are Flutter and Dart, so the same profile-mode measurement applies. For the wider tooling question, see FlutterFlow versus hand-written Flutter.
So which one is it in your app? Open DevTools on your worst screen, scroll it for ten seconds in profile mode, select the slowest red frame, and note whether the UI or raster thread is over budget. Then tell me how often it turns out to be images.



