Skip to content
Tech Stack15 August 2026 · 9 min readUpdated 30 September 2026

Flutter Performance in 2026: Lists, Images, and Animations

Lists that build more than they show, images that decode larger than they display, animations that rebuild too wide. Where Flutter frames actually go, and how to find yours.

Flutter Performance in 2026: Lists, Images, and Animations

What you'll learn

  • What actually causes Flutter jank?
  • Why is my Flutter ListView slow?
  • How do you stop animations and heavy work from dropping frames?
Article contents

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?

Two strips of pale paper tape running across a warm beige desk in hard directional light, the left strip lying perfectly flat while the right strip has buckled into a tight concertina of ripples that throws a sawtooth shadow

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?

A long accordion-folded paper concertina spilling off the edge of a wooden desk toward a concrete floor, with a lamp lighting only four folds in the middle of its run while every other fold falls into deep shadow

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.

ApproachBuilds off-screen items?Extra costReach for it when
ListView(children: [...])Builds all of them up frontWhole list constructed at startupA short, static list
ListView.builderOnly the viewport plus a cache bandPer-item construction repeats on scroll-backA standalone list is the whole screen
SliverList in CustomScrollViewSame laziness as the builderA little more setup codeThe list shares a scroll view with headers, grids or a collapsing app bar
ListView(shrinkWrap: true)Lazy, but re-measures as it scrollsSize recomputed as the scroll position changesRarely, 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 huge unrolled architectural drawing covering a wooden desk, with a single stamp-sized print of the same design resting alone at its centre and a faint coffee ring stain near the curled top corner

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:

  1. Reproduce the jank in profile mode and confirm the UI thread, not the raster thread, is over budget.
  2. Find the function in the CPU profiler instead of guessing.
  3. Move it behind Isolate.run, or compute() if the app also targets the web, where the guide says isolates aren't supported and compute() runs on the main thread.
  4. Re-measure. If frame time didn't move, that work wasn't the problem.
  5. 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.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

Why is my Flutter ListView slow even with ListView.builder?

Because laziness only covers construction, and something else in the list is still doing per-frame work. The usual culprits are shrinkWrap: true, which the API docs say is significantly more expensive because the size is recomputed whenever the scroll position changes, and intrinsic passes, where sizing cells to the biggest or smallest one makes the framework poll every cell twice. Set itemExtent or prototypeItem when rows share a size. Also check what your item does in its constructor and initState, because the ListView docs say off-screen children are destroyed and recreated on return, not recycled.

How does Flutter image caching work, and what are its limits?

Flutter's ImageCache is documented as a least-recently-used cache of up to 1000 images and up to 100 MB. Memory cost follows decoded pixels, not file size. My estimate for uncompressed RGBA is width times height times four bytes, so a 4000 by 3000 photo is about 48 MB. Since Flutter 1.17 the cache no longer increases its maxByteSize to fit a large image, per the breaking change note. Decode at display size with cacheWidth and cacheHeight on Image.network and Image.asset, or wrap a provider in ResizeImage when you can't pass them directly.

What is the fastest way to fix Flutter animation performance?

Pass the part of the subtree that doesn't animate to AnimatedBuilder's child parameter. The AnimatedBuilder docs say it's more efficient to build such a subtree once instead of rebuilding it on every animation tick. After that, use const constructors where the analyzer allows, since Flutter's best practices say they let the framework short-circuit most rebuild work, and keep setState() low in the tree because every descendant of the calling State rebuilds. Consider RepaintBoundary only after profiling shows painting is the cost, since it isolates painting and doesn't change rebuild scope.

When should I move work to a Flutter 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 that no exhaustive list of cases exists, though it names reading from a local database, parsing large data files and processing photos or audio as common ones. Confirm in profile mode that the UI thread is over budget, find the function in the CPU profiler, and move it behind Isolate.run. Use compute() if you also target web, where isolates aren't supported and it runs on the main thread. Re-measure afterwards, because if frame time didn't move, that work wasn't the problem.

Is Impeller enabled by default in Flutter now?

Yes on iOS, Android API 29 and above, macOS, Linux and Windows, but not on the web. Flutter's Impeller documentation says it's the only supported engine on iOS, enabled by default on Android API 29 and up with an OpenGL fallback on older or non-Vulkan devices, and enabled by default on macOS, Linux and Windows as of Flutter 3.47, with the opt-out slated for removal in a later release. Flutter on the web currently uses Skia. Impeller compiles its shaders at engine-build time, so they don't compile at runtime while your animation runs.