What Skia Was Doing
Flutter's original renderer was Skia — Google's general-purpose 2D graphics library, the same one used by Chrome, Android's canvas API, and dozens of other projects. Skia is excellent software. It's also designed to handle any possible 2D graphics operation from any possible source, which is a very different problem from "render Flutter's widget system."
GPUs run programs called shaders — small, highly parallel programs that calculate pixel colours. Before a shader can run, it has to be compiled for the specific GPU hardware on the device. Skia compiled these shaders at runtime, the first time it encountered a particular draw operation.
On most platforms this happened fast enough to be invisible. On iOS, with Apple's transition from OpenGL to Metal, shader compilation became significantly slower. The first time Skia needed to render a particular combination of effects — a specific animation, a specific blend mode, a specific clip path — it would pause to compile the shader. That pause was the jank.
The Flutter team tried to address this with SkSL warmup: pre-recording the shaders your app uses during testing and bundling them with the release build. It worked partially. It required a manual capture step that teams often forgot. It only covered shaders you explicitly exercised during capture. And it still didn't fully solve the problem on all devices.
The underlying issue was that Skia's shader set was open-ended. You couldn't know ahead of time every shader your app might need, because Skia could generate new ones for any arbitrary draw operation.
What Impeller Does Differently
Impeller starts from a different premise: instead of a general-purpose renderer that can handle any draw command, build a renderer designed specifically for Flutter's widget system.
Flutter's visual output is not arbitrary. The set of operations the widget system produces — gradients, shadows, clips, opacity, blend modes, transforms, text — is finite and known. Impeller defines a fixed set of shaders that cover every visual operation Flutter can generate. Because the shader set is fixed, it can be compiled at build time, not at runtime.
When your app launches, every shader Impeller will ever need is already compiled for the user's specific GPU. There's nothing to compile at runtime. The first frame of every animation runs exactly as fast as every subsequent frame.
On iOS, Impeller uses Metal — Apple's modern, low-level GPU API. On Android, it uses Vulkan where the device supports it, with an OpenGL ES fallback for older hardware. Both APIs give Impeller direct, efficient access to the GPU without the abstraction overhead that came with Skia on these platforms.
The result is not just the elimination of compilation jank. Impeller's frame times are more consistent overall. The variance between the fastest frame and the slowest frame narrows. The "worst frame" metric — the thing users actually notice — improves dramatically.
What This Means for Your Code
For the majority of Flutter apps, Impeller is transparent. Standard widgets, standard animations, Material and Cupertino components — everything works without any changes. Impeller became the default on iOS in Flutter 3.10 and on Android in Flutter 3.16. If you've shipped a Flutter app recently, you're already using it.
Where you need to pay attention is in three specific areas.
BackdropFilter and Blur Effects
BackdropFilter — Flutter's way of applying blur and other effects to whatever is behind a widget — has always been expensive. It requires creating a separate layer, rendering what's behind it to a texture, applying the filter, and compositing the result. Impeller handles this more efficiently than Skia did, particularly on iOS with Metal.
The practical rules haven't changed: use BackdropFilter sparingly, never inside a ListView.builder item, and never nested:
// ✅ A frosted glass panel — reasonable use of BackdropFilter
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
color: Colors.white.withOpacity(0.2),
padding: const EdgeInsets.all(16),
child: const Text('Frosted Panel'),
),
),
)
// ❌ BackdropFilter in a list — creates a saveLayer per visible item per frame
ListView.builder(
itemBuilder: (context, index) => Stack(
children: [
ItemCard(item: items[index]),
BackdropFilter( // This will hammer the Raster thread
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: const SizedBox.expand(),
),
],
),
)Opacity: FadeTransition vs the Opacity Widget
This is a subtle but important difference. The Opacity widget, when its opacity value changes, triggers a full repaint of its subtree. Impeller optimises this, but FadeTransition is still the better choice for animated opacity — it uses a layer with a paint alpha rather than repainting:
// ❌ Causes a repaint of the subtree on every animation tick
AnimatedBuilder(
animation: _controller,
builder: (context, child) => Opacity(
opacity: _controller.value,
child: const ExpensiveWidget(),
),
)
// ✅ Composites at the layer level — no repaint of the subtree
FadeTransition(
opacity: _controller,
child: const ExpensiveWidget(),
)The difference becomes significant for complex subtrees. FadeTransition tells Impeller's compositor to adjust the alpha of a cached layer. Opacity tells it to rebuild the layer at each opacity value.
Custom Painting and ClipPath
CustomPainter works fine under Impeller, but complex Path operations in clips are worth profiling. Paths with many bezier curves, particularly when used as clip boundaries, can be more expensive than simple rectangular clips. If you have a ClipPath that was fast under Skia and feels slower under Impeller, check the Raster thread in DevTools.
// ✅ Simple, cheap clip
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: child,
)
// Worth profiling if used frequently in scrolling content
ClipPath(
clipper: ComplexWaveClipper(), // Many bezier curves
child: child,
)Custom Shaders: The FragmentShader API
For apps that need visual effects beyond what Flutter's widget system provides — custom transitions, particle effects, water ripples, procedural textures — Flutter exposes a FragmentShader API that works natively with Impeller.
Shaders are written in GLSL (a C-like language that runs on the GPU), declared in pubspec.yaml, and compiled at build time as part of the Impeller pipeline.
A ripple distortion effect, as an example:
// assets/shaders/ripple.frag
#include <flutter/runtime_effect.glsl>
uniform float uTime;
uniform float uAmplitude;
uniform vec2 uCenter;
uniform sampler2D uTexture;
out vec4 fragColor;
void main() {
vec2 fragCoord = FlutterFragCoord().xy;
vec2 texSize = vec2(textureSize(uTexture, 0));
vec2 uv = fragCoord / texSize;
float dist = distance(fragCoord, uCenter);
float ripple = sin(dist * 0.08 - uTime * 4.0) * uAmplitude;
vec2 offset = normalize(fragCoord - uCenter) * ripple * 0.008;
fragColor = texture(uTexture, uv + offset);
}Declare it in pubspec.yaml:
flutter:
shaders:
- assets/shaders/ripple.fragLoad and use it in Dart:
import 'dart:ui' as ui;
class RippleEffect extends StatefulWidget {
final ui.Image image;
const RippleEffect({super.key, required this.image});
@override
State<RippleEffect> createState() => _RippleEffectState();
}
class _RippleEffectState extends State<RippleEffect>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
ui.FragmentShader? _shader;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat();
_loadShader();
}
Future<void> _loadShader() async {
final program = await ui.FragmentProgram.fromAsset(
'assets/shaders/ripple.frag',
);
setState(() => _shader = program.fragmentShader());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_shader == null) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
painter: _RipplePainter(
shader: _shader!,
image: widget.image,
time: _controller.value * 10.0,
),
);
},
);
}
}
class _RipplePainter extends CustomPainter {
final ui.FragmentShader shader;
final ui.Image image;
final double time;
const _RipplePainter({
required this.shader,
required this.image,
required this.time,
});
@override
void paint(Canvas canvas, Size size) {
shader.setFloat(0, time);
shader.setFloat(1, 4.0); // amplitude
shader.setFloat(2, size.width / 2); // center x
shader.setFloat(3, size.height / 2); // center y
shader.setImageSampler(0, image);
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..shader = shader,
);
}
@override
bool shouldRepaint(_RipplePainter old) => old.time != time;
}Because Impeller compiles these shaders at build time, the first frame of this ripple animation runs exactly as fast as the hundredth. There's no compilation stutter when the effect first appears.
Profiling Under Impeller
The profiling workflow from the Performance Profiling article applies here, with one addition: compare profiles with and without Impeller when diagnosing Raster thread slowness.
Run with Impeller (default):
flutter run --profileRun without Impeller (for comparison):
flutter run --profile --no-enable-impellerIf the Raster thread is slower with Impeller on a specific operation, it's likely one of the expensive categories — complex ClipPath, deeply nested BackdropFilter, or a custom shader that's doing more work than necessary.
In DevTools, the Raster thread timeline under Impeller shows different event names than under Skia. Look for Impeller RenderPass events and their sub-events. A single RenderPass that takes 8ms is a signal to investigate what's in that pass — usually a saveLayer or a complex clip.
To verify which renderer is active at runtime:
import 'package:flutter/rendering.dart';
// Logs to console — useful during development
debugDumpRenderTree(); // Shows renderer info in the tree
// Or check via service extensions in DevTools
// DevTools → Flutter Inspector → shows renderer in the headerWhat's Still in Progress
Impeller on mobile is stable and production-ready. Desktop support is in progress:
- macOS: Impeller is available but not yet the default. Opt in with
--enable-impeller. - Linux/Windows: Under active development. Skia remains the default.
- Web: Flutter Web uses a different rendering pipeline (HTML renderer and CanvasKit). Impeller is not involved here.
For mobile-first agencies, this timeline is largely irrelevant — the platforms that matter most (iOS and Android) are fully covered.
The Bigger Picture
Impeller represents something worth understanding beyond its practical implications: the decision to constrain the renderer's scope in order to make a specific guarantee.
Skia could render anything. That generality made it impossible to know ahead of time what shaders it would need. Impeller renders exactly what Flutter's widget system can produce — no more. That constraint is what makes AOT shader compilation possible. The constraint is the feature.
This pattern appears throughout good software design. BLoC's strict event/state contract is a constraint that makes state traceable. Clean Architecture's dependency rule is a constraint that makes the domain testable. Impeller's fixed shader set is a constraint that makes rendering predictable.
The best architectural decisions are often the ones that give something up in exchange for a guarantee. In Impeller's case: give up the ability to render arbitrary 2D operations, gain the guarantee that no user will ever see shader compilation jank.
For users, that trade is invisible. They just notice that the app feels smooth. Which is exactly how good architecture should feel.