Make each rebuild touch as little of the tree as possible: mark unchanging subtrees const, keep state in the small widget that uses it, and subscribe to the one field you need instead of the whole object.
**const constructors**: a const widget is the same instance on every build, so the framework sees an identical widget and skips that whole subtree
Split into widgets, not helper methods: a _buildHeader() method rebuilds with its parent; a separate widget can be const, so it is skipped, and its own setState or listener rebuilds only that widget
Move state down: setState in the page root rebuilds the page; the same state inside the toggle rebuilds only the toggle
Listen narrowly: context.select, BlocSelector, ref.watch(provider.select(...)) or ValueListenableBuilder rebuild only when the chosen value changes
**Pass a prebuilt child**: AnimatedBuilder, ValueListenableBuilder and Consumer take a child that is built once and reused
Measure first: the rebuild counts in DevTools show what actually rebuilds too often; rebuilding a cheap widget is not worth fixing
FlutterSenior
Performance
Rendering pipeline, jank, memory leaks, profiling tools
Questions closed
0 / 18
- 0Review
- 8Asked often
Questions
A rebuild re-runs build and updates widgets and elements; a repaint re-records the drawing commands of render objects. RepaintBoundary gives its subtree its own layer, so a repaint inside it does not repaint its neighbours, and the other way round.
They are independent: a CustomPaint with repaint: controller repaints every frame with zero rebuilds, and a rebuild that changes nothing visible causes no repaint
Why repaints spread: render objects without a boundary between them paint into one shared layer, so one dirty object repaints all of it
Where it pays off: a spinner, a video, a ticking clock or a live chart sitting next to static, expensive content
Where Flutter already adds one: routes and every ListView item (addRepaintBoundaries: true)
The cost: each boundary is an extra layer and extra GPU memory; wrapping everything makes the app slower, not faster
How to check: debugRepaintRainbowEnabled = true shows which layers repaint
Run flutter run --profile on a real, preferably cheap device, repeat one interaction and open the frame chart in DevTools Performance. Each frame has a UI bar (build, layout, paint recording) and a raster bar (GPU work); the one over budget tells you where to look.
The budget: about 16 ms per frame at 60 Hz and 8 ms at 120 Hz, for each thread; the two threads work on different frames in parallel
Always profile mode: debug runs JIT code with asserts and can be several times slower; release strips the profiling hooks
UI bar over budget: your Dart code: large rebuilds, heavy build, synchronous parsing; open the frame's flame chart and the CPU profiler
Raster bar over budget: what you asked the GPU to draw: saveLayer effects, clips, blurs, oversized images
Name your own work: wrap suspicious code in Timeline.timeSync('parse feed', () {...}) from dart:developer and it appears in the timeline
More detail on demand: "Track widget builds" and "Track layouts" in the Performance tab add per-widget events, at extra cost
Common mistake: measuring on a flagship phone and concluding the app is fine
Build it lazily with ListView.builder (or a SliverList inside a CustomScrollView), so only the visible rows plus a small cache area exist, and keep each row cheap to build, lay out and paint.
**Never ListView(children: [...]) for long data: it builds every child up front
Fixed or prototype extent**: itemExtent or prototypeItem lets the list skip measuring rows, which makes scrolling and jumpTo much cheaper
**No shrinkWrap: true inside another scrollable: it lays out every child and throws the laziness away; combine slivers instead
Cheap rows**: const where possible, thumbnails decoded at display size, no Opacity or intrinsic sizing per row
Stable keys: ValueKey(item.id) keeps state with the right row after inserts and deletes
Update one row, not the list: when one item changes, let that row listen to it instead of rebuilding the list from the top
Paginate: request the next page a screen or two before the user reaches the end
Every call to itemBuilder filters and sorts all 5,000 orders just to return one row, so each new row costs a full O(n log n) sort and a few rows per frame blow the 16 ms budget. Compute the visible list once, outside the builder.
Why it happens: ListView.builder is lazy about widgets, not about the code inside itemBuilder: the builder runs for every row that scrolls into the visible or cache area, so each row costs a filter plus a sort instead of a list lookup, and itemCount re-walks the whole list on every rebuild
The numbers: in an AOT build on a laptop, building 15 rows took roughly 15-30 ms with this code and 1-2 ms with the fix; a mid-range phone is several times slower, so even 2-3 rows per fling frame go over budget
The fix: build visible once in build and let itemBuilder only index into it; build does not run per scroll frame, only when the parent rebuilds or the inputs change; if the parent rebuilds often, cache visible in a State field and recompute it in didUpdateWidget when orders or query change
Going further: when the query changes on every keystroke, recompute only on change (debounced); move a sort of tens of thousands of items to Isolate.run
Catching it: the CPU profiler in DevTools on a janky frame shows List.sort under itemBuilder; in review, an itemBuilder should only look up the item and return a widget
The listener calls setState on every tick, so the whole build (including HeavyReport) runs 60 times a second just to rotate one icon. Let a transition widget or an AnimatedBuilder listen to the controller instead, so only the rotating part reacts.
**RotationTransition(turns: _ctrl)**: the transition listens to the animation itself; only its own small build runs per tick, while the page and HeavyReport are not rebuilt (FadeTransition does not even rebuild itself)
**AnimatedBuilder for custom effects**: its builder runs on each tick, but whatever you pass as child is built once and reused
**No setState at all: a controller-driven animation does not need one; the controller already drives the widgets that listen to it
The second bug**: _ctrl is never disposed, so in debug the page fails with "was disposed with an active Ticker" and in release the ticker keeps running
How to confirm: the rebuild counts in DevTools drop from 60 per second to zero for the page
shouldRepaint returns true, so every rebuild that hands CustomPaint a new painter repaints the chart. Compare the inputs and return false when they match, and stop copying the list with toList(), or an identity check can never succeed.
How it works: CustomPaint receives a new painter on each build and calls shouldRepaint(old); only true marks the render object for painting
Identity is enough for immutable data: !identical(old.points, points) costs nothing; with fresh copies you would need listEquals, which walks the whole list on every build
Repaint without rebuilding: pass a Listenable as repaint: to the painter's constructor and the chart repaints when it fires, with no rebuild at all
Isolate it: a RepaintBoundary around the CustomPaint keeps its repaints from spreading to the rest of the screen
Semantics too: shouldRebuildSemantics defaults to shouldRepaint, so true also rebuilds the accessibility data
They can force saveLayer: the subtree is drawn into an offscreen buffer and blended back, costing raster-thread time and memory in proportion to its area. Avoid it by putting alpha in the colour, using FadeTransition, and preferring Clip.hardEdge.
**Opacity**: for text and icons put the alpha into the colour; for images use the opacity parameter of Image, which takes an Animation<double> such as const AlwaysStoppedAnimation(0.5); to animate, use FadeTransition, which changes the render object each frame without rebuilding the subtree
**BackdropFilter**: the most expensive of all, because it reads back what is already drawn behind it; keep it small, clip it with ClipRect, never put one in every list row
Clips: Clip.antiAliasWithSaveLayer is the slow mode; Clip.hardEdge or Clip.antiAlias usually look the same, and a rounded BoxDecoration needs no clip at all
**ShaderMask and ColorFiltered: also paint through a layer
How to confirm**: the raster bar is over budget while the UI bar is fine; checkerboardOffscreenLayers: true on MaterialApp marks every offscreen layer on screen
Impeller is Flutter's own rendering engine. Skia compiled shaders at runtime the first time an effect appeared, which caused one-off jank on a fresh install; Impeller compiles its small, fixed set of shaders ahead of time, when the engine is built, so that first-run stutter is gone.
Where it is the default: iOS (Skia is no longer available there), Android with Vulkan and the desktop platforms in current Flutter; older Android devices without Vulkan fall back to the legacy OpenGL renderer
What warm-up was: record shaders with --cache-sksl, bundle them with --bundle-sksl-path and compile them at startup; the result was device-specific and fragile
Today: those flags belong to Skia and do nothing under Impeller; first-run jank now points at your own code, not at shaders
Other effects: frame times are more predictable, but some effects (large blurs, complex paths) render a little differently, so compare screens after an upgrade
Skia is not gone: Flutter web's CanvasKit renderer is built on it
Tree shaking is the release compiler dropping every class, function and constant the program can never reach, so unused library code never ships. Flutter does the same to icon fonts, but only for glyphs it can see as compile-time constants.
Ordinary code: the AOT compiler starts from main and keeps only what is reachable; importing a large package costs only what you actually call
Debug-only branches: kDebugMode and kReleaseMode are constants, so if (kDebugMode) blocks disappear from release builds
Icon fonts: icon tree shaking is on by default in release and cuts MaterialIcons from about 1.6 MB to a few KB of glyphs you use
**Non-constant IconData**: an icon built from a code point at runtime makes the build fail with "This application cannot tree shake icons fonts"; use const icons or a constant lookup map
What it cannot remove: assets declared in pubspec.yaml, native SDKs that plugins bring in, and code still reachable through a map or a switch that references everything
The new page's initState, first build, layout and image decoding all run during the frames that animate the transition, so any heavy work there comes straight out of the animation's budget.
**Do not block initState: opening a database, reading a large preferences blob or decoding cached JSON there stalls the frame; start the work and show a placeholder
Decode images ahead**: call precacheImage on the previous screen so the hero image is already in the cache
Simplify the first frame: render a skeleton and fill it in once the transition has finished
Wait for the animation: listen to ModalRoute.of(context)?.animation and start heavy work when its status is completed (read it in didChangeDependencies, add a status listener and remove it in dispose)
Confirm it in the timeline: the dropped frames sit at the start of the push and stop when the transition ends
Jank is a frame that misses its budget (about 16 ms at 60 Hz, 8 ms at 120 Hz), so it is dropped and motion stutters. First find out whether the UI thread or the raster thread is over budget, because the causes and the fixes are different.
UI thread over budget
Too much rebuilt per frame: setState high in the tree, or a whole page watching a provider; fix with const, smaller widgets and select
Heavy synchronous work: JSON decoding, sorting, image processing on the main isolate; move it to Isolate.run
Expensive layout: intrinsic sizing, shrinkWrap lists, non-lazy lists with hundreds of children
Raster thread over budget
**saveLayer effects**: Opacity, ShaderMask, BackdropFilter, Clip.antiAliasWithSaveLayer
Huge images: decoded at full resolution; use cacheWidth or ResizeImage
Large areas repainting every frame: isolate the animated part with RepaintBoundary
The process
Reproduce in --profile on the slowest device you support, pick the worst frame, fix its single biggest cost, then measure again
Lock the result in: a performance test in CI and frame metrics from production
Almost every Flutter leak is a disposable that was never disposed, or a long-lived object (a singleton, a global stream, a static field) still holding a reference to a dead screen's State.
Usual suspects
StreamSubscription, Timer, AnimationController, TextEditingController, ScrollController, FocusNode
A listener added to an app-wide ChangeNotifier and never removed; its closure captures this and keeps the whole State alive
A BuildContext or a GlobalKey stored in a static field or a singleton
How to find one
DevTools Memory: open and close the suspect screen ten times, take a heap snapshot and look for ten instances of its State; the retaining path shows who holds them
**leak_tracker**: turn on LeakTesting in widget tests so a forgotten dispose fails the test
Not every growth is a leak
ImageCache keeps up to 1000 images and 100 MB by design
Decoded bitmaps and video buffers are native memory and never appear in the Dart heap
Get the first frame on screen fast and do everything else after it: keep main() free of network and heavy disk work, show a native splash, and start optional services lazily. First launches are slower because caches, databases and migrations are created from scratch.
Where the time goes
Native launch before the engine exists, then engine and Dart VM start-up, then your main() and first build
flutter run --profile --trace-startup writes the real timings, including time to first frame
What to change
**await in main only what the first screen needs**: often nothing; start the rest after runApp
Defer optional services: analytics, crash reporting, remote config and feature flags start after the first frame or on first use
Show cached data immediately: refresh in the background instead of a spinner waiting on the network
Native splash: flutter_native_splash brands the gap before Flutter draws instead of leaving it blank
Why the first launch is slower
Empty HTTP and image caches, database creation and migrations, first-time plugin setup
On Android, the platform side (embedding and plugins) is not yet optimised by ART; Dart code is AOT-compiled already
Measure before cutting: build with --analyze-size, open the result in DevTools App Size, and attack the biggest buckets (usually assets, native SDKs pulled in by plugins, and debug symbols) while quoting the size the store actually delivers.
Measure the right number: Play Console and App Store Connect show the per-device download size, far below a universal APK or a raw IPA
Diff releases: load two size snapshots in DevTools to see exactly what a release added
Assets: WebP instead of PNG, drop unused resolution variants, move large media and onboarding videos to the network
Split debug info: --split-debug-info with --obfuscate moves symbol tables out of the binary; keep those files to symbolicate crashes
Fonts and icons: subset custom fonts and keep icon tree shaking on
Dependencies: a plugin that bundles a whole native SDK (maps, ML, video) is a frequent surprise; check for a lighter alternative
Deferred components: on Android, deferred as imports can become Play Feature Delivery modules downloaded on demand; iOS has no equivalent
Most of a Flutter app's footprint is not Dart objects. Decoded bitmaps, GPU textures, engine caches and plugin allocations live in native memory, which the Dart heap chart never shows.
Read the right line: in DevTools Memory the Dart heap counts Dart objects only, while RSS is the whole process; the gap between them is where the answer lives
Images dominate: a 4000×3000 photo takes about 48 MB in RAM however small the JPEG is, and ImageCache holds up to 1000 entries and 100 MB app-wide
Decode smaller: cacheWidth/cacheHeight or ResizeImage everywhere, and lower cache limits on screens full of large photos
Look outside Dart: video players, the camera, map tiles and ML models all allocate natively
Use the platform tools: Xcode Instruments on iOS and the Android Studio memory profiler show what DevTools cannot
Why it dies in the background: the OS reclaims memory by process size, so native memory is exactly what gets the app killed
Collect frame timings and startup metrics from release builds on real devices and read percentiles per device class, either from FrameTiming callbacks you report yourself or from a tool such as Firebase Performance Monitoring or Sentry.
**SchedulerBinding.instance.addTimingsCallback**: delivers FrameTiming batches in release builds, with build, raster and total time for each frame
Firebase Performance: app start and network traces out of the box, plus custom traces you add around Flutter flows; its automatic screen traces are per native screen, so a Flutter app shows as one screen; add custom traces per route
Sentry: slow and frozen frames, app start and spans per route through its navigator observer; Crashlytics covers crashes, not speed
Percentiles, not averages: p90 and p99 frame time and the share of janky frames; an average hides jank completely
Keep build and raster apart: they have different fixes
Slice by device model and OS: jank concentrates in the cheapest third of the install base; compare each release against the previous one
Script the interaction as an integration test, run it in profile mode on a fixed physical device in CI, summarise the frame timings and fail the job when a percentile gets worse than the committed baseline by more than an agreed margin.
Record the right slice: IntegrationTestWidgetsFlutterBinding.traceAction records a timeline for exactly the interaction you care about
Summarise it: TimelineSummary.summarize(timeline) writes JSON with average, p90 and p99 build and raster times and the count of frames over budget
Compare with a baseline: fail on a relative regression, say 15% on p90, not on an absolute number, so device noise does not break builds
Pin the hardware: the same physical phone every run; emulator numbers mean nothing
Common mistake: running the benchmark in debug mode, where the numbers are meaningless