All posts

A TikTok-style video feed in Flutter, without melting the phone

3 min read·Adnane BENZERDJEB
Flutter
Performance
Video

When we built the buyer app for DA-Mall, the whole premise was that you discover products the way you already browse: a full-screen vertical feed where every item is a video you can buy from without leaving the scroll.

The first version worked beautifully on my phone with eight products in the list. Then someone loaded a real catalogue and the app started throwing decoder errors after roughly a dozen swipes.

One controller per item is a trap

The obvious implementation is a PageView.builder where each page owns a VideoPlayerController. It reads well and it is completely wrong.

Every initialised controller holds a platform decoder — a MediaCodec instance on Android, an AVPlayerItem on iOS. Android devices expose a hard limit on concurrent codec instances. On the budget phones most of our users actually own, that ceiling is often around eight. Past it you do not get slow playback, you get an exception, and PageView.builder happily keeps building pages as you fling.

Flutter's builder will dispose off-screen pages eventually, but "eventually" is decided by the framework's cache extent, not by your codec budget. You cannot rely on it.

Own the window yourself

The fix is to stop letting the widget tree decide how many videos exist. Keep a small pool — current, previous, next — and recycle it.

class VideoPool {
  VideoPool({this.radius = 1});
 
  /// How many items either side of the active index stay initialised.
  final int radius;
  final _controllers = <int, VideoPlayerController>{};
 
  VideoPlayerController? of(int index) => _controllers[index];
 
  /// Initialises the window around [active] and disposes everything else.
  Future<void> moveTo(int active, List<String> urls) async {
    final wanted = <int>{
      for (var i = active - radius; i <= active + radius; i++)
        if (i >= 0 && i < urls.length) i,
    };
 
    // Release first, so we are under the codec limit before allocating.
    for (final index in _controllers.keys.toList()) {
      if (wanted.contains(index)) continue;
      await _controllers.remove(index)!.dispose();
    }
 
    for (final index in wanted) {
      if (_controllers.containsKey(index)) continue;
      final controller =
          VideoPlayerController.networkUrl(Uri.parse(urls[index]));
      _controllers[index] = controller;
      await controller.initialize();
      await controller.setLooping(true);
    }
 
    for (final entry in _controllers.entries) {
      entry.key == active ? entry.value.play() : entry.value.pause();
    }
  }
 
  Future<void> disposeAll() async {
    for (final controller in _controllers.values) {
      await controller.dispose();
    }
    _controllers.clear();
  }
}

Two details in there matter more than they look:

Dispose before you allocate. If you initialise the incoming controller first and release the outgoing one after, you spend a moment holding one more decoder than your steady state. On a device sitting right at the limit, that moment is the crash.

radius is a budget, not a preference. One either side means at most three live decoders. That is enough for the next swipe to feel instant and low enough to survive a cheap device.

Never show a black rectangle

A controller takes a few hundred milliseconds to initialise over a mobile network. If you render the player as soon as the page builds, the user sees black while it spins up — and on a fast scroll they see a column of black rectangles.

Render the poster frame underneath, always, and fade the video in on top once it is genuinely ready:

Stack(
  fit: StackFit.expand,
  children: [
    Image.network(product.posterUrl, fit: BoxFit.cover),
    if (controller != null && controller.value.isInitialized)
      FadeIn(child: VideoPlayer(controller)),
  ],
)

The poster is a still frame the backend already extracted at upload time. It costs one cached image and it is the single biggest perceived-performance win in the whole feed.

Stop when the app goes away

If the user takes a call, your video keeps playing and keeps holding its decoder. Bind playback to the lifecycle:

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  if (state == AppLifecycleState.resumed) {
    _pool.of(_index)?.play();
  } else {
    _pool.of(_index)?.pause();
  }
}

And when the route is popped, disposeAll(). A leaked controller is a leaked decoder, and it will be the next screen's problem, not this one's — which makes it painful to track down.

What actually moved the needle

Ranked by impact, on real devices:

  1. Capping live controllers at three. This is what stopped the crashes. Nothing else came close.
  2. Poster frames under every player. The feed went from "loading" to "instant" without a single byte of video arriving faster.
  3. Disposing before allocating. Removed the last cluster of errors on low-end Android.
  4. Lifecycle pausing. Fixed the battery complaints and a class of bug where audio kept playing over other apps.

None of this is exotic. It is all about accepting that a video player is a scarce platform resource and not a widget — and once you treat it that way, the feed stops being a performance problem and goes back to being a feature.

Working on something like this?

I build mobile apps and backends that hold up in production. Get in touch.

Say hello