Video Streaming Platform (YouTube / Netflix)
Upload, transcode and stream video globally with adaptive bitrate and low start-up latency.
Requirements
Functional
- Creators upload video files (up to several GB) with a title, description and visibility.
- Viewers stream any video on web, mobile and TV with adaptive quality.
- Playback resumes where the viewer left off, across devices.
- View counts, likes and basic creator analytics.
- Optional: live streaming, comments, recommendations, subtitles, DRM.
Non-functional
- Start-up latency: first frame within ~1–2 s on a normal connection.
- No rebuffering under fluctuating bandwidth: the player must switch quality smoothly.
- Highly available playback; a transcoding outage delays new uploads but never breaks existing videos.
- Durable: an uploaded original is never lost (11 nines on the object store).
- Global: viewers on every continent get similar start-up latency.
- Upload-to-playable within minutes for typical videos.
Out of scope
- Recommendation ranking, ad insertion, monetisation, moderation workflow details, live streaming internals.
Back-of-envelope estimates
Assumptions
- 1B monthly viewers, 100M DAU, each watches ~5 videos/day of ~5 min average.
- 500K uploads/day, average 10 min long, raw file ~1 GB (phone 1080p, ~15 Mbps).
- Store 5 renditions: 1080p (5 Mbps), 720p (2.5), 480p (1.2), 360p (0.8), 240p (0.4) ≈ 10 Mbps total ≈ 75 MB per minute.
Upload traffic
- 500K/day / 86,400 ≈ 6 uploads/s average, ~20/s peak.
- Ingest bandwidth: 500K × 1 GB/day = 500 TB/day ≈ 46 Gbps average. Direct-to-object-storage multipart, never through API servers.
Storage
- Originals: 500 TB/day → 180 PB/year. Keep originals in cold storage after transcoding.
- Renditions: 500K × 10 min × 75 MB = 375 TB/day → ~137 PB/year, hot tier for the first weeks then infrequent access.
- 5-year total on the order of 1.5 EB; this is why cost tiering and per-title encoding matter.
- Metadata: 500K/day × 5 years ≈ 1B videos × ~2 KB = 2 TB. Trivial; a sharded relational store.
Playback / egress
- 100M DAU × 5 videos × 5 min = 2.5B minutes/day. At an average delivered bitrate of ~2 Mbps: 2.5B × 60 s × 2 Mbps / 8 ≈ 37.5 PB/day.
- 37.5 PB / 86,400 s ≈ 3.5 Tbps average, ~10 Tbps peak. Only a CDN can serve this; origin egress should be <5% of it with a ≥95% edge hit rate.
- Concurrent streams at peak: 100M × 25 min/day / 1,440 min × peak factor 3 ≈ 5M concurrent viewers.
Transcoding compute
- Encoding ~1× realtime per rendition on one core at 1080p: 10 min video × 5 renditions ≈ 50 core-minutes.
- 500K/day × 50 core-min = 25M core-min/day ≈ 17K cores running continuously. Chunked parallel encoding makes a 10-min video ready in ~1–2 min of wall-clock.
Cache
- Top 20% of videos drive ~80% of views. Edge PoPs each need the hot set: a few hundred TB of SSD per PoP, backed by regional caches.
API design
POST /v1/videos
- Body: { title, description, visibility, fileSizeBytes, contentType }
- Returns 201 { videoId, uploadId, partSizeBytes, partUrls[] } — pre-signed multipart URLs to object storage.
- Errors: 400 invalid metadata, 413 file too large, 429 upload quota.
PUT {partUrl} (client → object storage directly)
- Uploads one part; storage returns an ETag per part. Retrying a part is idempotent.
POST /v1/videos/{videoId}/complete
- Body: { uploadId, parts: [{ partNumber, etag }] }
- Finishes the multipart upload, validates size and checksum, enqueues transcoding. Returns 202 { status: 'processing' }.
- Errors: 409 already completed, 422 checksum mismatch.
GET /v1/videos/{videoId}
- Returns { title, durationSec, status, thumbnails[], manifestUrl, renditions[] }.
- manifestUrl points at the CDN (HLS .m3u8 or DASH .mpd), with a short-lived signed token for private videos.
GET {cdn}/v/{videoId}/{rendition}/seg_{n}.m4s (player → CDN)
- Immutable segments; cached for a year. Manifests cached for minutes.
PUT /v1/videos/{videoId}/progress
- Body: { positionSec, deviceId }. Fire-and-forget from the player every ~10 s. Returns 204.
POST /v1/videos/{videoId}/views
- Body: { sessionId, watchedSec }. Emits an event; counts are eventually consistent.
Notes
- All playback traffic goes to the CDN, never to API servers.
- Use signed URLs with expiry for private or paid content; the CDN validates the signature at the edge.
Data model
Entity: video
- video_id (PK, snowflake/ULID), owner_id (indexed), title, description, visibility
- status (uploading | processing | ready | failed), duration_sec, original_key (object store path)
- created_at, published_at, view_count (denormalised, updated in batches)
Entity: rendition
- rendition_id (PK), video_id (FK, indexed), codec, height (240…1080), bitrate_kbps
- manifest_key, segment_prefix, status
Entity: segment / manifest objects (object storage, not the DB)
- Key: v/{video_id}/{rendition}/seg_{n}.m4s, plus master.m3u8 and per-rendition playlists.
- Immutable once written; a re-encode produces a new rendition_id and new keys.
Entity: watch_progress
- (user_id, video_id) PK, position_sec, device_id, updated_at
- Written every ~10 s per active viewer: a key-value store (DynamoDB/Cassandra/Redis with persistence), not the relational metadata DB.
Entity: user
- user_id (PK), handle, email, created_at
Storage choice
- Video metadata: relational (Postgres/MySQL), sharded by video_id, read replicas plus a cache in front. Queries are simple and the dataset is small (a few TB).
- Bytes: object storage (S3/GCS) with lifecycle rules: originals to cold tier after transcoding, renditions to infrequent-access tier after 30–90 days without views.
- Progress and view events: high-write key-value and stream (Kafka) respectively; aggregate views in batch and write back to video.view_count.
High-level design
Upload path
1. Client calls POST /v1/videos. The upload service creates the metadata row with status=uploading and returns pre-signed multipart URLs.
2. The client uploads parts straight to object storage in parallel, retrying individual parts.
3. The client calls /complete. The upload service finalises the multipart object, verifies checksum and size, sets status=processing and publishes a "video uploaded" message to the transcoding queue.
Processing path
1. A transcoding orchestrator picks up the message, probes the file, and splits it into chunks at GOP boundaries.
2. Chunks are encoded in parallel across a worker fleet, one job per (chunk, rendition). Outputs are packaged into HLS/DASH segments plus manifests and written to object storage.
3. Thumbnails, audio tracks and a copyright fingerprint are produced by side tasks. When all tasks finish the orchestrator sets status=ready and pre-warms the CDN if the video is scheduled.
Playback path
1. Client calls GET /v1/videos/{id} and receives the manifest URL (CDN) and metadata.
2. The player fetches the master manifest, then segments from the CDN edge. Cache miss → regional cache → origin shield → object storage.
3. The player measures throughput per segment and switches rendition up or down.
4. Every ~10 s the player sends progress to the progress service and view events to a stream; counts and analytics are aggregated asynchronously.
Why these splits?
- Bytes never pass through API servers: upload and playback are object-store and CDN problems, metadata is a small DB problem.
- Transcoding is bursty and expensive; a queue decouples upload acknowledgement from processing and lets the worker fleet autoscale.
Deep dives
1. Resumable upload with pre-signed URLs
- Multipart upload: the client splits the file into 5–50 MB parts and PUTs each to a pre-signed URL. Parts can be uploaded in parallel and retried independently; storage returns an ETag per part.
- Resume: the client (or a GET /uploads/{id} call) asks the server which part numbers are already present and only sends the missing ones. Pre-signed URLs expire (e.g. 24 h); the server can mint new ones for the same uploadId.
- Completion: the client sends the part list. The server verifies the assembled object's size and a client-supplied checksum (per-part MD5 or a whole-file SHA-256), runs a quick ffprobe to confirm it is decodable, then flips status to processing. Reject early rather than let a corrupt file burn transcoding minutes.
- Abandoned uploads: a lifecycle rule aborts incomplete multipart uploads after N days so parts do not leak storage.
2. The transcoding DAG
- Probe: read codec, resolution, duration, frame rate, keyframe positions.
- Split: cut at GOP (group of pictures) boundaries so every chunk starts on a keyframe and can be decoded independently. Chunks of ~10–30 s balance parallelism against per-task overhead.
- Encode: fan out one task per (chunk, rendition). Each worker is stateless: download chunk, encode, upload output. A failed chunk is retried in isolation; the whole file is never re-encoded.
- Package: stitch encoded chunks into fixed-length segments (2–6 s), generate per-rendition playlists and the master manifest, optionally encrypt for DRM.
- Side tasks: thumbnails and storyboard sprites, audio normalisation, subtitle extraction, fingerprinting.
- Orchestration: a DAG scheduler (Temporal/Airflow-style or a custom state machine in the DB) tracks task state and handles idempotent retries. Priority queues: a scheduled premiere from a large creator jumps the queue; backlog from a bulk importer goes to a low-priority lane.
- Cost: per-title encoding (pick bitrates based on content complexity) saves 20–50% of storage and egress versus a fixed ladder.
3. Adaptive bitrate (ABR)
- The master manifest lists renditions with bandwidth and resolution. The player starts at a conservative rendition for a fast first frame, then measures throughput of each downloaded segment.
- Decision inputs: recent throughput (EWMA), current buffer level, and screen size. Buffer-based rules ("if buffer > 20 s step up, if < 5 s step down") avoid oscillation better than throughput alone.
- Segments align across renditions (same keyframe positions), so switching happens at a segment boundary with no visual glitch.
- Short segments (2 s) make switching and live latency faster; longer segments (6 s) are more cache-friendly and have less manifest overhead. Pick 4 s for VOD.
4. CDN tiering, cache keys and pre-warming
- Three tiers: edge PoP (nearest the viewer, SSD), regional cache (per continent), origin shield (one or two caches in front of object storage so a global miss hits storage once, not once per PoP).
- Cache key: the segment URL, which includes video id, rendition and segment number. Segments are immutable, so set Cache-Control: immutable, max-age=1y. Manifests are short-lived (minutes) because renditions can be added later.
- Hit rate: head content is in every edge; the long tail lives in regional caches. Target ≥95% at the edge so origin egress is a small fraction of total.
- Pre-warming: for a scheduled premiere, push the first few minutes of every rendition to the edges ahead of time, and stagger start times by a few seconds per region to avoid a synchronised thundering herd.
- Request collapsing at every tier: concurrent misses for the same segment produce one upstream fetch.
5. Watch progress, resume and view counting
- Progress: the player writes { position } every 10 s and on pause/close. Key (user_id, video_id) in a KV store; last-writer-wins by timestamp is fine. On open, the player reads the position and seeks. Cross-device works for free because the key is the user, not the device.
- View counting: the player emits a view event after N seconds watched. Events go to a stream; a streaming job counts per video per minute and writes to an analytics store; a periodic job updates video.view_count for display. Dedupe by (session_id, video_id) within a window to resist replay and reloads.
- Hot videos: a viral video gets millions of events per minute; partition the stream by video_id with a salt for the hottest keys so one partition does not become the bottleneck.
6. Copyright fingerprinting at upload
- Generate an audio and video fingerprint (perceptual hashes over short windows) as a side task of the DAG and match against a reference index of rights-holder content.
- Matching is approximate nearest-neighbour search on hash vectors; it runs asynchronously and can block publishing, mute audio, or just attribute revenue depending on policy.
- Keep this out of the hot upload path: the video can be "ready" for the owner while the match job finishes.
Trade-offs & wrap-up
Summary of decisions
- Direct-to-object-storage multipart upload with pre-signed URLs; API servers never carry bytes.
- Chunked, parallel transcoding orchestrated as a DAG with per-chunk retries and priority lanes.
- HLS/DASH over a three-tier CDN with immutable segment URLs and an origin shield.
- Player-driven ABR with buffer-aware switching, 4 s segments for VOD.
- Metadata in sharded relational storage; bytes in object storage with lifecycle tiering; progress in a KV store; views via a stream.
What was given up
- Sub-second latency (segmented HTTP streaming is seconds behind; WebRTC would be needed for interactive live).
- Exact real-time view counts; they are eventually consistent and deduplicated approximately.
- Simplicity: the transcoding pipeline is a distributed system of its own.
With more time
- Live streaming with low-latency HLS, DRM and key rotation, per-title encoding with ML-driven bitrate ladders, recommendation and search, regional storage replication for data residency.