Design a Video Streaming App
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
AVPlayerItema minute before the end. - Watch
item.statusfor errors, andtimeControlStatus == .waitingToPlayAtSpecifiedRateto show a buffering spinner. - Reuse one
AVPlayer. Many players at once use a lot of memory and battery. - Set the audio session to
.playbackand 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:
- Create the session with the same identifier.
- Call
session.getAllTasksto find running tasks. UsetaskDescriptionto map them back to videos. - Check the DB: if a record says
downloadingbut no task exists, mark itfailedwith "Retry". - Check that
downloadedfiles 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
| Decision | Option A | Option B | Usual pick |
|---|---|---|---|
| Download tool | Own segment downloader | AVAssetDownloadURLSession | AVFoundation (handles HLS + DRM) |
| Download quality | Highest: looks great | Medium: smaller, faster | Let the user choose; default medium |
| Cellular downloads | Allowed | Wi-Fi only | Wi-Fi only by default, a setting to allow |
| Save position | To server every 10 s | Locally, upload batched | Local + batched upload |
| Position conflicts | Merge | Newest updatedAt wins | Newest wins |
| Players | One per screen or cell | One shared AVPlayer | One shared |
What interviewers look for
- You can explain HLS and ABR in two sentences, and you let
AVPlayerdo 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,taskDescriptionto 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).