iOS Development
AVPlayer
Scroll Performance
Smooth Scrolling
Video Playback Optimization

Maintaining good scroll performance when using AVPlayer

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

AVPlayer itself is not the only reason scrolling becomes janky in a feed. The real problem is usually too many players, too much layer churn, too much asset work on the main thread, or too much buffering happening for off-screen content. Good scroll performance comes from limiting active playback work and reusing as much view infrastructure as possible.

Do Not Create a Player for Every Cell Up Front

A common anti-pattern is giving every visible and soon-to-be-visible cell its own fully configured player. That grows memory pressure and decoding work quickly.

A better pattern is to create players lazily and keep only a small number active.

swift
1import AVFoundation
2import UIKit
3
4final class VideoCell: UICollectionViewCell {
5    private let playerLayer = AVPlayerLayer()
6    private var player: AVPlayer?
7
8    override init(frame: CGRect) {
9        super.init(frame: frame)
10        playerLayer.videoGravity = .resizeAspectFill
11        contentView.layer.addSublayer(playerLayer)
12    }
13
14    required init?(coder: NSCoder) {
15        fatalError("init(coder:) has not been implemented")
16    }
17
18    override func layoutSubviews() {
19        super.layoutSubviews()
20        playerLayer.frame = contentView.bounds
21    }
22
23    func configure(url: URL) {
24        let item = AVPlayerItem(url: url)
25        let player = AVPlayer(playerItem: item)
26        self.player = player
27        playerLayer.player = player
28    }
29
30    override func prepareForReuse() {
31        super.prepareForReuse()
32        player?.pause()
33        playerLayer.player = nil
34        player = nil
35    }
36}

The key idea is not the exact class structure. It is that off-screen cells should not keep expensive playback work alive forever.

Separate Scrolling from Playback Decisions

A smooth feed usually has one primary playing item, not ten. Decide which cell should play based on visibility and pause the rest.

swift
1func updatePlayback(for collectionView: UICollectionView) {
2    for cell in collectionView.visibleCells.compactMap({ $0 as? VideoCell }) {
3        let visibleRect = collectionView.convert(cell.frame, to: collectionView.superview)
4        let isMostlyVisible = collectionView.bounds.intersection(visibleRect).height > cell.bounds.height * 0.6
5
6        if isMostlyVisible {
7            // start or resume
8        } else {
9            // pause
10        }
11    }
12}

This reduces CPU, network, and decoder pressure. The user experience is usually better too, because the feed feels intentional instead of noisy.

Reuse Layers and Avoid Main-Thread Asset Work

AVPlayerLayer should usually be reused with the cell rather than torn down and recreated excessively during scrolling. Likewise, avoid expensive media preparation work on the main thread.

If metadata loading, thumbnail generation, or asset inspection happens during fast scrolls, push that work away from the critical path. Main-thread layout and compositing should stay focused on keeping the list responsive.

Buffering Strategy Matters

Aggressive buffering for many videos at once can ruin feed performance. If only one or two items are likely to play, prioritize those.

swift
player.currentItem?.preferredForwardBufferDuration = 3
player.automaticallyWaitsToMinimizeStalling = true

The exact values depend on the product, but the principle is stable: do not pre-buffer the world when the user can only watch one item at a time.

Clean Up Observers and KVO

Video feeds often accumulate observers, periodic time callbacks, and notification handlers. If those survive cell reuse, performance and correctness both degrade.

swift
1private var timeObserver: Any?
2
3func attachObserver(to player: AVPlayer) {
4    timeObserver = player.addPeriodicTimeObserver(
5        forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
6        queue: .main
7    ) { time in
8        print(time.seconds)
9    }
10}
11
12func detachObserver(from player: AVPlayer) {
13    if let timeObserver {
14        player.removeTimeObserver(timeObserver)
15        self.timeObserver = nil
16    }
17}

Leaked observers create hidden work that keeps happening long after the cell should be idle.

Common Pitfalls

The biggest mistake is creating and keeping too many active AVPlayer instances in a scrolling feed. Even if each one works, the combined decoder, buffering, and layer cost can overwhelm smooth scrolling.

Another issue is doing too much setup during cellForItemAt or on the main thread during fast reuse. Asset preparation and player wiring should be as lightweight as possible in the hot path.

People also often forget cleanup. Observers, current items, and player references that survive reuse can create both memory growth and unnecessary background activity.

Finally, do not try to optimize only rendering while ignoring product behavior. The fastest feed is often the one that allows only one primary video to play at a time.

Summary

  • Keep the number of active AVPlayer instances small.
  • Reuse cell and layer infrastructure instead of recreating everything during scroll.
  • Pause or detach players for off-screen cells.
  • Avoid heavy asset and metadata work on the main thread.
  • Clean up observers and playback state during reuse to prevent hidden performance costs.

Course illustration
Course illustration

All Rights Reserved.