Skip to main content

Building a Vertical Video Feed in Flutter Without Crashing

· 4 min read
Jakub Homlala
Senior Mobile Engineer

If you put a standard video player inside a PageView.builder, your feed will feel fine in a desktop browser and crash quickly on real mobile devices.

Mobile operating systems share a limited pool of hardware decoders across every running app. Each active video player requests its own decoder session and allocates a GPU texture. Because native engines tear down decoders asynchronously, a user flicking through half a dozen clips creates new players faster than the OS can release old ones. The result: decoder allocation failures, frame drops, or the OS killing your process for hogging graphics memory.

Better Player solves this with controller pooling: recycling a small, fixed set of native player instances instead of allocating a fresh player for every page.

The Hardware Constraint​

Desktop GPUs have plenty of video memory and multiple hardware decode pipelines. Mobile chips (Apple Silicon, Snapdragon, MediaTek) do not.

Every active player instance claims three scarce system resources:

  • A hardware decoder slot managed by the OS media framework
  • A native GPU texture surface
  • An audio focus session

When a user swipes past three videos in two seconds, calling dispose() in Dart only signals the native platform (AndroidX Media3 or AVPlayer) to start releasing resources. That cleanup happens asynchronously on background threads. If your creation rate outpaces teardown, you hit platform limits: MediaCodec allocation failures on Android, memory pressure kills on iOS, or audio tracks overlapping because the previous player has not yielded focus yet.

To keep your app alive, keep the number of native player instances fixed and rotate what they play.

How the Pool Works​

Instead of creating a player per feed card, BetterPlayerFeed manages a bounded pool (typically 3 instances):

  • Previous item (index - 1): Kept paused in memory. If the user swipes back up, it resumes without rebuffering.
  • Active item (index): The video currently visible in the viewport, playing audio and rendering frames.
  • Next item (index + 1): Pre-buffering in the background so the next swipe lands on a ready frame instead of a black screen.

When the user swipes forward, the controller from index - 1 gets reassigned to index + 2. Its data source updates to the new URL while keeping the underlying player instance and texture alive.

Complete Feed Implementation​

Here is a full working feed screen. It initializes BetterPlayerFeedController with a 3-player pool, enables disk caching so repeated videos do not re-download, and hooks into WidgetsBindingObserver to pause playback when the user backgrounds the app.

import 'package:flutter/material.dart';
import 'package:better_player/better_player.dart';

class VideoFeedScreen extends StatefulWidget {
const VideoFeedScreen({super.key});


State<VideoFeedScreen> createState() => _VideoFeedScreenState();
}

class _VideoFeedScreenState extends State<VideoFeedScreen>
with WidgetsBindingObserver {
late final BetterPlayerFeedController _feedController;

final _videoUrls = const [
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyBlazes.mp4',
];


void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);

_feedController = BetterPlayerFeedController(
dataSources: _videoUrls.map((url) {
return PlayerDataSource(
DataSourceType.network,
url,
cacheConfiguration: const CacheConfiguration(
useCache: true,
maxCacheSize: 250 * 1024 * 1024, // 250 MB disk cache
maxCacheFileSize: 25 * 1024 * 1024,
),
);
}).toList(),
poolConfiguration: const PlayerPoolConfiguration(
poolSize: 3,
preloadForward: 1,
preloadBackward: 1,
),
);
}


void dispose() {
WidgetsBinding.instance.removeObserver(this);
_feedController.dispose();
super.dispose();
}


void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
_feedController.pauseActive();
} else if (state == AppLifecycleState.resumed) {
_feedController.playActive();
}
}


Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: BetterPlayerFeed(
controller: _feedController,
configuration: PlayerFeedConfiguration(
scrollDirection: Axis.vertical,
placeholderBuilder: (context, index, dataSource) {
return Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
);
},
),
),
);
}
}

Profiling in DevTools​

Run your app in profile mode to verify memory behavior on real hardware:

flutter run --profile

Open Flutter DevTools and inspect the Memory tab while testing:

  • Memory ceiling: Swipe through 25 to 30 videos. Total memory should plateau after the pool warms up. If memory climbs linearly with every swipe, a widget reference or listener outside the pool is leaking.
  • Lifecycle check: Send the app to the background and confirm playback halts. Leaving hardware decoders running while the app is hidden drains battery and invites the OS to terminate your process.
  • Cache limits: Keep maxCacheSize tuned in your CacheConfiguration. A feed that caches every video without an eviction ceiling will fill up device storage faster than your users expect.