Impeller Android (API 34): Heavy frame drops during CustomPainter shader invalidation inside SliverGrid using SliverChildBuilderDelegate
06:29 04 Oct 2026

I’m hitting a severe frame drop / UI freeze issue on Flutter (v3.38+ / Impeller on Android API 34) that I can’t isolate. The issue happens when updating a uniform value inside a custom fragment shader attached to a CustomPainter rendering inside a SliverGrid.

We are building a media grid where each item has a subtle background animation driven by a AnimationController and an AGSL fragment shader. Instead of rebuilt widgets, we use CustomPainter with repaint: animationController to draw directly to canvas.

On Skia (or iOS Impeller), this runs smoothly at 60–120 FPS. However, on Android 14 / API 34 with Impeller enabled by default, fast scrolling causes immediate visual stutter, where DevTools CPU/GPU frame times spike up to 45ms–60ms (dropping frames down to ~18-22 FPS).

Here is the setup:

import 'dart:ui' as ui;
import 'package:flutter/material.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final shader = await ui.FragmentProgram.fromAsset('shaders/ripple.frag');
  runApp(MyApp(shader: shader));
}

class MyApp extends StatelessWidget {
  final ui.FragmentProgram shader;
  const MyApp({super.key, required this.shader});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ShaderGridScreen(shader: shader),
    );
  }
}

class ShaderGridScreen extends StatefulWidget {
  final ui.FragmentProgram shader;
  const ShaderGridScreen({super.key, required this.shader});

  @override
  State createState() => _ShaderGridScreenState();
}

class _ShaderGridScreenState extends State
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState() {
      _controller = AnimationController(
        vsync: this,
        duration: const Duration(seconds: 2),
      )..repeat();
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          SliverGrid(
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3,
              mainAxisSpacing: 4,
              crossAxisSpacing: 4,
            ),
            delegate: SliverChildBuilderDelegate(
              (context, index) {
                return CustomPaint(
                  painter: ShaderTilePainter(
                    program: widget.shader,
                    progress: _controller,
                    index: index,
                  ),
                );
              },
              childCount: 300,
              addRepaintBoundaries: true, // Tried true & false
              addAutomaticKeepAlives: false,
            ),
          ),
        ],
      ),
    );
  }
}

class ShaderTilePainter extends CustomPainter {
  final ui.FragmentProgram program;
  final Animation progress;
  final int index;

  ShaderTilePainter({
    required this.program,
    required this.progress,
    required this.index,
  }) : super(repaint: progress);

  @override
  void paint(Canvas canvas, Size size) {
    final shader = program.fragmentShader();
    // Set uniforms
    shader.setFloat(0, size.width);
    shader.setFloat(1, size.height);
    shader.setFloat(2, progress.value);
    shader.setFloat(3, index.toDouble());

    final paint = Paint()..shader = shader;
    canvas.drawRect(Offset.zero & size, paint);
  }

  @override
  bool shouldRepaint(covariant ShaderTilePainter oldDelegate) {
    return oldDelegate.progress != progress || oldDelegate.index != index;
  }
}

What I Have Tried So Far

  1. Repaint Boundaries: Wrapped each CustomPaint item inside a RepaintBoundary (and toggled addRepaintBoundaries: true in SliverChildBuilderDelegate). This lowered raster times slightly, but GPU compilation spikes remain high when new grid tiles scroll into view.

  2. Pre-allocating FragmentShader instances: Moved program.fragmentShader() generation into initState / held in state memory instead of creating a new FragmentShader object inside paint(). This reduced Dart GC pressure, but the rasterizer jank on Impeller persists.

  3. addAutomaticKeepAlives: false: Set off keep-alives so invisible tiles unmount immediately, but fast flings still hit 50ms frame gaps.

  4. Disabling Impeller (Testing Skia): Passing --enable-impeller=false on Android immediately resolves the issue—scrolling stays at a locked 60 FPS without frame drops.

DevTools / Performance Profile Findings

From Flutter DevTools Timeline (Performance tab):

  • Raster / GPU Thread Spikes: Every time a new batch of 3-6 tiles comes into view, Impeller::CommandQueue::Submit takes ~30ms - 40ms.

  • Pipeline Trace: RenderCustomPaint.paint triggers GLES/Vulkan Pipeline Creation or Uniform Buffer allocation overhead per dynamic child tile under Impeller.

Environment (flutter doctor -v)

[✓] Flutter (Channel stable, 3.38.2, on Microsoft Windows 11 Pro 10.0.22631)
    • Flutter version 3.38.2 on channel stable
    • Engine revision 04301548fb
    • Tools Dart version 3.11.0

[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
    • Android SDK at C:\Users\Dev\AppData\Local\Android\Sdk
    • Platform android-34, build-tools 34.0.0

[✓] Connected device (1 available)
    • Pixel 8 Pro (mobile) • android-arm64 • Android 14 (API 34)

Is there a known issue with how Impeller manages pipeline state or uniform buffer allocations for dynamic shaders inside viewport slivers (SliverGrid)? How can we properly reuse or batch ui.FragmentShader instances across scrolling grid cells to prevent Vulkan submission stalls on API 34?

flutter dart