iosNudge

Design a Video Streaming App

HardSystem designNetworkingPersistenceApp lifecyclePerformance 60 min read

Requirements

You design the iOS app of a video service, like Netflix or Disney+. Users browse a catalog, watch videos, continue where they stopped, and download videos to watch on a plane.

Functional

  • Browse and search the catalog.
  • Play videos with adaptive quality (HLS).
  • Resume from the last position, also on another device.
  • Download videos for offline viewing. Downloads continue in the background.
  • Manage downloads: pause, cancel, delete, see storage used.
  • Subtitles and audio languages. Picture in Picture and AirPlay are nice extras.

Non-functional

  • Fast start: video plays in under 2 seconds.
  • Few stalls (pauses to buffer).
  • Battery friendly. Respect cellular and Low Data Mode settings.
  • Paid content is protected with DRM (FairPlay). Offline licenses expire.
  • Downloads survive app kills and relaunches.

Out of scope

  • Video encoding, the CDN, recommendations, live streaming, ads.

Questions to ask the interviewer

  • Only video on demand, or live too?
  • Is DRM required? What are the download rules (how many devices, how long a license lasts)?
  • Can users download on cellular? Which quality do downloads use?
  • Do we need profiles (for example kids)?
  • iPhone only, or iPad and Apple TV too?

High-level architecture

+----------------+     +-------------------+     +---------------------+
|  Catalog UI    | --> |  Player screen    | --> |  PlayerController   |
|  browse/search |     |  (AVPlayer view)  |     |  AVPlayer +         |
+-------+--------+     +---------+---------+     |  AVPlayerItem       |
        |                        |               +----------+----------+
        v                        v                          |  HLS playlists
+----------------+     +-------------------+                |  + segments
| CatalogRepo    |     | ProgressStore     |                v
| API + cache    |     | local + server    |       +-----------------+
+----------------+     | sync              |       |      CDN        |
                       +---------+---------+       +-----------------+
                                 |
                                 v
                       +-------------------+     +---------------------+
                       |  Local DB         |<----|  DownloadManager    |
                       |  progress,        |     |  AVAssetDownload-   |
                       |  downloads        |     |  URLSession (bg)    |
                       +-------------------+     +---------------------+
                                                 +---------------------+
                                                 |  KeyService (DRM)   |
                                                 |  FairPlay licenses  |
                                                 +---------------------+

What HLS is. HLS (HTTP Live Streaming) cuts a video into small files of a few seconds each. A "master playlist" lists the same video in many qualities. The player picks the best quality for the current network, and can switch between segments. This is called adaptive bitrate (ABR).

master.m3u8
  +-- 400 kbps,  416x234    -> v400/index.m3u8   -> seg1.m4s, seg2.m4s, ...
  +-- 2 Mbps,    1280x720   -> v2000/index.m3u8  -> seg1.m4s, seg2.m4s, ...
  +-- 6 Mbps,    1920x1080  -> v6000/index.m3u8  -> seg1.m4s, seg2.m4s, ...
  +-- subtitles (en, fr), audio (en, es)

AVPlayer does all of this for you. Your job is everything around it.

Data model

struct Video: Identifiable, Codable, Sendable {
    let id: String
    let title: String
    let duration: TimeInterval
    let hlsURL: URL               // the master playlist
    let thumbnailURL: URL
    let isDownloadable: Bool
}

struct PlaybackProgress: Codable, Sendable {
    let videoID: String
    var position: TimeInterval    // seconds
    var updatedAt: Date
    var needsUpload: Bool         // changed locally, not yet sent to the server
}

enum DownloadState: Codable, Sendable, Equatable {
    case queued
    case downloading(progress: Double)
    case paused(progress: Double)
    case downloaded(relativePath: String, licenseExpiresAt: Date?)
    case failed(message: String)
}

struct DownloadRecord: Codable, Sendable {
    let videoID: String
    var state: DownloadState
    var bytesOnDisk: Int64
    let quality: DownloadQuality
}

enum DownloadQuality: String, Codable, Sendable { case standard, high }

Note relativePath. The app's container folder can change between launches and updates. If you save the full path, the download looks "missing" later. Save the path relative to the home folder and rebuild the URL each time.

API / interfaces

Server endpoints

GET  /v1/catalog?section=home                  rows of videos
GET  /v1/videos/{id}                           details, hlsURL, DRM info
GET  /v1/progress?since=<time>                 positions changed on other devices
PUT  /v1/progress/{videoID}  { "position": 1312.5, "updatedAt": "..." }
POST /v1/downloads/{videoID}/license           offline license; server checks device limit
POST /v1/drm/fairplay                          key request -> key response (online play)

Swift protocols

protocol ProgressStore: Sendable {
    func save(_ progress: PlaybackProgress) async
    func progress(for videoID: String) async -> PlaybackProgress?
    func pendingUploads() async -> [PlaybackProgress]
}

protocol DownloadManaging: Sendable {
    func download(_ video: Video, quality: DownloadQuality) async throws
    func pause(videoID: String) async
    func cancel(videoID: String) async
    func delete(videoID: String) async throws
    func localURL(for videoID: String) async -> URL?      // nil = not downloaded
    var updates: AsyncStream<(videoID: String, state: DownloadState)> { get }
}

protocol PlaybackSource: Sendable {
    /// Local file if downloaded and the license is valid, else the stream URL.
    func playableURL(for video: Video) async -> URL
}

Deep dives

1. Playback with AVPlayer

Choose the source first: a valid download is better than the network.

struct DefaultPlaybackSource: PlaybackSource {
    let downloads: DownloadManaging

    func playableURL(for video: Video) async -> URL {
        if let local = await downloads.localURL(for: video.id) { return local }
        return video.hlsURL
    }
}

The player controller:

import AVFoundation

@MainActor
final class PlayerController {
    let player = AVPlayer()
    private var timeObserver: Any?
    private var video: Video?
    private let source: PlaybackSource
    private let progressStore: ProgressStore

    init(source: PlaybackSource, progressStore: ProgressStore) {
        self.source = source
        self.progressStore = progressStore
    }

    func start(_ video: Video) async {
        self.video = video
        let item = AVPlayerItem(url: await source.playableURL(for: video))
        player.replaceCurrentItem(with: item)

        // Resume, unless the user almost finished last time.
        if let saved = await progressStore.progress(for: video.id),
           saved.position < video.duration * 0.95 {
            await player.seek(to: CMTime(seconds: saved.position, preferredTimescale: 600))
        }
        player.play()
        observeTime()
    }
}

Tips for a fast start and fewer stalls:

  • Let the player start with a lower quality. ABR moves up after a few seconds.
  • Preload the next episode: create its AVPlayerItem a minute before the end.
  • Watch item.status for errors, and timeControlStatus == .waitingToPlayAtSpecifiedRate to show a buffering spinner.
  • Reuse one AVPlayer. Many players at once use a lot of memory and battery.
  • Set the audio session to .playback and add the "Audio, AirPlay, and Picture in Picture" background mode, so sound keeps playing when the screen locks.

2. Resume position

We save the position:

  • every 10 seconds while playing,
  • on pause, on seek, on close,
  • when the app goes to the background,
  • at the end (then mark it "finished").
extension PlayerController {
    func observeTime() {
        let interval = CMTime(seconds: 10, preferredTimescale: 600)
        timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) {
            [weak self] time in
            MainActor.assumeIsolated { self?.saveProgress(at: time.seconds) }
        }
    }

    func saveProgress(at seconds: Double) {
        guard let video, seconds.isFinite, seconds > 0 else { return }
        let progress = PlaybackProgress(videoID: video.id, position: seconds,
                                        updatedAt: .now, needsUpload: true)
        Task { await progressStore.save(progress) }    // local first, upload later
    }

    func stop() {
        saveProgress(at: player.currentTime().seconds)
        if let timeObserver { player.removeTimeObserver(timeObserver) }
        timeObserver = nil
        player.pause()
    }
}

Local first, then server. Saving to the server every 10 seconds is wasteful and fails offline. Save locally every time. Upload the latest position per video every 30–60 seconds, on pause, and on background. Offline positions are uploaded later.

Two devices. The user watched on the iPad last night and opens the iPhone today. On open, fetch GET /v1/progress?since=.... The newest updatedAt wins. Use the server time when you can, because phone clocks can be wrong.

3. Offline downloads

Use AVAssetDownloadURLSession. It downloads a full HLS stream (playlists, segments, keys) into a .movpkg package that AVPlayer can play offline. Do not try to download segments yourself: you would need to rewrite playlists and handle DRM keys by hand.

// @unchecked: `session` is set once in init and never changed after.
final class DownloadManager: NSObject, AVAssetDownloadDelegate, @unchecked Sendable {
    private var session: AVAssetDownloadURLSession!
    private let records: DownloadRecordStore

    init(records: DownloadRecordStore, allowCellular: Bool) {
        self.records = records
        super.init()
        let config = URLSessionConfiguration.background(withIdentifier: "com.example.video.downloads")
        config.allowsCellularAccess = allowCellular
        config.allowsConstrainedNetworkAccess = false        // respect Low Data Mode
        session = AVAssetDownloadURLSession(configuration: config,
                                            assetDownloadDelegate: self,
                                            delegateQueue: .main)
    }

    func download(_ video: Video) {
        let asset = AVURLAsset(url: video.hlsURL)
        let config = AVAssetDownloadConfiguration(asset: asset, title: video.title)
        let task = session.makeAssetDownloadTask(downloadConfiguration: config)
        task.taskDescription = video.id          // survives app relaunch: maps task -> video
        task.resume()
        records.set(.queued, for: video.id)
    }

    func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
                    didFinishDownloadingTo location: URL) {
        guard let videoID = assetDownloadTask.taskDescription else { return }
        // Save the RELATIVE path. The container path can change.
        records.set(.downloaded(relativePath: location.relativePath, licenseExpiresAt: nil),
                    for: videoID)
    }

    func urlSession(_ session: URLSession, task: URLSessionTask,
                    didCompleteWithError error: Error?) {
        guard let videoID = task.taskDescription, let error else { return }
        records.set(.failed(message: error.localizedDescription), for: videoID)
    }
}

Rebuild the URL when you play:

let fileURL = URL(fileURLWithPath: NSHomeDirectory()).appending(path: relativePath)

For progress, observe task.progress.fractionCompleted and update the record (throttle UI updates to about once per second).

Pick the quality with the configuration's variant qualifiers (for example, "720p or less"). A 2-hour film can be roughly 1–2 GB at 720p and 3–5 GB at 1080p. Show the size before the user taps.

4. Background downloads and app relaunch

A background URLSession is run by the system, not by your app. Downloads continue when the app is suspended, and even after iOS ends the app to free memory. (If the user force-quits the app from the app switcher, iOS cancels its background transfers.)

When a download finishes while your app is not running, iOS wakes the app in the background:

final class AppDelegate: NSObject, UIApplicationDelegate {
    var backgroundCompletion: (() -> Void)?

    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
        backgroundCompletion = completionHandler     // call it when all events are handled
        _ = DownloadManager.shared                   // recreate the session with the same id
    }
}

// In DownloadManager (URLSessionDelegate):
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    appDelegate.backgroundCompletion?()       // lets iOS take a new app-switcher snapshot
    appDelegate.backgroundCompletion = nil
}

On every launch:

  1. Create the session with the same identifier.
  2. Call session.getAllTasks to find running tasks. Use taskDescription to map them back to videos.
  3. Check the DB: if a record says downloading but no task exists, mark it failed with "Retry".
  4. Check that downloaded files still exist. If not, update the record.

5. DRM, licenses and storage

DRM (Digital Rights Management) stops people from copying paid videos. On Apple platforms it is FairPlay Streaming. Use AVContentKeySession:

  • Streaming: the player asks for a key. Your app sends the request to your key server and returns the response.
  • Offline: ask for a persistable key when you download. Save it on the device. The server decides how long it lasts, for example 30 days, or 48 hours after first play.

Before you play a download offline, check licenseExpiresAt. If it expired and you are online, renew it. If offline, show a clear message: "Connect to the internet to renew this download."

Storage rules:

  • Check free space before you start: volumeAvailableCapacityForImportantUsageKey.
  • Show "Downloads use 4.2 GB" and a "Delete all" button.
  • Delete downloads when their license can no longer be renewed (the title left the catalog).
  • Remove all downloads and keys on logout.

Low Power and memory: pause new downloads in Low Power Mode if the user agreed to that. Decode thumbnails at the cell size. Keep only one player alive.

Trade-offs

DecisionOption AOption BUsual pick
Download toolOwn segment downloaderAVAssetDownloadURLSessionAVFoundation (handles HLS + DRM)
Download qualityHighest: looks greatMedium: smaller, fasterLet the user choose; default medium
Cellular downloadsAllowedWi-Fi onlyWi-Fi only by default, a setting to allow
Save positionTo server every 10 sLocally, upload batchedLocal + batched upload
Position conflictsMergeNewest updatedAt winsNewest wins
PlayersOne per screen or cellOne shared AVPlayerOne shared

What interviewers look for

  • You can explain HLS and ABR in two sentences, and you let AVPlayer do the hard part.
  • Resume logic: save locally often, upload the latest position in batches, newest wins across devices.
  • Offline with AVAssetDownloadURLSession, not a custom segment downloader.
  • Background session details: same identifier on relaunch, handleEventsForBackgroundURLSession, taskDescription to map tasks, relative paths.
  • Respect for the user's data plan, battery and storage.
  • DRM basics: FairPlay, persistable keys, license expiry, renewal online.
  • Clear states for downloads and good error messages.
  • Follow-ups: live streaming, Picture in Picture, AirPlay, autoplay previews in the catalog, "Up Next" and preloading, analytics for stalls (quality of experience).