All Posts

How I Cut Flutter Video Feed Load Time by 75% — Without a Backend Change

Built a predictive video preloading + safe operation queue system in Flutter, cutting feed video startup time from 4s to under 1s.

Series: Flutter Architecture Patterns Part 1

After joining JB Connect, I started working the development of V+ing — a Japanese B2B video recruitment platform — from the ground up as the senior mobile developer. Early in the build, we ran into a hard problem: videos in the feed took around 4 seconds to start playing. That doesn't sound dramatic. But in a feed-based app where recruiters swipe through video profiles of candidates, 4 seconds per video is an eternity. Our analytics confirmed it was the leading cause of drop-off. And the instinct — "compress the video, upgrade the CDN, reduce resolution" — was wrong. The backend was fine. The problem was entirely on the client.


The Root Cause

Flutter's default video player initializes on demand. When a user scrolls to a video, the player starts from zero: fetches the URL, initializes the controller, buffers enough to begin playback. On mobile networks, that full sequence takes time — and it compounds quickly in a feed. But there was a second problem hiding beneath the latency: race conditions. When a user scrolls quickly, rapid play/pause/dispose calls on the same controller would arrive out of order. Without sequencing, this caused stuttering, double-dispose crashes, and state corruption. The solution had to address both: anticipate what the user will watch next, and make all video operations safe to call in any order.


The Architecture: Two Layers

Layer 1 — SafeVideoPlayerController: Serialized Operations

I wrapped VideoPlayerController in a custom class that routes every operation through an OperationQueue. Conflicting pending operations are cancelled before a new one is enqueued.

enum SafeVideoOperationType { init, play, pause, seekTo, dispose }

class SafeVideoPlayerController extends VideoPlayerController {
  SafeVideoPlayerController.networkUrl(super.url) : super.networkUrl(
    videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false),
  );

  final OperationQueue<SafeVideoOperationType> _queue = OperationQueue();

  @override
  Future<void> play() async {
    // Cancel any pending play or pause — only the latest intent matters
    _queue.cancelOperationsWhere((e) =>
      e.type == SafeVideoOperationType.play ||
      e.type == SafeVideoOperationType.pause,
    );
    await _queue.enqueue(SafeVideoOperationType.play, () async {
      if (!value.isInitialized) return;
      await super.play();
    });
  }

  @override
  Future<void> dispose() async {
    // Cancel pending seeks before disposing — they're irrelevant
    _queue.cancelOperationsWhere((e) =>
      e.type == SafeVideoOperationType.seekTo,
    );
    pause();
    await _queue.enqueue(SafeVideoOperationType.dispose, () async {
      if (!value.isInitialized) return;
      await super.dispose();
    });
  }

  // seekTo, pause, initialize follow the same pattern
} 

This makes the controller safe to call in any order. Rapid scrolling no longer causes crashes or state corruption — the queue absorbs the chaos.


Layer 2 — SafeMultiVideoPlayerController: Pre-fetch Window Cache

This is where the load time improvement comes from. Instead of initializing a controller when the user arrives at a video, we initialize it before they get there.

The system maintains a sliding window of initialized controllers centered on the current index. When the user scrolls, the window shifts: new controllers are initialized ahead, old ones are disposed behind.

class SafeMultiVideoPlayerController {
  final Map<VideoModel, SafeVideoPlayerController> _videoControllers = {};
  final Map<SafeVideoPlayerController, Completer<void>> _videoCompleters = {};

  int _focusedPageIndex = 0;

  void preCache(int currentIndex, {
    required int totalVideos,
    required VideoModel? Function(int) getVideoModel,
  }) async {
    // Compute the window: N videos centered on currentIndex
    int half = Constants.defaultCacheVideoCount ~/ 2;
    int windowStart = (currentIndex - half).clamp(0, totalVideos);
    int windowEnd = (currentIndex + half).clamp(0, totalVideos);

    // Phase 1: dispose controllers nearest to the window boundary (free memory first)
    // Phase 2: initialize the inner window (most likely to be viewed next)
    // Phase 3: initialize outer window edges + dispose the rest
    // Each phase runs in parallel within itself via Future.wait
  }

  Future<void> _initializeVideo(VideoModel? videoModel) async {
    if (_videoControllers.containsKey(videoModel)) return; // already cached

    final controller = SafeVideoPlayerController.networkUrl(
      Uri.parse(videoModel!.videoUrl),
    );
    _videoControllers[videoModel] = controller;

    // Completer signals when this controller is ready to play
    final completer = Completer<void>();
    _videoCompleters[controller] = completer;

    try {
      await controller.initialize();
      completer.complete();
    } on Exception {
      completer.complete(); // still unblock waiters on failure
    }
  }
} 

The key insight is the Completer<void> per controller. When a user scrolls to a video, instead of starting initialization from zero, we just await the completer — which either resolves immediately (controller already warm) or resolves as soon as initialization finishes. Either way, we never block unnecessarily.


Putting It Together: Page Transitions

When the user swipes to a new video:

void onChangePage(int index, List<VideoModel> videoModels) async {
  final previousVideo = videoModels[_focusedPageIndex];
  final currentVideo = videoModels[index];
  _focusedPageIndex = index;

  _videoControllers[previousVideo]?.pause().then((_) async {
    // Wait for the incoming video to be ready (may already be)
    await _videoCompleters[_videoControllers[currentVideo]]?.future;

    // Guard against rapid swipes — only play if this is still the focused video
    if (currentVideo.videoUrl == _focusedVideo?.videoUrl) {
      _videoControllers[currentVideo]?.play();
    }

    // Reset the previous video in the background
    await _videoControllers[previousVideo]?.seekTo(Duration.zero);
  });
} 

The completer await is the performance win. By the time the user reaches a video, its controller has typically been initializing for the duration of one or two swipes. The wait resolves in milliseconds, not seconds.


The Result

Video load time dropped from 4 seconds to under 1 second. A 75% reduction. No backend change. No infrastructure cost.

The phased parallel initialization in preCache — disposing boundary controllers first to free memory, then initializing inner-window controllers in priority order — meant we could maintain a healthy cache size without causing memory pressure spikes during fast scrolling.


What I Learned

Performance problems in Flutter are almost never where you first look. The reflex is to blame the backend, the network, or the framework. Here, the fix was two things working together: anticipation (pre-fetch before the user arrives) and safety (serialize operations so rapid interaction can't corrupt state).

If your Flutter feed app has slow video playback, audit your initialization lifecycle and your operation sequencing before touching anything else. Both problems are usually already on the device.


I'm a Senior Flutter Developer with 6+ years building cross-platform apps. I write about architecture, performance, and shipping. Feel free to connect.