Sixteen milliseconds
Your display refreshes sixty times per second. Sixty frames per second means one new frame every 16.67 milliseconds. That is your budget — the total time Flutter has to take everything that changed in your widget tree and turn it into pixels on screen. Miss that budget, even once, and a human eye will notice. The app stutters. The animation hiccups. The scroll feels wrong.
Sixteen milliseconds is not much time. But it's consistent. Predictable. And predictability, it turns out, matters more than raw speed.
For years, Flutter had a specific, well-documented failure mode that broke this predictability. Not a slowdown — something more surprising than that. An app would run at 60fps reliably, then encounter a particular animation for the first time, freeze for anywhere from a few frames to half a second, and then — only then — run that animation perfectly forever after.
One freeze. Then smooth. Then smooth. Then smooth.
It was a specific behavior. It had a specific name. And it required a complete rewrite of Flutter's rendering engine to eliminate.
This post is about that rewrite. But to understand what Impeller is, we need to understand what a GPU actually does — and why it makes that stutter structurally inevitable when you're not careful.
What a GPU actually is
A CPU — the processor running your Dart code, handling your business logic, managing your widget tree — is designed for sequential, general-purpose computation. It has a small number of powerful cores, each capable of complex branching logic, memory access patterns, and arbitrary computation. A modern mobile CPU has 4 to 8 of these cores.
A GPU is a different kind of machine. It has thousands of small, simple cores that can each do only a narrow range of operations. They're not general-purpose; they're specialized for one thing: doing the same calculation across millions of inputs simultaneously.
Rendering a frame means calculating the color of every pixel on screen. A 1080p display has 2,073,600 pixels. A 4K display has over 8 million. Each pixel's color might depend on overlapping transparent layers, shadows, gradients, blur effects, clip boundaries — a complex calculation that needs to happen millions of times, in parallel, sixty times per second. A CPU, powerful as it is, would take seconds to do this sequentially. A GPU does it in milliseconds because it runs the pixel calculation on all those cores simultaneously.
But the GPU can't run just any code. The programs that run on a GPU are called shaders — small, specialized programs written in languages like GLSL or MSL that define how a particular visual operation transforms inputs (geometry, texture samples, time, position) into output colors. Every gradient, every shadow, every blur, every animated effect runs through a shader.
Here is the part that matters: before a shader can run on a GPU, it must be compiled for that specific GPU's hardware. A shader written in GLSL is not machine code. It's a high-level description that must be translated into the specific instruction set of the GPU on the user's device. An Adreno GPU on a Snapdragon chip has a different instruction set from an Apple GPU on an A17. The compilation produces hardware-specific machine code — and that compilation takes time.
The pipeline from widget to pixel
When Flutter renders a frame, it runs through a pipeline across two threads.
The UI thread runs your Dart code: the framework calls build() on dirty widgets, runs layout, walks the semantic tree, and produces a data structure called a display list — a recording of all the drawing operations needed for this frame. Think of it as a script: "draw a rounded rectangle at these coordinates, draw this text at this size and color, apply a blur to this region." The display list doesn't touch the GPU. It's still in Dart/CPU territory.
The Raster thread takes that display list, translates it into GPU commands, and submits them to the graphics API (Metal on iOS, Vulkan or OpenGL ES on Android). The GPU executes those commands and outputs pixels to the display's framebuffer. The screen shows the result.
Both threads have to complete within the 16ms budget. The UI thread and the Raster thread are pipelined — while the Raster thread is processing frame N, the UI thread is already building frame N+1. If either thread takes too long, frames drop.
Shader compilation happens in the Raster thread, during the translation from display list to GPU commands. And that is where the stutter was born.
What Skia was doing
Flutter's original rendering engine was Skia — Google's open-source 2D graphics library, used by Chrome, Android, Firefox, LibreOffice, and dozens of other projects. Skia is genuinely excellent software. It handles an enormous range of 2D operations correctly and efficiently across a wide variety of hardware.
Skia's approach to shaders is the only reasonable one for a general-purpose library: compile them at runtime, on demand, the first time a particular draw operation is needed.
When Skia needed to render a particular combination of effects — a specific clip path applied to a specific gradient with a specific blend mode — it would generate the GLSL shader for that operation and compile it. If the app never used that combination again, the compiled shader sat in an in-memory cache. If the same combination appeared later, the cached version was used. No recompilation.
The problem is that this compilation is not free. On most platforms, it was fast enough that users didn't notice. On iOS, with Apple's transition from OpenGL ES to Metal starting in 2018, it became much slower. Metal's shader compilation model is stricter than OpenGL's — it validates and optimizes shaders more aggressively, which produces better GPU code but takes more time to compile. A shader compilation that took 2ms on OpenGL could take 30ms or more on Metal.
30ms is nearly two missed frames. And it happened on the Raster thread, inside the 16ms budget, with no warning.
Why the band-aid didn't work
The Flutter team's first response to this was SkSL warmup — a mechanism for pre-compiling shaders. You would run your app in a special mode, exercise all its screens and animations, and Flutter would record which shaders were compiled. You'd bundle those recordings with your release build. On app startup, Flutter would pre-compile them before showing the first frame.
It worked. Partially.
It required an explicit manual step that teams often forgot. It only covered shaders that you personally exercised during capture — a user taking a path you didn't test would still hit first-time compilation. The captured shaders were tied to a specific GPU vendor and driver version, so they weren't guaranteed to match the user's device. And because Skia's shader set was open-ended — it could generate new shaders for any arbitrary draw operation — you could never guarantee you'd captured everything.
The deeper problem was structural. Skia could render anything. That generality was the source of its power and the source of the problem. Because you couldn't enumerate ahead of time every shader Skia might ever need, you couldn't pre-compile them all. The best you could do was pre-compile the ones you knew about and hope.
Patching Skia to fix this would have meant fundamentally constraining what Skia could do — undermining the very quality that made it useful as a general-purpose library. The Flutter team eventually reached a conclusion: the only way to properly fix the problem was to build a renderer designed specifically for Flutter's widget system.
Impeller's founding insight
Here is the question that unlocks Impeller: what is the complete set of visual operations that Flutter's widget system can produce?
Gradients — linear, radial, sweep. Shadows — box shadows, text shadows. Clips — rectangular, rounded rectangular, arbitrary paths. Opacity and blend modes. Transforms — translate, rotate, scale, perspective. Text — shaped, styled, measured. Images. Blur filters. Color filters. That's most of it. Flutter's widget system, as rich as it is, produces a bounded, finite set of visual operations.
Skia was designed for arbitrary 2D graphics. Flutter only needs a specific subset. If you build a renderer designed exclusively for that subset, you know ahead of time exactly which shaders it will ever need. If you know which shaders you'll ever need, you can compile them at build time — before the app ever ships — and embed the compiled machine code in the binary.
This is the insight Impeller was built around. Not a better general-purpose renderer. A renderer whose scope is deliberately, architecturally limited to what Flutter actually needs. The constraint is what makes the guarantee possible.
When your Flutter app launches, every shader Impeller will ever need is already compiled. Not cached from a previous run — compiled at build time, sitting in libflutter.so as machine code, ready to execute immediately. The first frame of an animation runs at full speed. The hundredth frame runs at the same speed. There is nothing to compile at runtime.
The compile-time vs runtime thread continues
If you've been reading this series in order, this should feel familiar.
In Post 2 we talked about the developer's instinct: push problems from runtime to compile time. Null safety took a whole category of runtime crashes — NullPointerException — and made them compile-time errors. Sealed classes took "forgetting to handle a case" — a runtime bug — and made it a compile-time error. Every time we move something from runtime to compile time, users benefit because the problem surfaces during development instead of in production.
Impeller is this instinct applied to rendering. Shader compilation was a runtime problem — it happened on the user's device, inside a frame, stealing time from the 16ms budget. Impeller moved it to build time — it happens on your machine, during flutter build, adding a few seconds to your build process. The cost is paid by you, once, during development. The benefit is experienced by every user, every frame, forever.
The same trade as null safety. The same trade as DDD value objects making invalid states unrepresentable at construction time. Move the cost earlier. Make the runtime guarantee.
What Impeller is made of
Impeller is not a thin wrapper over Metal or Vulkan. It's a complete rendering stack:
The display list — Impeller shares this concept with Skia. The UI thread produces a display list recording of drawing commands. This is the handoff point between Dart and the renderer.
The Aiks layer — a Flutter-specific graphics API that sits above the hardware. This is where Flutter's drawing commands (draw image, draw path, draw text) are translated into Impeller primitives. Aiks knows about Flutter's coordinate system, clip stack, transform stack, and layer system.
The Entity system — Impeller's representation of what needs to be drawn. Each entity has a geometry (what shape), a contents object (what it looks like — gradient, image, solid color, text), and a transform. Entities are pure data; they don't know about GPU commands.
The rendering backend — the layer that turns entities into actual GPU commands, dispatching to Metal on iOS, Vulkan on modern Android, or OpenGL ES on older Android. The backend is where the pre-compiled shaders are used.
Each layer has a clear job and a clear boundary. The Flutter team designed Impeller to be testable at each layer — you can test the display list independently of the entity system, and the entity system independently of the GPU backend. This is part of why Impeller's development, despite being a complete rewrite, remained relatively stable across Flutter versions.
Metal on iOS, Vulkan on Android
Impeller's choice of graphics API is not arbitrary.
Metal is Apple's modern, low-level GPU API, available since iOS 8. It replaced OpenGL ES as Apple's recommended path and is the only API with full access to Apple GPU features on current iOS hardware. Metal's compilation model — which caused the jank problem for Skia — is actually an asset for Impeller: Metal's offline compilation produces highly optimized shader code, and since Impeller does this at build time, the optimization happens once rather than on the user's device.
Vulkan is the modern, low-level GPU API on Android, available since Android 7.0 (Nougat). Like Metal, it gives direct access to GPU resources with minimal driver overhead. Impeller uses Vulkan where available (the majority of current Android devices) and falls back to OpenGL ES on older hardware. OpenGL ES's more forgiving compilation model means it doesn't benefit as dramatically from Impeller's pre-compilation, but it's still meaningfully faster than Skia on those devices.
The move to Metal and Vulkan is not just about shader compilation. Both APIs expose a programming model closer to how GPUs actually work — explicit memory management, explicit synchronization, explicit pipeline state objects. This control lets Impeller schedule GPU work more efficiently and avoid stalls that Skia, running over the more abstracted OpenGL model, couldn't avoid.
The fragment shader API: writing your own compile-time shaders
One of the most interesting consequences of Impeller's architecture is that it enables Flutter developers to write their own shaders — and have them compiled at build time, just like Impeller's built-in shaders.
The FragmentShader API (stable since Flutter 3.10) lets you write GLSL and declare it in pubspec.yaml. During the build, Flutter compiles it into the app binary alongside Impeller's own shaders. At runtime, it runs instantly — no compilation latency.
flutter:
shaders:
- assets/shaders/noise.frag// assets/shaders/noise.frag
#include <flutter/runtime_effect.glsl>
uniform float uTime;
uniform vec2 uResolution;
out vec4 fragColor;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2(1,0)), u.x),
mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), u.x),
u.y
);
}
void main() {
vec2 uv = FlutterFragCoord().xy / uResolution;
float n = noise(uv * 4.0 + uTime * 0.3);
fragColor = vec4(vec3(n), 1.0);
}class NoiseBackground extends StatefulWidget {
const NoiseBackground({super.key, required this.child});
final Widget child;
@override
State<NoiseBackground> createState() => _NoiseBackgroundState();
}
class _NoiseBackgroundState extends State<NoiseBackground>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
ui.FragmentShader? _shader;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 10),
)..repeat();
_loadShader();
}
Future<void> _loadShader() async {
final program = await ui.FragmentProgram.fromAsset('assets/shaders/noise.frag');
if (mounted) setState(() => _shader = program.fragmentShader());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _shader == null
? null
: _NoisePainter(_shader!, _controller),
child: widget.child,
);
}
}
class _NoisePainter extends CustomPainter {
final ui.FragmentShader shader;
final Animation<double> animation;
_NoisePainter(this.shader, this.animation) : super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
shader
..setFloat(0, animation.value * 10.0)
..setFloat(1, size.width)
..setFloat(2, size.height);
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..shader = shader,
);
}
@override
bool shouldRepaint(_NoisePainter old) => false; // animation handles repaint
}The first time this noise animation plays on a user's device, it runs at full speed. The shader was compiled when you ran flutter build. You don't cache, you don't warm up, you don't worry about it. The build system handled it.
This is a qualitatively different experience than writing the same effect with Skia's ColorFilter or custom Canvas compositing — not just faster, but architecturally guaranteed to be fast on the first frame.
What changes for you as a developer
For everyday Flutter development — Material widgets, standard animations, navigation transitions — Impeller is invisible. It works without any changes from you and your UI is smoother for it.
Where it becomes relevant is at the edges:
`BackdropFilter` — blur effects and other image filters are expensive on any renderer. Impeller handles them better than Skia on iOS specifically, but the principle still holds: use sparingly, never in scrolling lists, never nested. Each BackdropFilter creates a separate render pass that reads back the framebuffer — the GPU equivalent of interrupting a production line.
`FadeTransition` over `Opacity` for animated opacity — Opacity with a changing value triggers a repaint of its entire subtree each frame. FadeTransition composites a cached layer at the alpha level. For complex subtrees, this difference matters. Impeller doesn't change this trade-off.
Complex `ClipPath` — paths with many bezier curves as clip boundaries are worth profiling under Impeller. Rectangular clips and rounded rectangular clips are fast. Complex path clips compute coverage, which is more expensive and in some cases slower under Impeller than under Skia. Profile before assuming.
Profiling Impeller specifically — in DevTools, the Raster thread shows Impeller RenderPass events. A render pass taking more than 4-5ms deserves investigation. The most common culprit is a saveLayer call (which BackdropFilter, Opacity, and certain blend modes trigger implicitly), which forces the compositor to create and composite an additional layer.
To compare Impeller against Skia for a specific problematic screen:
# Default — Impeller enabled
flutter run --profile
# Explicitly disable Impeller for comparison
flutter run --profile --no-enable-impellerIf your Raster thread is slower on Impeller for a specific operation, you've found something worth reporting — Impeller is still being refined and the team actively investigates regressions.
The "constraint is the feature" principle
There's a design philosophy worth naming explicitly here, because it appears throughout software architecture and it's easy to miss when you're focused on implementation details.
Skia's power was its generality. Its generality made the guarantee impossible.
Impeller gave up generality in exchange for a specific, valuable guarantee: no shader compilation at runtime. Users of general-purpose graphics libraries don't get that guarantee. Impeller gets it precisely because it accepted a constraint.
This principle recurs everywhere:
- Dart's null safety is more restrictive than the previous type system. The restriction is what makes the guarantee ("this value cannot be null here") possible.
- A sealed class is more restrictive than an open class hierarchy. The restriction is what makes exhaustive matching possible.
- DDD value objects are more restrictive than mutable data structures. The restriction (immutability, encapsulated validation) is what makes domain invariants enforceable.
- A pure function with no side effects is more restrictive than a function that can do anything. The restriction is what makes testing, caching, and parallelism reliable.
In each case, someone looked at an open-ended system and asked: what is the actual set of things we need this to do? Then they constrained the system to that set, and gained a guarantee the open-ended version could never provide.
Impeller is this insight at the rendering layer. The widget system defines a bounded vocabulary of visual operations. Build a renderer that speaks only that vocabulary, and you can make promises that a general renderer cannot.
Where Impeller stands today
iOS: Impeller has been the default since Flutter 3.10. Stable, production-ready, no caveats for most apps.
Android: Impeller became the default in Flutter 3.16. Vulkan where available, OpenGL ES fallback. Also stable and production-ready.
macOS: Available but not yet the default. You can opt in with --enable-impeller. The Metal backend is the same codebase as iOS.
Linux and Windows: Under active development. Skia remains the default. Impeller on desktop uses a Vulkan backend on Linux and a Vulkan/DirectX backend on Windows.
Web: Flutter Web has a different rendering story entirely — it uses either an HTML renderer or a WebAssembly-compiled CanvasKit (which is Skia). Impeller is not part of the web story, at least not yet.
For mobile development — which is what this series is primarily about — the transition is complete. If you're building for iOS and Android in 2026, you're building on Impeller.
What this series has built
Take a step back and look at what four posts have traced:
Our article on Flutter Compilation Insights followed your code from Dart source through the frontend compiler, through gen_snapshot's AOT compilation, into libapp.so and libflutter.so, and into the APK on a user's device.
Then we explored the timeline of that journey — what compile time and runtime actually are, what kinds of errors belong to each, and why the best engineering decisions continuously push problems from runtime into compile time.
Then, we showed what the GC can and cannot protect you from — and why dispose() is not about freeing memory but about severing references so the GC can free memory.
This post showed what lives inside libflutter.so — specifically the rendering engine — and why the Flutter team's most consequential performance improvement wasn't a code optimization but an architectural constraint: committing to render only what Flutter's widget system can produce, so that rendering could be fully predictable from build time onward.
The thread running through all four is the same: understanding what a system needs to do, committing to exactly that, and using that commitment to make guarantees that open-ended systems cannot make.
Next, we'll go even further under the hood — across the boundary between Dart and native code entirely. The FFI series starts here.