Most performance tickets we inherit on Flutter engagements sound the same: "it's smooth on the developer's Pixel, it stutters on the client's three-year-old Android." For years the usual culprit was shader compilation jank, and the usual fix was --purge-persistent-cache plus SkSL warm-up. That advice is now out of date. Impeller — Flutter's rendering runtime — is the default on iOS and Android, Skia's runtime shader compilation is no longer in the hot path on those platforms, and the shape of a performance problem has changed with it.
This tutorial is about what changed, what did not, and how to prove your app is fast on hardware you don't own.
What Impeller actually is
Skia is a general-purpose 2D graphics library. It compiles shaders at runtime, on demand, the first time a particular kind of draw happens. That is exactly why the first run of an animation used to stutter and every run after it was fine.
Impeller takes the opposite approach. Its shader set is fixed and known ahead of time, so shaders are compiled when the engine is built, not when your user opens a screen. It talks to Metal on iOS and Vulkan on Android (with an OpenGL ES backend for devices that lack a usable Vulkan driver).
The practical consequences:
- First-run animation jank from shader compilation is largely gone. If you still carry
flutter build --bundle-sksl-path, SkSL warm-up JSON files, or a "warm the shaders on splash" widget tree, they are dead weight. Delete them. - Frame cost is more uniform. The first frame of a transition costs roughly what the hundredth costs, which makes profiling honest.
- Some effects got more expensive, some got cheaper. Blur, in particular, has different characteristics than it did under Skia. Measure; do not assume your old numbers hold.
- A new failure mode exists: driver-level differences between Vulkan and GLES backends on cheap Android hardware.
Check what your app is actually running on a given device rather than guessing:
import 'dart:ui' as ui;
void logRenderer() {
// Available since Flutter 3.22. False means the Skia/GLES path.
debugPrint('Impeller enabled: ${const bool.fromEnvironment('dart.vm.product')
? 'release' : 'debug'} / ${ui.window.toString()}');
}
More reliably, read the startup logs: the engine prints the backend it selected (Using the Impeller rendering backend (Vulkan) or (OpenGLES)) on Android, and DevTools' Flutter Framework info panel reports the renderer. Log that string into your crash reporter as a custom key — when a jank report comes in from the field, the first question is always "Vulkan or GLES?"
Step 1: stop measuring on your laptop's simulator
Nothing in this article matters if you profile in debug mode. Debug builds run Dart in the JIT with assertions on and can be an order of magnitude slower than release. Profile mode is the only honest option that still exposes timeline data:
flutter run --profile -d <device-id>
Simulators and emulators are also disqualified for renderer work: the iOS Simulator does not use a real Metal driver path the way a device does, and Android emulators use host GPU translation. Every number below assumes a physical device in profile mode.
Step 2: read frame times, not frames per second
"60fps" is a comforting average that hides the two-frame hitch users actually notice. What you want is the distribution of build and raster times.
import 'package:flutter/scheduler.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
for (final t in timings) {
final build = t.buildDuration.inMicroseconds / 1000.0;
final raster = t.rasterDuration.inMicroseconds / 1000.0;
if (build + raster > 16.0) {
debugPrint('SLOW FRAME build=${build.toStringAsFixed(1)}ms '
'raster=${raster.toStringAsFixed(1)}ms');
}
}
});
runApp(const MyApp());
}
That callback is safe to keep in profile builds and is the cheapest possible early-warning system. The split matters because it tells you which team owns the bug:
- High
buildDuration— your Dart code. Too much work inbuild(), rebuilding subtrees that didn't change, synchronous JSON decoding on the UI thread. This is a state-management and widget problem, not a renderer problem. - High
rasterDuration— the GPU/raster thread. Blurs, saveLayers, opacity over large subtrees, huge images, clip anti-aliasing, custom shaders. This is where Impeller behaviour is relevant.
On a 120Hz display the budget is 8.3ms per frame, not 16.7ms. Modern flagship Androids and iPhone Pro models will happily run your app at 120Hz and expose half-budget problems you never saw on a 60Hz test device.
Step 3: the raster-thread offenders, ranked
In the audits we run, the same handful of constructs account for most raster time.
Blur (BackdropFilter, ImageFiltered)
Frosted-glass headers and modal scrims are the single most common cause of raster spikes. Impeller has optimised blur substantially, but a full-screen BackdropFilter still forces the compositor to read back and process the pixels beneath it every frame.
Rules that keep blur affordable:
// Bad: blurs the entire screen behind an app bar on every frame.
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: SizedBox.expand(child: header),
)
// Better: clip the blur to exactly the region that needs it.
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: SizedBox(height: 96, child: header),
),
)
Always wrap a BackdropFilter in a ClipRect (or ClipRRect) that bounds it. Without a clip, the filter's region is unbounded and you pay for far more pixels than you can see. And do not animate sigma if you can animate opacity of a pre-blurred layer instead.
Opacity and implicit saveLayer
Opacity over a subtree with multiple children allocates an offscreen layer. AnimatedOpacity does it once per frame for the length of the animation.
// Bad: offscreen layer per frame.
Opacity(opacity: t, child: complexSubtree)
// Good: no layer for a single painting child.
FadeTransition(opacity: animation, child: complexSubtree)
// Good: image-specific fast path.
Image.asset('x.png', opacity: animation)
FadeTransition still uses a layer in the general case, but it avoids rebuilding the subtree, and for a single render object Flutter can often apply alpha directly. For text and icons, animating Color.withOpacity on the style is cheaper than any layer at all.
Large images decoded at full resolution
A 4000×3000 JPEG rendered into a 120×90 thumbnail costs you ~48MB of GPU memory and a lot of upload bandwidth. cacheWidth/cacheHeight decode at the size you actually need:
Image.network(
url,
cacheWidth: (120 * MediaQuery.devicePixelRatioOf(context)).round(),
fit: BoxFit.cover,
)
This is renderer-agnostic advice, but it bites harder on Impeller/Vulkan devices with modest memory bandwidth.
Anti-aliased clips in scrolling lists
Clip.antiAliasWithSaveLayer is the most expensive clip behaviour and it is easy to select by accident on Card, ClipRRect and Container with a borderRadius. In a list that scrolls, prefer Clip.antiAlias (the default for most widgets) or, better, avoid clipping entirely by using a DecoratedBox with a rounded border and letting the child paint inside it.
Step 4: custom fragment shaders
FragmentProgram is fully supported under Impeller, but the compilation model is stricter. Shaders are written in GLSL, compiled by impellerc at build time via the shaders: section of your pubspec, and must avoid constructs Impeller's translator rejects (notably unbounded loops and some texture-sampling patterns).
flutter:
shaders:
- shaders/gradient_sweep.frag
#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uSize;
uniform float uTime;
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uSize;
float wave = 0.5 + 0.5 * sin(uv.x * 6.2831 + uTime);
fragColor = vec4(uv.x, wave, 1.0 - uv.y, 1.0);
}
class SweepPainter extends CustomPainter {
SweepPainter(this.program, this.time);
final ui.FragmentProgram program;
final double time;
@override
void paint(Canvas canvas, Size size) {
final shader = program.fragmentShader()
..setFloat(0, size.width)
..setFloat(1, size.height)
..setFloat(2, time);
canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
}
@override
bool shouldRepaint(SweepPainter old) => old.time != time;
}
Two things to watch. First, uniform indices are positional and flat: a vec2 consumes indices 0 and 1. Getting the offsets wrong produces garbage output rather than an error. Second, a shader that samples an image needs setImageSampler, and sampling large textures per pixel is exactly as expensive as it sounds — keep the sampled image small.
If you are porting shaders from a Skia-era codebase, compile them early. flutter build apk --debug will fail loudly at impellerc time rather than at runtime, which is the good outcome.
Step 5: the Android backend matrix
This is the part teams under-test. On Android, Impeller runs on Vulkan where the driver is adequate and falls back to OpenGL ES otherwise. Old Mali and Adreno drivers on budget devices are where visual and performance differences show up.
A minimum device matrix for any consumer app we ship:
| Class | Example | What it catches |
|---|---|---|
| Current flagship, 120Hz | Pixel 9 / Galaxy S24 | Half-budget (8.3ms) frame problems |
| Mid-range, 3–4 years old | Pixel 6a / Galaxy A54 | Realistic raster ceiling |
| Budget, GLES fallback | Entry Android 11–12 device | Backend-specific rendering bugs |
| iPhone, oldest supported | iPhone SE / iPhone 12 | Metal path, thermal throttling |
Run the same scripted scenario on each — cold start, the heaviest scrolling list, the heaviest animated transition — and capture timings automatically rather than by eye:
flutter drive \
--profile \
--driver=test_driver/perf_test.dart \
--target=test_driver/scroll_perf.dart \
-d <device-id>
// test_driver/scroll_perf.dart (integration_test)
testWidgets('feed scroll', (tester) async {
await binding.watchPerformance(() async {
await tester.fling(find.byType(ListView), const Offset(0, -1200), 4000);
await tester.pumpAndSettle();
}, reportKey: 'feed_scroll_timeline');
});
watchPerformance writes a timeline summary containing average, 90th and 99th percentile build and raster times. Commit those numbers as a baseline and fail CI when the 90th percentile regresses by more than a set margin — a percentage threshold is more durable than an absolute one across device classes.
Step 6: what to do when a device is genuinely broken
Occasionally you will hit a device whose Vulkan driver misrenders something. Before you spend a week on it:
- Reproduce on the same model with a clean build and the latest stable Flutter. Engine fixes for driver quirks land regularly.
- Capture the startup log line naming the backend, the GPU model, and the Android build number.
- Try the app with Impeller disabled to confirm the renderer is implicated. On Android this is an
AndroidManifest.xmlmeta-data flag (io.flutter.embedding.android.EnableImpellerset tofalse); on iOS it isFLTEnableImpellerinInfo.plist.
Treat that flag strictly as a diagnostic, not a shipping strategy. The Skia fallback path is on its way out of the engine, so an app that depends on it is accruing a migration debt with a deadline. If step 3 confirms a renderer bug, the productive move is a minimal reproduction filed upstream plus a temporary workaround in your own drawing code (for example, replacing a problematic blur with a pre-rendered asset on affected devices).
A checklist you can hand to a team
- Profile mode, physical devices, never the simulator.
addTimingsCallbackwired up in profile builds, logging frames over budget.- Every
BackdropFilterbounded by aClipRect. - No
Opacitywrapping animated subtrees; useFadeTransitionor colour alpha. cacheWidth/cacheHeighton every remote image rendered smaller than its source.- No
Clip.antiAliasWithSaveLayerinside scrollables. - All SkSL warm-up files and
--bundle-sksl-pathflags removed from build scripts. - Renderer backend recorded as a crash-reporter key.
- A four-device matrix with a scripted
flutter drivescenario and committed baselines. - 90th-percentile raster time tracked in CI, not just eyeballed.
Where this usually lands
The honest summary of the Impeller transition is that it removed the single most notorious class of Flutter jank — first-run shader compilation — and replaced it with a smaller, more ordinary set of problems: too much work in build(), unbounded blurs, oversized textures, and one or two odd Android drivers. Those are all measurable, and measurable problems get fixed.
If your app is stuttering on hardware you cannot reproduce locally, or you want a frame-time baseline wired into CI before your next release, our Flutter consultants do this as a scoped engagement — a device-matrix audit, a prioritised list with measured costs, and the fixes implemented alongside your team. Get in touch and tell us which devices are complaining.