The "first-run jank" in Flutter applications—where animations stutter significantly the first time they are rendered—has historically been the framework's most persistent performance bottleneck. This phenomenon erodes user trust and makes otherwise native-feeling applications feel unmistakably cross-platform in the worst way.
While pre-warming the Skia shader cache (flutter drive --profile --cache-sksl) provided a stopgap, it was a fragile workflow that rarely achieved 100% coverage.
The definitive solution is Impeller, Flutter’s new rendering engine. Impeller replaces Skia with a custom runtime that fundamentally changes how the GPU interacts with your rendering layer.
The Root Cause: Just-in-Time Shader Compilation
To understand why Impeller fixes jank, you must understand why Skia produced it.
In the legacy Skia backend (running on Metal or OpenGL), shaders were generated Just-in-Time (JIT).
- The Flutter framework builds a Layer Tree describing the UI.
- Skia traverses this tree and issues draw commands.
- The GPU driver receives a command requiring a specific combination of visual effects (e.g., a rounded rectangle with a blur, clipped by a circle, at 50% opacity).
- If the driver hasn't seen this specific combination before, it pauses the raster thread to compile a new shader program for the GPU.
- The Pause: Shader compilation can take 100ms–200ms. The frame deadline is 16ms (60fps) or 8ms (120fps).
- The Result: The UI freezes until compilation finishes.
Impeller solves this by moving shader compilation to Ahead-of-Time (AOT). The engine contains a pre-compiled set of smaller, simpler shaders that are combined at runtime without triggering driver-side compilation.
Migration Guide and Implementation
Impeller is the default rendering engine on iOS as of Flutter 3.10. On Android, it is available behind a flag (targeting Vulkan) as of recent stable releases.
1. Verifying Impeller on iOS
If you are on Flutter 3.10+ targeting iOS, Impeller is active by default. However, you must verify it is actually running and not falling back to Skia due to configuration errors.
Run your app and check the logs:
flutter run -d <device_id>
Look for the following line in the console output upon startup:
[Impeller] (Info) : Using the Impeller rendering backend.
If you see usage of the Skia backend, ensure your Info.plist does not contain the disable flag:
<!-- ios/Runner/Info.plist -->
<!-- REMOVE THIS IF PRESENT -->
<key>FLTEnableImpeller</key>
<false/>
2. Enabling Impeller on Android
As of Flutter 3.19/3.22, Impeller on Android utilizes the Vulkan backend. It requires explicit opt-in in your manifest.
Open android/app/src/main/AndroidManifest.xml and add the following metadata within the <application> tag:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.your_app">
<application
android:label="your_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<!-- ENABLE IMPELLER ON ANDROID -->
<meta-data
android:name="io.flutter.embedding.android.ImpellerEnabled"
android:value="true" />
<activity ... >
<!-- Activity Config -->
</activity>
</application>
</manifest>
Hardware Constraint: Impeller on Android requires devices that support Vulkan 1.1+. If a device does not support Vulkan, Flutter will silently fall back to Skia (OpenGL ES).
3. Migrating Custom Shaders (The Breaking Change)
If your application uses FragmentProgram for custom shaders, migration requires strict adherence to GLSL 4.6 standards and specific uniform handling. Skia was forgiving; Impeller is not.
Impeller uses SPIR-V transpilation. You must ensure your .frag files match the expected input format for the Impeller compiler.
A. The Shader Code (shaders/pixelate.frag)
Create a strictly typed GLSL shader. Note the explicit inclusion of flutter/runtime_effect.glsl.
#include <flutter/runtime_effect.glsl>
uniform vec2 uSize; // The size of the canvas
uniform float uPixelSize; // The size of the "pixels"
out vec4 fragColor;
void main() {
vec2 pos = FlutterFragCoord().xy;
// Calculate the nearest pixelated coordinate
float dx = uPixelSize * (1.0 / uSize.x);
float dy = uPixelSize * (1.0 / uSize.y);
vec2 coord = vec2(
dx * floor(pos.x / dx),
dy * floor(pos.y / dy)
);
// Sample the texture (assuming standard Flutter pipeline input)
// Note: In custom FragmentShaders, usually you operate on input colors
// or noise. If sampling a child widget, strictly use Flutter's sampler.
// For this example, we generate a color based on coord to prove the shader works
fragColor = vec4(coord.x / uSize.x, coord.y / uSize.y, 0.5, 1.0);
}
B. The pubspec.yaml Configuration
Register the shader.
flutter:
shaders:
- shaders/pixelate.frag
C. The Dart Implementation
Load the shader asynchronously and pass uniforms.
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
class PixelateShaderWidget extends StatefulWidget {
const PixelateShaderWidget({super.key});
@override
State<PixelateShaderWidget> createState() => _PixelateShaderWidgetState();
}
class _PixelateShaderWidgetState extends State<PixelateShaderWidget>
with SingleTickerProviderStateMixin {
ui.FragmentProgram? _program;
late Ticker _ticker;
double _pixelSize = 10.0;
double _time = 0.0;
@override
void initState() {
super.initState();
_loadShader();
_ticker = createTicker((elapsed) {
setState(() {
_time = elapsed.inMilliseconds / 1000.0;
// Animate pixel size slightly
_pixelSize = 10.0 + (5.0 * (0.5 + 0.5 * (1.0 + (1.0))));
});
});
_ticker.start();
}
Future<void> _loadShader() async {
try {
// Compiles the SPIR-V binary at runtime start
final program = await ui.FragmentProgram.fromAsset('shaders/pixelate.frag');
setState(() {
_program = program;
});
} catch (e) {
debugPrint('Shader compilation error: $e');
}
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_program == null) {
return const Center(child: CircularProgressIndicator());
}
return CustomPaint(
size: const Size(300, 300),
painter: ShaderPainter(
shaderProgram: _program!,
pixelSize: _pixelSize,
),
);
}
}
class ShaderPainter extends CustomPainter {
final ui.FragmentProgram shaderProgram;
final double pixelSize;
ShaderPainter({required this.shaderProgram, required this.pixelSize});
@override
void paint(Canvas canvas, Size size) {
// Create the shader from the program
// Uniforms must match the order in the .frag file exactly
final shader = shaderProgram.fragmentShader();
// uniform vec2 uSize;
shader.setFloat(0, size.width);
shader.setFloat(1, size.height);
// uniform float uPixelSize;
shader.setFloat(2, pixelSize);
final paint = Paint()..shader = shader;
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
}
@override
bool shouldRepaint(covariant ShaderPainter oldDelegate) {
return oldDelegate.pixelSize != pixelSize;
}
}
Technical Explanation: Why This Works
Moving to Impeller shifts the burden of complexity from the GPU driver to the Flutter Engine.
- Uniform Buffers: Impeller relies heavily on Uniform Buffer Objects (UBOs). Instead of recompiling a shader when you change opacity, Impeller uses a static shader and passes the opacity as a uniform variable in a memory buffer. This is computationally practically free compared to compilation.
- Tessellation: In Skia, complex paths often required distinct shaders for anti-aliasing. Impeller uses a modern tessellation approach (generating triangles from curves) that allows standard vertex shaders to handle complex geometries without unique fragment shaders.
- Vulkan/Metal Direct Access: Skia was an abstraction layer over OpenGL/Metal. Impeller talks directly to Metal (iOS) and Vulkan (Android). This reduces driver overhead and allows explicit control over command buffers, memory barriers, and render pass attachments.
Conclusion
The era of "Flutter Jank" is effectively over with Impeller, provided you configure your environment correctly. While iOS is largely "set and forget," Android requires explicit opt-in via the manifest and relies on Vulkan support.
For developers relying on custom FragmentProgram implementations, strict typing and SPIR-V compliance are now mandatory. The trade-off—eliminating runtime shader compilation—results in a consistently smooth 60/120fps experience from the very first frame.