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.
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.