← Back to posts Cover image for Mastering Flutter UI Performance: Building JANK-Free Video Feeds and Complex Scrollables

Mastering Flutter UI Performance: Building JANK-Free Video Feeds and Complex Scrollables

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Building silky-smooth video feeds and complex scrollable UIs in Flutter is a rite of passage. You start with a simple ListView.builder and a VideoPlayerController in each item, and suddenly your app is stuttering, devices are warming up, and scrolling feels like wading through molasses. The culprit is almost always the same: the costly creation and disposal of resources like video controllers during rapid scroll interactions.

Let’s break down why this happens and master the techniques to build jank-free experiences.

The Performance Pitfall: Thrashing Resources on Scroll

The most common mistake is initializing a VideoPlayerController inside a ListView item’s build method or initState. As users scroll, widgets are created and destroyed rapidly. If each creation triggers video controller initialization (involving platform channels and hardware decoder setup), and each disposal triggers cleanup, you create a performance death spiral. The garbage collector churns, the UI thread blocks waiting for platform operations, and jank spikes appear.

The Bad Pattern (Avoid This):

class JankyVideoItem extends StatefulWidget {
  @override
  _JankyVideoItemState createState() => _JankyVideoItemState();
}

class _JankyVideoItemState extends State<JankyVideoItem> {
  late VideoPlayerController _controller;

  @override
  void initState() {
    super.initState();
    // This runs every time the item is scrolled into view!
    _controller = VideoPlayerController.network(videoUrl)
      ..initialize().then((_) {
        setState(() {});
      });
  }

  @override
  void dispose() {
    // This runs every time the item is scrolled out of view!
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return VideoPlayer(_controller);
  }
}

Solution 1: The Video Player Pool Manager

The core optimization is to treat video controllers like a limited, reusable pool. Instead of creating/destroying on scroll, you pre-initialize a small number of controllers and reassign their data sources (video URLs) as needed. This keeps the expensive decoder setup/teardown to an absolute minimum.

Here’s a simplified manager pattern:

class VideoControllerPool {
  final Map<String, VideoPlayerController> _activeControllers = {};
  final List<VideoPlayerController> _idlePool = [];
  final int _maxPoolSize = 3; // Typically 1-2 more than visible items

  Future<VideoPlayerController> getControllerForUrl(String url) async {
    // Return existing controller if already active for this URL
    if (_activeControllers.containsKey(url)) {
      return _activeControllers[url]!;
    }

    // Reuse an idle controller from the pool
    VideoPlayerController controller;
    if (_idlePool.isNotEmpty) {
      controller = _idlePool.removeLast();
      await controller.pause();
    } else if (_activeControllers.length < _maxPoolSize) {
      // Create a new one if under pool limit
      controller = VideoPlayerController.network('');
    } else {
      // Recycle the least recently used active controller
      final lruUrl = _activeControllers.keys.first;
      controller = _activeControllers[lruUrl]!;
      _activeControllers.remove(lruUrl);
      await controller.pause();
    }

    // Reconfigure the controller with the new URL
    await controller.setNetworkDataSource(url);
    _activeControllers[url] = controller;
    return controller;
  }

  void releaseController(String url) {
    final controller = _activeControllers[url];
    if (controller != null) {
      _activeControllers.remove(url);
      // Keep it in the idle pool for future reuse
      _idlePool.add(controller);
    }
  }

  void disposeAll() {
    [..._activeControllers.values, ..._idlePool].forEach((c) => c.dispose());
    _activeControllers.clear();
    _idlePool.clear();
  }
}

In your widget, you’d call pool.getControllerForUrl when an item becomes visible (using VisibilityDetector or within a PageView/ListView visibility callback) and pool.releaseController when it’s far off-screen.

Solution 2: Precise Control with ValueNotifier and AutomaticKeepAliveClientMixin

For non-pooled scenarios or other expensive widgets, combine AutomaticKeepAliveClientMixin to prevent disposal with a ValueNotifier to manage state efficiently.

class OptimizedListItem extends StatefulWidget {
  final String videoUrl;

  const OptimizedListItem({super.key, required this.videoUrl});

  @override
  State<OptimizedListItem> createState() => _OptimizedListItemState();
}

class _OptimizedListItemState extends State<OptimizedListItem>
    with AutomaticKeepAliveClientMixin {
  late final VideoPlayerController _controller;
  final _isInitialized = ValueNotifier<bool>(false);

  @override
  bool get wantKeepAlive => _isInitialized.value; // Keep alive once initialized

  @override
  void initState() {
    super.initState();
    _controller = VideoPlayerController.network(widget.videoUrl)
      ..initialize().then((_) {
        _isInitialized.value = true; // Triggers keep-alive
      });
  }

  @override
  Widget build(BuildContext context) {
    super.build(context); // Required for AutomaticKeepAliveClientMixin
    return ValueListenableBuilder<bool>(
      valueListenable: _isInitialized,
      builder: (context, isInitialized, child) {
        return isInitialized
            ? VideoPlayer(_controller)
            : const Center(child: CircularProgressIndicator());
      },
    );
  }

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

Solution 3: Offloading Work and Smooth Scrolling

For complex scrollables with heavy layouts, ensure you’re not building expensive widgets during scroll animations.

  1. Use const constructors wherever possible.
  2. Lazily load content: Defer non-essential image decoding or data parsing until after the scroll settles.
  3. Offload computations: Use compute or Isolate for heavy synchronous tasks that block the UI thread. Don’t assume async alone makes something non-blocking.
Future<void> _loadAndProcessData() async {
  // This async operation still blocks the UI thread during processing!
  final heavyData = await _fetchData();
  // Move intensive processing to a separate isolate
  final result = await compute(_processDataHeavily, heavyData);
  setState(() => _data = result);
}

// This function runs in a separate isolate
static HeavyResult _processDataHeavily(RawData input) {
  // ... intensive CPU work ...
  return HeavyResult();
}

Key Takeaways

  • Never create/dispose VideoPlayerController in rapid succession. Use a pooling or caching strategy.
  • Limit the number of active video players to just those visibly playing (plus one buffer).
  • Use AutomaticKeepAliveClientMixin strategically for expensive widget state.
  • Profile with the Flutter Performance overlay and the DevTools timeline to identify raster and UI thread jank.
  • Remember async doesn’t mean non-blocking for CPU work—use isolates.

By treating controllers as precious resources and managing their lifecycle proactively, you can build Flutter video feeds that are indistinguishable from native performance, even on budget hardware. The goal is to do less work on the critical path of the UI thread, and these patterns provide a solid foundation for exactly that.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Developers are curious about Flutter's capabilities beyond typical business apps, especially for demanding desktop applications like CAD/CAM or image/video processing. This post will explore Flutter's suitability for high-performance, viewport-based desktop GUIs, discussing Dart's memory model, the 60fps update loop, and real-world examples to gauge its readiness for 'serious' complex software.

Cover image for Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Flutter web applications often suffer from a frustrating 'deep link refresh bug' where refreshing the browser on a nested route (e.g., /home/details) bounces the user back to the root or an incorrect path. This post will diagnose the common causes of this issue, explain how Flutter's router handles web URLs, and provide practical solutions and best practices for building robust, refresh-proof navigation in your Flutter web apps.

Cover image for Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

As Flutter applications grow, managing strings for multiple languages or just keeping text consistent becomes a challenge. This post will guide developers through effective strategies for centralizing strings, implementing robust internationalization (i18n) and localization (l10n), and leveraging tools to streamline the process for small to large-scale projects.