← Back to Blog

Impeller vs. Skia in 2026: Why Your Flutter App Still Has Shader Jank

January 15, 202610 Minutes Read
flutterperformanceimpellerskiarenderingshader jankoptimizationmobile developmentapp developmentgraphics

Impeller vs. Skia in 2026: Why Your Flutter App Still Has Shader Jank

You've built a beautiful Flutter app, but users report stuttering and jank, especially on the first run. Animations aren't smooth, scrolling feels laggy, and you're seeing frame drops. You thought Impeller would fix everything, but the jank persists.

The truth is: Shader compilation jank is still a problem in 2026, even with Impeller. Understanding the difference between Impeller and Skia, and knowing when shader jank occurs, is crucial for building performant Flutter apps.

Quick Solution: Enable Impeller for better performance, use --enable-impeller flag, pre-warm shaders with ShaderWarmUp, avoid complex shaders on first frame, and profile with flutter run --profile to identify jank sources. For production, consider shader precompilation and warm-up strategies.

This guide explains Impeller vs. Skia, why shader jank still happens, and provides practical solutions to achieve smooth 60fps performance.


What Are Skia and Impeller?

Skia (The Old Engine)

Skia is the rendering engine Flutter used from its inception until 2023. It's a mature, cross-platform 2D graphics library used by Chrome, Android, and many other projects.

How Skia Works:

  • Compiles shaders at runtime (when first needed)
  • Uses OpenGL/Metal/Vulkan APIs
  • Shader compilation happens on the first frame that needs it
  • This causes first-frame jank - the app freezes while compiling

Problems with Skia:

  • Shader compilation jank on first use
  • Runtime compilation overhead
  • Platform-specific shader code generation
  • Difficult to precompile shaders

Impeller (The New Engine)

Impeller is Flutter's new rendering engine, introduced in 2023 and made default in Flutter 3.10+. It's built specifically for Flutter and uses Metal on iOS and Vulkan on Android.

How Impeller Works:

  • Precompiles shaders at build time (AOT compilation)
  • Uses Metal Shading Language (MSL) on iOS
  • Uses SPIR-V on Android
  • Shaders are ready before runtime

Advantages of Impeller:

  • No first-frame shader compilation
  • Better performance on modern devices
  • More predictable frame times
  • Optimized for Flutter's rendering pipeline

Why Shader Jank Still Happens in 2026

Even with Impeller, you can still experience jank. Here's why:

1. Impeller Isn't Enabled by Default (Yet)

While Impeller is the default in newer Flutter versions, older apps or custom builds might still use Skia.

Check if Impeller is enabled:

import 'dart:io';

void checkRenderer() {
  if (Platform.isIOS) {
    // Impeller uses Metal, Skia uses OpenGL
    // Check in Xcode or use Flutter Inspector
  }
}

Enable Impeller explicitly:

# Run with Impeller
flutter run --enable-impeller

# Or in your app
flutter build ios --enable-impeller
flutter build android --enable-impeller

2. Complex Shaders Still Compile at Runtime

Even with Impeller, very complex shaders or dynamic shader generation can cause jank:

// ❌ Problematic - Complex shader on first frame
CustomPaint(
  painter: ComplexGradientPainter(), // Compiles shader on first paint
  child: Container(),
)

3. Platform-Specific Issues

iOS: Impeller works great, but older devices might struggle.

Android: Vulkan support varies by device and Android version.

Web: Still uses Skia (Impeller not available for web yet).

4. Widget Rebuilds Triggering Shader Compilation

Frequent rebuilds can cause shader recompilation:

// ❌ Problematic - Rebuilds trigger shader compilation
setState(() {
  // This might trigger shader recompilation
});

Understanding Shader Compilation

What Are Shaders?

Shaders are small programs that run on the GPU to render graphics. They handle:

  • Vertex transformations
  • Fragment (pixel) coloring
  • Textures and gradients
  • Complex visual effects

Why Shader Compilation Causes Jank

When a shader is first used:

  1. Shader code is compiled from high-level language to GPU machine code
  2. This happens on the main thread (or causes main thread blocking)
  3. Frame rendering pauses while compilation happens
  4. User sees a stutter or dropped frame

Timeline of jank:

Frame 1: Render starts → Need shader → Compile shader (50ms) → Render completes
         [User sees: STUTTER]
Frame 2: Render starts → Shader ready → Render completes (16ms)
         [User sees: SMOOTH]

Solution 1: Enable and Verify Impeller

For iOS

In Xcode:

  1. Open ios/Runner.xcworkspace
  2. Go to Build Settings
  3. Search for "Impeller"
  4. Ensure "Enable Impeller" is set to Yes

In code (iOS 13+):

// Info.plist
<key>FLTEnableImpeller</key>
<true/>

Command line:

flutter build ios --enable-impeller

For Android

In android/app/build.gradle:

android {
    defaultConfig {
        // Enable Impeller
        ndk {
            abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
        }
    }
}

Command line:

flutter build apk --enable-impeller
flutter build appbundle --enable-impeller

Verify Impeller is Running

Check logs:

flutter run --verbose
# Look for: "Using Impeller"

In Flutter DevTools:

  1. Open DevTools
  2. Go to Performance tab
  3. Check renderer information

Solution 2: Pre-warm Shaders

Even with Impeller, pre-warming helps ensure smooth first frames.

Using ShaderWarmUp

import 'package:flutter/scheduler.dart';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ShaderWarmUpWidget(
        child: HomePage(),
      ),
    );
  }
}

class ShaderWarmUpWidget extends StatefulWidget {
  final Widget child;

  ShaderWarmUpWidget({required this.child});

  @override
  _ShaderWarmUpWidgetState createState() => _ShaderWarmUpWidgetState();
}

class _ShaderWarmUpWidgetState extends State<ShaderWarmUpWidget> {
  bool _shadersWarmed = false;

  @override
  void initState() {
    super.initState();
    _warmUpShaders();
  }

  void _warmUpShaders() {
    // Warm up common shaders
    SchedulerBinding.instance.addPostFrameCallback((_) {
      // Render off-screen to compile shaders
      final renderObject = context.findRenderObject();
      if (renderObject != null) {
        // Force a frame to compile shaders
        setState(() {
          _shadersWarmed = true;
        });
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!_shadersWarmed) {
      return Container(
        color: Colors.white,
        child: Center(child: CircularProgressIndicator()),
      );
    }
    return widget.child;
  }
}

Pre-warm Specific Shaders

class ShaderWarmUp {
  static void warmUpCommonShaders(BuildContext context) {
    // Warm up gradient shaders
    final gradient = LinearGradient(
      colors: [Colors.blue, Colors.purple],
    );
    
    // Warm up shadow shaders
    final shadow = BoxShadow(
      color: Colors.black.withOpacity(0.3),
      blurRadius: 10,
    );
    
    // Warm up by rendering off-screen
    final renderObject = context.findRenderObject();
    if (renderObject is RenderBox) {
      // Trigger shader compilation
      renderObject.paint(
        PaintingContext(ContainerLayer(), Rect.zero),
        Offset.zero,
      );
    }
  }
}

Solution 3: Optimize Shader Usage

Avoid Complex Shaders on First Frame

// ❌ Bad - Complex shader on first frame
class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: VeryComplexPainter(), // Compiles on first frame
    );
  }
}

// ✅ Good - Defer complex shaders
class MyWidget extends StatefulWidget {
  @override
  _MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  bool _ready = false;

  @override
  void initState() {
    super.initState();
    // Defer complex shader until after first frame
    WidgetsBinding.instance.addPostFrameCallback((_) {
      setState(() {
        _ready = true;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!_ready) {
      return SimplePlaceholder();
    }
    return CustomPaint(
      painter: VeryComplexPainter(),
    );
  }
}

Cache Shader Objects

class ShaderCache {
  static final Map<String, Shader> _cache = {};

  static Shader? getShader(String key) {
    return _cache[key];
  }

  static void cacheShader(String key, Shader shader) {
    _cache[key] = shader;
  }

  static void clearCache() {
    _cache.clear();
  }
}

// Usage
class CachedGradientPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    const key = 'gradient_${size.width}_${size.height}';
    var shader = ShaderCache.getShader(key);
    
    if (shader == null) {
      shader = LinearGradient(
        colors: [Colors.blue, Colors.purple],
      ).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
      ShaderCache.cacheShader(key, shader);
    }
    
    final paint = Paint()..shader = shader;
    canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
  }

  @override
  bool shouldRepaint(CachedGradientPainter oldDelegate) => false;
}

Solution 4: Profile and Identify Jank

Using Flutter DevTools

  1. Run in profile mode:

    flutter run --profile
    
  2. Open DevTools:

    flutter pub global activate devtools
    flutter pub global run devtools
    
  3. Check Performance tab:

    • Look for frame drops
    • Identify shader compilation spikes
    • Check render times

Using Performance Overlay

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      showPerformanceOverlay: true, // Enable performance overlay
      home: HomePage(),
    );
  }
}

What to look for:

  • Green bars: Good frame time (< 16ms)
  • Red bars: Frame took too long (> 16ms)
  • Yellow bars: Warning (approaching 16ms)

Using Timeline

import 'package:flutter/scheduler.dart';

void profileFrame() {
  SchedulerBinding.instance.addTimingsCallback((timings) {
    for (final timing in timings) {
      if (timing.totalSpan.inMilliseconds > 16) {
        print('Slow frame: ${timing.totalSpan.inMilliseconds}ms');
        print('Build: ${timing.buildDuration.inMilliseconds}ms');
        print('Raster: ${timing.rasterDuration.inMilliseconds}ms');
      }
    }
  });
}

Solution 5: Optimize Widget Rebuilds

Frequent rebuilds can trigger unnecessary shader recompilation.

Use const Constructors

// ✅ Good - Const widget, no rebuild
const Text('Hello')

// ❌ Bad - Rebuilds every frame
Text('Hello')

Minimize setState Calls

// ❌ Bad - Rebuilds entire tree
setState(() {
  _counter++;
});

// ✅ Good - Only rebuilds what changed
ValueNotifier<int> counter = ValueNotifier(0);

// In widget
ValueListenableBuilder<int>(
  valueListenable: counter,
  builder: (context, value, child) {
    return Text('$value');
  },
)

Use RepaintBoundary

// Isolate expensive paints
RepaintBoundary(
  child: ExpensiveWidget(),
)

Solution 6: Platform-Specific Optimizations

iOS Optimizations

Use Metal Performance Shaders (if available):

// In AppDelegate or custom renderer
// Leverage Metal for better performance

Optimize for different iOS versions:

if (Platform.isIOS) {
  final version = Theme.of(context).platform;
  // Adjust rendering based on iOS version
}

Android Optimizations

Check Vulkan support:

import 'dart:io';

Future<bool> hasVulkanSupport() async {
  if (!Platform.isAndroid) return false;
  // Check device capabilities
  // Impeller uses Vulkan on Android
  return true; // Assume supported on modern devices
}

Optimize for different Android versions:

if (Platform.isAndroid) {
  // Use Impeller for Android 7.0+ (Vulkan support)
  // Fall back to Skia for older versions
}

Impeller vs. Skia: When to Use What

Use Impeller When:

  • ✅ Building new apps (default in Flutter 3.10+)
  • ✅ Targeting iOS 13+ or Android 7.0+
  • ✅ Need best performance
  • ✅ Want predictable frame times
  • ✅ Building for production

Use Skia When:

  • ⚠️ Supporting very old devices
  • ⚠️ Web platform (Impeller not available)
  • ⚠️ Legacy codebase not yet migrated
  • ⚠️ Need specific Skia features

Migration Strategy

For existing apps:

  1. Enable Impeller in development
  2. Test thoroughly on target devices
  3. Profile performance differences
  4. Fix any Impeller-specific issues
  5. Enable in production

Real-World Example: Optimizing a Complex App

class OptimizedApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // Enable Impeller optimizations
      builder: (context, child) {
        // Pre-warm shaders
        WidgetsBinding.instance.addPostFrameCallback((_) {
          _warmUpShaders(context);
        });
        return child!;
      },
      home: HomePage(),
    );
  }

  void _warmUpShaders(BuildContext context) {
    // Warm up common shaders used throughout app
    final commonShaders = [
      LinearGradient(colors: [Colors.blue, Colors.purple]),
      RadialGradient(colors: [Colors.red, Colors.orange]),
      BoxShadow(color: Colors.black, blurRadius: 10),
    ];
    
    // Render off-screen to compile
    for (final shader in commonShaders) {
      // Trigger compilation
    }
  }
}

class OptimizedHomePage extends StatefulWidget {
  @override
  _OptimizedHomePageState createState() => _OptimizedHomePageState();
}

class _OptimizedHomePageState extends State<OptimizedHomePage> {
  bool _shadersReady = false;

  @override
  void initState() {
    super.initState();
    // Defer complex rendering
    WidgetsBinding.instance.addPostFrameCallback((_) {
      setState(() {
        _shadersReady = true;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _shadersReady
          ? ComplexAnimatedContent()
          : SimplePlaceholder(),
    );
  }
}

Debugging Shader Jank

Enable Verbose Logging

flutter run --verbose
# Look for shader compilation messages

Check Frame Times

import 'package:flutter/scheduler.dart';

void monitorFrameTimes() {
  SchedulerBinding.instance.addTimingsCallback((timings) {
    for (final timing in timings) {
      final total = timing.totalSpan.inMilliseconds;
      if (total > 16) {
        debugPrint('⚠️ Slow frame: ${total}ms');
        debugPrint('  Build: ${timing.buildDuration.inMilliseconds}ms');
        debugPrint('  Raster: ${timing.rasterDuration.inMilliseconds}ms');
      }
    }
  });
}

Identify Problematic Widgets

// Wrap suspicious widgets
RepaintBoundary(
  child: YourWidget(),
  // Check if this reduces jank
)

Best Practices Summary

  1. Enable Impeller for new projects
  2. Pre-warm shaders before showing complex UI
  3. Defer complex shaders until after first frame
  4. Cache shader objects when possible
  5. Use const widgets to minimize rebuilds
  6. Profile regularly with DevTools
  7. Test on real devices (not just simulators)
  8. Monitor frame times in production
  9. Use RepaintBoundary for expensive paints
  10. Optimize widget rebuilds with proper state management

Conclusion

Shader jank in Flutter 2026 is still a concern, but Impeller significantly reduces it. The key is:

  • Enable Impeller for better performance
  • Pre-warm shaders for smooth first frames
  • Optimize shader usage to avoid runtime compilation
  • Profile your app to identify jank sources
  • Test on real devices to catch platform-specific issues

Remember: 60fps means 16ms per frame. Any frame that takes longer causes jank. Impeller helps, but proper optimization is still essential.


Next Steps

  • Enable Impeller in your app and measure the difference
  • Set up continuous performance monitoring
  • Profile your app with DevTools regularly
  • Consider shader precompilation for production builds

Updated for Flutter 3.24+, Impeller default, and 2026 best practices