Design a Video Streaming Platform (YouTube / Netflix)
An end-to-end interview-ready walkthrough — from capacity math through deep dives on resumable uploads, transcoding DAGs, adaptive bitrate streaming, CDN edge delivery, view-count pipelines, and recommendation feeds. Structured to mirror a 45-minute system design interview.
Requirements
A video streaming platform is one of the most complex systems you can be asked to design. The scope ranges from a simple "upload and play" service to a multi-petabyte global delivery network with ML-powered recommendations. Anchoring the requirements early prevents you from drowning in scope creep — and signals to the interviewer that you know how to frame a problem before solving it.
Functional Requirements
Core business logic & features
- 01.Video UploadUsers can upload videos of any size (up to 256 GB). Uploads must be resumable — a dropped connection shouldn't restart from zero.
- 02.Video StreamingUsers can watch videos with smooth playback. The player adapts quality based on network conditions (adaptive bitrate).
- 03.Video ProcessingUploaded videos are transcoded into multiple resolutions and codecs to support all devices and bandwidths.
- 04.Video MetadataEach video has a title, description, thumbnail, tags, upload date, and view count. Users can search and browse.
- 05.Comments & ReactionsUsers can like/dislike videos and post comments. Comments are threaded and paginated.
- 06.Personalized FeedHomepage shows recommended videos based on watch history, subscriptions, and trending content.
Non-Functional
System constraints
Availability
99.99% uptime for streaming. A video platform that buffers loses users permanently.
Latency
Video playback starts in <2s. Seek operations complete in <500ms. Global reach.
Scale
500 hours of video uploaded per minute. 1B+ video views per day. 100M+ DAU.
Durability
Zero data loss. Once uploaded, a video must never be lost. 11 nines of object durability.
🎯 Clarifying questions that change the design
Each of these steers you toward a fundamentally different architecture:
- Live streaming or VOD only? Live adds WebRTC/RTMP ingest, real-time transcoding, and sub-second latency targets. VOD is pre-processed and CDN-cached.
- What's the average video length? 5-min clips (TikTok) vs 2-hour movies (Netflix) changes storage, transcoding time, and chunking strategy.
- Global or single-region? Global means multi-region object storage, CDN edge nodes on every continent, and geo-routed DNS.
- How fresh do view counts need to be? Real-time counters vs eventually-consistent aggregates — different pipelines.
- DRM required? Encrypted segments, license servers, Widevine/FairPlay integration — adds an entire subsystem.
- Monetization model? Ads require ad-insertion points in manifests (SSAI). Subscription is simpler.
In scope vs out of scope
| In Scope | Out of Scope | Why |
|---|---|---|
| Upload + transcode + stream (VOD) | Live streaming (RTMP ingest) | Live is a separate system — different latency model entirely |
| Adaptive bitrate (HLS/DASH) | DRM / content encryption | DRM adds Widevine/FairPlay — enterprise feature, not core architecture |
| Resumable chunked uploads | Client-side video editing | Editor is a frontend concern, not a distributed systems one |
| View counts + trending | Real-time ad auction / SSAI | Ad tech is its own 45-minute interview |
| Recommendation feed (high-level) | Full ML model training pipeline | We cover the serving layer, not the training infrastructure |
| Comments + likes | Content moderation ML | Moderation is a product/ML concern, not a systems one |
| CDN-based global delivery | P2P delivery (WebTorrent) | P2P is niche — CDN is the industry standard |
💡 Interviewer signal
The strongest opening is: "I'll focus on the upload-to-playback pipeline — that's where the distributed systems complexity lives. Comments and likes are standard CRUD. The recommendation feed I'll cover at the serving layer, not the training side." This shows you know where the interesting problems are.
Back-of-Envelope Estimation
Video is the heaviest workload on the internet. Unlike a URL shortener where each record is 500 bytes, a single 10-minute video at 1080p consumes ~600 MB raw. The numbers here justify every architectural decision — why we need object storage, why transcoding must be async, why CDNs are non-negotiable, and why storage cost optimization is a first-class concern.
Upload volume
YouTube's public stat is 500 hours of video uploaded per minute. Let's derive what that means for infrastructure.
Given:
500 hours/min uploaded
Average video length: ~5 min (short-form dominates)
→ 500 × 60 / 5 = 6,000 videos uploaded per minute
→ 100 videos/sec (average)
→ Peak: ~300 videos/sec (3× burst)
Average raw file size (1080p, H.264, 8 Mbps):
5 min × 60s × 8 Mbps / 8 = 300 MB per video
Daily raw ingestion:
6,000/min × 60 × 24 = 8.64M videos/day
8.64M × 300 MB = 2.6 PB/day raw upload
After transcoding (4 resolutions × 2 codecs = 8 renditions):
Each rendition averages ~40% of original (lower res = smaller)
Storage multiplier: ~3.2× original
2.6 PB × 3.2 = ~8.3 PB/day total storage (all renditions + original)
Implication:
→ Object storage (S3) is the only option. No filesystem handles this.
→ Storage cost is THE dominant cost. Hot/warm/cold tiering is mandatory.
→ Transcoding 100 videos/sec requires massive parallel compute.
Streaming traffic (reads)
Given:
1B video views/day
Average watch duration: 7 min (not all videos watched fully)
Average bitrate served: 4 Mbps (mix of 480p mobile + 1080p desktop)
Concurrent viewers (peak hour = 2× average):
1B / 86,400 = ~11,600 views starting per second (average)
Peak: ~23,000 views/sec
Average concurrent streams: ~4.8M (7 min avg × 11,600/sec × 60)
Peak concurrent: ~10M streams
Egress bandwidth:
4.8M concurrent × 4 Mbps = 19.2 Tbps average
Peak: ~40 Tbps
Implication:
→ No single origin can serve this. CDN is the product.
→ CDN cache hit rate must be >95% to keep origin load manageable.
→ Origin bandwidth: 40 Tbps × 5% miss = 2 Tbps from origin.
Storage growth
Video storage grows relentlessly. Unlike a URL shortener where you can archive old data, videos are accessed years after upload (the means old viral videos resurface unpredictably).
Daily storage growth: ~8.3 PB/day (all renditions)
Monthly: ~250 PB
Yearly: ~3 EB (exabytes)
With compression + deduplication + cold-tier archival:
Active hot storage (last 30 days): ~250 PB
Warm storage (30-365 days): ~2 EB
Cold storage (>1 year, rarely accessed): grows unbounded
Cost at S3 pricing ($0.023/GB/month for standard):
250 PB hot = $5.75M/month
With Glacier for cold: ~$1/TB/month → massive savings
Implication:
→ Storage tiering is a P0 cost optimization, not a nice-to-have.
→ Videos not watched in 90 days → move to warm/cold tier.
→ Delete lowest-resolution renditions for unwatched videos.
Transcoding compute
Input: 100 videos/sec average, 300 videos/sec peak
Each video → 8 renditions (4 resolutions × 2 codecs)
Each rendition takes ~1× real-time on a GPU (5-min video = 5 min to transcode)
→ With parallelized segment-level transcoding: ~30s per rendition
Workers needed (peak):
300 videos/sec × 8 renditions × 30s per rendition = 72,000 worker-seconds/sec
→ 72,000 concurrent GPU workers at peak
With segment-level parallelism (split into 4s segments):
5 min video = 75 segments
Each segment transcodes in ~2s on GPU
→ 300 videos × 75 segments × 8 renditions = 180,000 segment-jobs/sec
→ But each finishes in 2s, so steady-state: ~360,000 concurrent segment jobs
Implication:
→ Transcoding is the most expensive compute workload.
→ Must be async (queue-based). Blocking upload on transcode = terrible UX.
→ Spot/preemptible instances save 60-70% on GPU cost.
→ Custom ASICs (YouTube VCU, Netflix ASIC) at hyperscale.
Metadata storage
Per-video metadata:
video_id : 8 bytes (UUID or Snowflake)
title : 100 bytes avg
description : 500 bytes avg
uploader_id : 8 bytes
duration_ms : 4 bytes
upload_status : 1 byte
created_at : 8 bytes
thumbnail_url : 100 bytes
tags (JSONB) : 200 bytes
manifest_urls : 300 bytes
view_count : 8 bytes
─────────────────────────────
≈ 1.2 KB per video
Total metadata (5 years):
8.64M videos/day × 365 × 5 = ~15.8B videos
15.8B × 1.2 KB = ~19 TB
Implication:
→ Metadata fits comfortably in a sharded relational DB.
→ The hard problem is video bytes, not metadata bytes.
→ Shard by hash(video_id) — point lookups dominate.
🧮 The numbers that drive the design
Quick Revision Cheat Sheet
Upload rate: 500 hrs/min → 100 videos/sec avg
Daily raw ingestion: ~2.6 PB/day
Storage with renditions: ~8.3 PB/day
Streaming views: 1B/day → 11.6K views/sec avg
Peak concurrent streams: ~10M
Peak egress bandwidth: ~40 Tbps
CDN cache hit target: >95% (origin sees <2 Tbps)
Transcoding workers (peak): ~72K concurrent GPU jobs
Metadata (5 years): ~19 TB — fits in sharded Postgres
Dominant cost: Storage > Egress > Compute
💡 What interviewers want to hear
Don't memorize these numbers. Derive them live: "500 hours/min at 5-min average = 6000 videos/min = 100/sec. At 300 MB each, that's 30 GB/sec ingestion. That's why we need S3 — no filesystem handles 30 GB/sec sustained writes." The reasoning matters more than the precision.
API Design
The API surface for a video platform is larger than a URL shortener but the critical paths are few: upload initiation, chunk upload, stream (manifest fetch), and metadata retrieval. Everything else (comments, likes, subscriptions) is standard CRUD that doesn't need deep discussion in an interview.
Core endpoints
| Method | Path | Purpose | Latency target |
|---|---|---|---|
POST | /api/v1/videos/upload-init | Initialize resumable upload, get presigned URLs | < 200ms |
PUT | /api/v1/videos/:id/chunks/:chunkIndex | Upload a single chunk (5-50 MB) | Network-bound |
POST | /api/v1/videos/:id/complete | Signal upload complete, trigger processing | < 100ms |
GET | /api/v1/videos/:id/manifest | Fetch HLS/DASH manifest (master playlist) | < 50ms (CDN-cached) |
GET | /api/v1/videos/:id | Fetch video metadata (title, description, counts) | < 50ms |
GET | /api/v1/feed | Personalized recommendation feed | < 200ms |
POST | /api/v1/videos/:id/views | Record a view event (fire-and-forget) | < 10ms (async) |
Upload initialization (resumable)
The upload flow uses a — the client requests upload credentials, splits the file into chunks, and uploads each chunk independently. If the connection drops, the client queries which chunks succeeded and resumes from there.
POST /api/v1/videos/upload-init HTTP/1.1
Host: api.vidstream.io
Authorization: Bearer <jwt>
Content-Type: application/json
{
"title": "System Design Interview Tips",
"description": "How to ace the YouTube design question",
"file_name": "interview-tips.mp4",
"file_size_bytes": 524288000, // 500 MB
"content_type": "video/mp4",
"chunk_size_bytes": 10485760, // 10 MB per chunk
"tags": ["system-design", "interview"]
}
--- 201 Created ---
{
"video_id": "vid_8f4b3c2a",
"upload_id": "upl_9x7k2m", // S3 multipart upload ID
"chunk_count": 50,
"presigned_urls": [ // One per chunk
{ "chunk_index": 0, "url": "https://s3.../part0?X-Amz-Signature=...", "expires_at": "..." },
{ "chunk_index": 1, "url": "https://s3.../part1?X-Amz-Signature=...", "expires_at": "..." },
// ... 48 more
],
"status": "uploading"
}
--- 400 Bad Request ---
{ "error": "FILE_TOO_LARGE", "message": "Maximum file size is 256 GB" }
--- 429 Too Many Requests ---
{ "error": "UPLOAD_LIMIT", "message": "Max 10 concurrent uploads per user" }
Upload completion
After all chunks are uploaded directly to S3 via , the client signals completion. The server verifies all chunks are present, assembles the multipart upload in S3, and enqueues the transcoding job.
POST /api/v1/videos/vid_8f4b3c2a/complete HTTP/1.1
Authorization: Bearer <jwt>
Content-Type: application/json
{
"upload_id": "upl_9x7k2m",
"chunks": [
{ "chunk_index": 0, "etag": ""abc123..."" },
{ "chunk_index": 1, "etag": ""def456..."" },
// ... all 50 ETags from S3 responses
]
}
--- 202 Accepted ---
{
"video_id": "vid_8f4b3c2a",
"status": "processing",
"estimated_ready_at": "2026-05-17T10:15:00Z",
"webhook_url": "/api/v1/videos/vid_8f4b3c2a/status"
}
--- 409 Conflict ---
{ "error": "CHUNKS_MISSING", "missing": [12, 37], "message": "2 chunks not yet uploaded" }
Stream (manifest fetch)
The video player fetches the master manifest to discover available quality levels, then fetches individual segments as playback progresses. This is the hot path — served entirely from CDN edge after the first request.
GET /api/v1/videos/vid_8f4b3c2a/manifest HTTP/1.1
Host: cdn.vidstream.io
Accept: application/vnd.apple.mpegurl
--- 200 OK ---
Content-Type: application/vnd.apple.mpegurl
Cache-Control: public, max-age=86400
X-CDN-Cache: HIT
#EXTM3U
#EXT-X-VERSION:4
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.4d401e,mp4a.40.2"
/segments/vid_8f4b3c2a/360p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=854x480,CODECS="avc1.4d401f,mp4a.40.2"
/segments/vid_8f4b3c2a/480p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2"
/segments/vid_8f4b3c2a/720p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
/segments/vid_8f4b3c2a/1080p/playlist.m3u8
Resume upload (query progress)
If the client's connection drops mid-upload, it can query which chunks have been successfully received and resume from there — avoiding re-uploading potentially gigabytes of data.
GET /api/v1/videos/vid_8f4b3c2a/upload-status HTTP/1.1
Authorization: Bearer <jwt>
--- 200 OK ---
{
"video_id": "vid_8f4b3c2a",
"upload_id": "upl_9x7k2m",
"status": "uploading",
"total_chunks": 50,
"uploaded_chunks": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
"missing_chunks": [12, 13, 14, ..., 49],
"presigned_urls": [
{ "chunk_index": 12, "url": "https://s3.../part12?...", "expires_at": "..." },
// fresh presigned URLs for remaining chunks
]
}
🔑 Why presigned URLs instead of proxying through the server
A 500 MB video uploaded through the application server means 500 MB of ingress bandwidth, 500 MB of memory buffering, and the server becomes a bottleneck. With presigned URLs, the client uploads directly to S3 — the application server only handles the lightweight coordination (init, status, complete). This is how YouTube, Vimeo, and every production video platform works.
💡 Interview tip: mention the upload protocol early
Saying "clients upload directly to object storage via presigned URLs with multipart upload" in the first 2 minutes of the API discussion signals you've built real systems. It also naturally leads into the resumability deep dive.
Data Model
The data model for a video platform splits into two fundamentally different worlds: lightweight metadata (relational, queryable, indexed) and heavyweight binary data (object storage, immutable, CDN-served). The schema design must reflect this split — never store video bytes in a database, and never query object storage for metadata.
Video metadata (PostgreSQL — sharded)
CREATE TABLE videos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uploader_id UUID NOT NULL REFERENCES users(id),
title VARCHAR(500) NOT NULL,
description TEXT,
duration_ms INT, -- populated after transcoding
status VARCHAR(20) NOT NULL DEFAULT 'uploading',
-- uploading | processing | ready | failed | deleted
visibility VARCHAR(10) NOT NULL DEFAULT 'public',
-- public | unlisted | private
thumbnail_url TEXT,
manifest_url TEXT, -- set after transcoding completes
original_url TEXT NOT NULL, -- S3 path to raw upload
file_size_bytes BIGINT NOT NULL,
tags JSONB DEFAULT '[]',
view_count BIGINT NOT NULL DEFAULT 0, -- denormalized, async-updated
like_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ,
published_at TIMESTAMPTZ -- when status → ready
);
-- Primary access pattern: fetch by ID (player page)
-- Already covered by PK
-- Secondary: user's videos (channel page)
CREATE INDEX idx_videos_uploader ON videos (uploader_id, created_at DESC)
WHERE status != 'deleted';
-- Secondary: trending/search (read from search index, not this)
-- Secondary: processing queue (internal)
CREATE INDEX idx_videos_status ON videos (status, created_at)
WHERE status IN ('uploading', 'processing');
Transcoding jobs (PostgreSQL or dedicated queue)
Each video spawns multiple transcoding jobs — one per rendition. This table tracks the DAG of work and enables resumability if a worker crashes mid-transcode.
CREATE TABLE transcoding_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
video_id UUID NOT NULL REFERENCES videos(id),
resolution VARCHAR(10) NOT NULL, -- 360p, 480p, 720p, 1080p, 4k
codec VARCHAR(20) NOT NULL, -- h264, h265, vp9, av1
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending | segmenting | transcoding | assembling | done | failed
segment_count INT, -- total segments for this rendition
segments_done INT DEFAULT 0, -- progress tracking
worker_id VARCHAR(50), -- which worker claimed this job
output_url TEXT, -- S3 path to transcoded segments
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
retry_count INT DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
-- Workers poll for pending jobs
CREATE INDEX idx_jobs_pending ON transcoding_jobs (status, created_at)
WHERE status = 'pending';
-- Dashboard: all jobs for a video
CREATE INDEX idx_jobs_video ON transcoding_jobs (video_id);
Video segments (object storage — not a DB table)
Segments are stored in S3 with a predictable path convention. There is no database table for individual segments — the manifest file serves as the "index" that references segment URLs. This is critical: with 75 segments × 8 renditions = 600 objects per video, storing segment metadata in a DB would create billions of rows for no benefit.
Bucket: vidstream-segments-{region}
Path structure:
/raw/{video_id}/original.mp4 -- raw upload
/segments/{video_id}/{resolution}/{codec}/
├── playlist.m3u8 -- media manifest for this rendition
├── segment_000.ts -- 4-second segment
├── segment_001.ts
├── segment_002.ts
└── ...
/manifests/{video_id}/master.m3u8 -- master manifest (all renditions)
/thumbnails/{video_id}/thumb_{timestamp}.jpg -- auto-generated thumbnails
Example:
/segments/vid_8f4b3c2a/1080p/h264/playlist.m3u8
/segments/vid_8f4b3c2a/1080p/h264/segment_042.ts
/segments/vid_8f4b3c2a/720p/vp9/segment_042.ts
View events (ClickHouse — analytics)
View events are high-volume (11.6K/sec average, 23K/sec peak) and write-heavy. They flow through Kafka into for aggregation queries. Never write view events synchronously to Postgres — it would create massive write amplification on the videos table.
CREATE TABLE view_events (
event_id UUID,
video_id UUID,
viewer_id UUID, -- nullable for anonymous viewers
watched_ms UInt32, -- how much they actually watched
total_ms UInt32, -- video duration
device_type LowCardinality(String), -- mobile, desktop, tv, tablet
country_code FixedString(2),
quality LowCardinality(String), -- 360p, 480p, 720p, 1080p
referrer String, -- search, recommendation, direct, external
session_id UUID,
created_at DateTime64(3)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (video_id, created_at)
TTL created_at + INTERVAL 2 YEAR;
Why these storage choices
| Data | Store | Why |
|---|---|---|
| Video metadata | PostgreSQL (sharded by video_id) | Relational queries, ACID for status transitions, 19 TB over 5 years — manageable |
| Video bytes + segments | S3 (object storage) | Unlimited scale, 11 nines durability, presigned URL access, lifecycle policies |
| Manifests | S3 + CDN edge cache | Static files, immutable after generation, served millions of times |
| View events | ClickHouse (via Kafka) | 11K+ events/sec, columnar for aggregation, TTL for cost control |
| View counters (real-time) | Redis | Atomic INCR, sub-ms reads for displaying on video page |
| User watch history | Cassandra / DynamoDB | Write-heavy (every play), partition by user_id, time-sorted |
| Search index | Elasticsearch | Full-text search on title/description/tags, fuzzy matching |
🔑 The view_count denormalization
The view_count column in the videos table is a denormalized counter updated asynchronously by a Flink job that aggregates from ClickHouse every few minutes. It's eventually consistent — the video page shows a count that may be 2-3 minutes stale. This is acceptable because YouTube itself shows approximate counts ("1.2M views") and updates them lazily.
💡 What to tell the interviewer
"I separate the data model into three tiers: metadata in Postgres for queryability, video bytes in S3 for durability and scale, and analytics events in ClickHouse for aggregation. Each tier has different consistency, durability, and access patterns — mixing them in one store would be a disaster."
High-Level Architecture
The architecture splits into three independent paths — each with its own latency target, consistency model, and scaling axis. This separation is the most important structural decision because video upload (write-heavy, async, tolerates minutes of delay), video streaming (read-heavy, latency-critical, must never buffer), and analytics (high-throughput, eventually consistent) have fundamentally incompatible requirements. Coupling them means the slowest path drags down the fastest.
Path 1: Upload (ingest + process)
When a creator uploads a video, the flow is: client → presigned URL → S3 direct upload → completion signal → transcoding queue → async workers → segments + manifests written to S3 → video status flipped to "ready". The entire pipeline is asynchronous — the user gets a "processing" status immediately and is notified when the video is live.
Client
Chunked upload
Upload Service
Init + coordinate
S3 (raw)
Store original
Kafka
transcode-jobs topic
Transcoding DAG
Segment + encode
S3 (segments)
HLS/DASH output
The upload path targets <5 minutes end-to-end for a 10-minute video (from upload complete to playable). The bottleneck is transcoding — network transfer to S3 is fast (multi-gigabit), but encoding 8 renditions takes real compute time. The key insight: we can pipeline segmentation and transcoding so that the first segments are ready before the last ones finish.
Path 2: Streaming (the hot path)
This is where 99% of user-facing traffic lives. A viewer clicks play and the player fetches the master manifest, selects a quality level, then fetches segments sequentially as playback progresses. The entire flow is served from — the origin (S3) is only hit on cache misses for unpopular content.
Player
Video.js / ExoPlayer
CDN Edge
Cache hit (95%+)
Origin Shield
Regional cache
S3
Fallback (cold content)
The streaming path targets <2s time-to-first-frame and <500ms seek latency. With CDN cache hit rates above 95%, the origin sees less than 5% of total streaming traffic. For popular videos, the CDN serves everything — S3 is never touched after the initial cache fill.
Path 3: Analytics + Engagement (async)
Every play event, seek, pause, quality switch, and buffer event is captured — but this must never slow down playback. The player batches events and sends them to a lightweight ingestion endpoint that produces to asynchronously. Downstream, aggregates events into view counts, watch-time metrics, and recommendation signals.
Player SDK
Batch events
Ingestion API
Validate + produce
Kafka
view-events topic
Flink
Aggregate + enrich
Redis
Real-time counters
ClickHouse
Historical analytics
Component responsibilities
Each component has a single, clear job. If you can't describe what it does in one sentence, it's doing too much.
Upload Service
Coordinates resumable uploads. Generates presigned URLs, tracks chunk progress, signals completion to Kafka. Stateless — scales on request rate.
Transcoding Orchestrator
Manages the DAG of transcoding work. Splits video into segments, fans out to workers, tracks progress, assembles manifests. Uses Temporal for workflow orchestration.
Transcoding Workers
Stateless GPU instances running ffmpeg. Pull segment jobs from queue, transcode, write output to S3. Auto-scaled on queue depth. Spot instances for cost.
CDN (CloudFront / Akamai)
Caches manifests and segments at 200+ global PoPs. Serves 95%+ of streaming traffic. Origin shield reduces S3 load. Cache key: path-based (no query params).
S3 (Object Storage)
Stores raw uploads, transcoded segments, manifests, and thumbnails. Lifecycle policies move cold content to Glacier. 11 nines durability.
Metadata Service
Serves video metadata (title, description, counts) from PostgreSQL + Redis cache. Powers the video page, search results, and feed cards.
Kafka (Event Bus)
Decouples upload-complete events, view events, and recommendation signals. Topics: transcode-jobs, view-events, recommendation-signals. Retention: 7 days.
Recommendation Service
Serves personalized feed. Candidate generation (ANN search) → ranking (ML model) → re-ranking (diversity, freshness). Precomputed for active users, on-demand for others.
Why three separate paths matter
The streaming SLA (<2s to first frame) is incompatible with synchronous transcoding or analytics writes. A transcoding failure should never affect a viewer watching an already-processed video. A Kafka outage should never cause buffering. By separating the paths:
- Consistency model — streaming is AP (serve from cache even if origin is down); upload is CP (must not lose data); analytics is eventually consistent
- SLO — streaming: 99.99% / <2s; upload: 99.9% / <5min processing; analytics: 99% / minutes of lag acceptable
- Scaling axis — streaming scales on concurrent viewers (CDN capacity); upload scales on ingestion rate (S3 throughput + GPU workers); analytics scales on event throughput (Kafka partitions)
- Failure isolation — CDN down? Origin serves directly (degraded). Transcoding down? Existing videos still play. Kafka down? Views still work, counts lag.
Total budget: <2s to first frame
0ms ─ DNS resolution (cached after first visit)
50ms ─ TLS handshake (HTTP/2 keep-alive eliminates on subsequent)
100ms ─ Fetch master manifest from CDN edge (cache hit: ~20ms)
150ms ─ Player parses manifest, selects initial quality (bandwidth probe)
200ms ─ Fetch first segment from CDN edge (~800KB for 4s at 1.6 Mbps)
├─ CDN hit (95%): ~100ms (edge is <50ms RTT away)
└─ CDN miss (5%): ~300ms (origin shield → S3)
1200ms ─ First segment decoded and rendered
1500ms ─ Playback begins, next segment prefetched in parallel
───────────────────────────────────────────────
Total on CDN hit: ~1.2s ✅
Total on CDN miss: ~1.8s ✅ (still under 2s budget)
Seek operation:
Player requests segment at new timestamp from CDN
Budget: <500ms (CDN hit) / <800ms (CDN miss)
💡 What to say to the interviewer
"The three-path split lets me pick different consistency, availability, and latency targets per path. Streaming is AP — I serve from CDN cache even if the origin is unreachable. Upload is CP — I must never lose a video. Analytics is eventual — a 2-minute lag on view counts is invisible to users." This single framing signals senior-level architectural thinking.
Video Upload Pipeline
Uploading a 500 MB video over a flaky mobile connection is the hardest UX problem in this system. A single dropped packet shouldn't restart a 20-minute upload. The solution is chunked, resumable, direct-to-S3 uploads — the application server never touches the video bytes, only coordinates the process.
End-to-end upload flow
Client requests upload initialization
The client sends video metadata (title, file size, content type) to the Upload Service. The service validates the request, creates a video record in Postgres with status='uploading', initiates an S3 Multipart Upload, and returns presigned URLs for each chunk.
Client uploads chunks directly to S3
Each chunk (10 MB default) is uploaded directly to S3 using the presigned PUT URL. The client uploads chunks in parallel (up to 6 concurrent) for maximum throughput. S3 returns an ETag for each successful chunk — the client stores these locally for the completion step. No application server bandwidth is consumed.
Progress tracking and resumability
The client periodically reports progress to the Upload Service (which chunks succeeded). If the connection drops, the client calls the upload-status endpoint to get the list of completed chunks, requests fresh presigned URLs for the remaining ones, and resumes. This is the protocol in action.
Client signals completion
After all chunks are uploaded, the client sends the list of ETags to the Upload Service. The service calls S3's CompleteMultipartUpload API, which assembles the chunks into the final object. If any chunk is missing, the API returns an error and the client can retry just that chunk.
Trigger transcoding pipeline
Once S3 confirms the object is assembled, the Upload Service updates the video status to 'processing' and publishes a message to the transcode-jobs Kafka topic. This decouples upload from processing — the user gets immediate feedback while transcoding happens asynchronously.
Notify creator when ready
The Transcoding Orchestrator updates the video status to 'ready' and sends a push notification / webhook to the creator. The video is now playable.
Upload coordination logic
The Upload Service is lightweight — it never touches video bytes. Its job is purely coordination: validate, generate presigned URLs, track progress, and trigger downstream processing.
async function initUpload(req: InitUploadRequest): Promise<InitUploadResponse> {
// 1. Validate
if (req.fileSizeBytes > MAX_FILE_SIZE) throw new FileTooLargeError();
if (!ALLOWED_CONTENT_TYPES.includes(req.contentType)) throw new InvalidContentTypeError();
// 2. Check user upload limits (max 10 concurrent)
const activeUploads = await db.countActiveUploads(req.userId);
if (activeUploads >= MAX_CONCURRENT_UPLOADS) throw new UploadLimitError();
// 3. Create video record
const video = await db.createVideo({
uploaderId: req.userId,
title: req.title,
description: req.description,
fileSizeBytes: req.fileSizeBytes,
status: 'uploading',
});
// 4. Initiate S3 multipart upload
const chunkCount = Math.ceil(req.fileSizeBytes / req.chunkSizeBytes);
const { uploadId } = await s3.createMultipartUpload({
bucket: RAW_UPLOADS_BUCKET,
key: `raw/${video.id}/original.${getExtension(req.fileName)}`,
contentType: req.contentType,
});
// 5. Generate presigned URLs for each chunk
const presignedUrls = await Promise.all(
Array.from({ length: chunkCount }, (_, chunkIndex) =>
s3.getSignedUrl('uploadPart', {
bucket: RAW_UPLOADS_BUCKET,
key: `raw/${video.id}/original.${getExtension(req.fileName)}`,
uploadId,
partNumber: chunkIndex + 1,
expiresIn: 3600, // 1 hour
}).then(url => ({ chunkIndex, url, expiresAt: Date.now() + 3600_000 }))
)
);
// 6. Store upload state
await db.createUploadSession({
videoId: video.id,
uploadId,
chunkCount,
chunkSizeBytes: req.chunkSizeBytes,
});
return { videoId: video.id, uploadId, chunkCount, presignedUrls, status: 'uploading' };
}
Chunk size selection
Chunk size is a trade-off between resumability granularity and overhead. Smaller chunks mean less data re-uploaded on failure, but more HTTP requests and S3 API calls. The sweet spot depends on the client's network conditions.
Chunk size options:
5 MB — Best for mobile / flaky connections. 100 chunks for 500 MB file.
More S3 API calls ($0.005 per 1000 PUTs), but minimal re-upload on failure.
10 MB — Default. Good balance. 50 chunks for 500 MB.
50 MB — Best for high-bandwidth desktop uploads. 10 chunks for 500 MB.
Fewer API calls, but a failure wastes up to 50 MB of progress.
Adaptive strategy (what YouTube does):
Start with 10 MB chunks.
If 3 consecutive chunks succeed in <2s each → increase to 25 MB.
If a chunk fails or takes >30s → decrease to 5 MB.
This adapts to the user's actual bandwidth without configuration.
S3 constraints:
Minimum part size: 5 MB (except last part)
Maximum part size: 5 GB
Maximum parts per upload: 10,000
→ Maximum file size: 10,000 × 5 GB = 50 TB (well above our 256 GB limit)
Failure modes and recovery
🔥 Client disconnects mid-upload
S3 multipart uploads persist indefinitely until completed or aborted. The client can resume hours or days later by querying the upload-status endpoint. Presigned URLs expire after 1 hour, but the service generates fresh ones on resume. Incomplete uploads older than 7 days are cleaned up by an S3 lifecycle policy to avoid storage cost leaks.
🔥 Upload Service crashes mid-coordination
The upload state (video record + upload session) is persisted in Postgres. Any Upload Service instance can resume coordination. The service is stateless — it reads state from DB on every request. No in-memory state is lost on crash.
🔥 S3 rejects a chunk (checksum mismatch)
The client retries that specific chunk with exponential backoff. S3 returns a Content-MD5 mismatch error if the data was corrupted in transit. The client re-reads the chunk from disk and re-uploads. Other chunks are unaffected.
🔥 Duplicate upload (same video twice)
We don't deduplicate at the upload level — each upload gets a unique video_id. Content-based deduplication (hash the file, check if it exists) is a cost optimization for later. At YouTube scale, Content ID handles this for copyright, not for storage savings.
💡 The pipelining optimization (advanced)
Instead of waiting for the entire upload to finish before starting transcoding, you can pipeline: as each chunk arrives in S3, an S3 event notification triggers segmentation of that chunk immediately. The transcoding workers start processing the first segments while the client is still uploading the last chunks. This reduces end-to-end time from "upload time + transcode time" to "max(upload time, transcode time)". YouTube does this. Mention it as an optimization — don't make it your initial design.
Transcoding & Post-Processing
Transcoding is the most compute-intensive operation in the entire system. A single 10-minute 1080p video needs to be converted into 8+ renditions (4 resolutions × 2 codecs), generating hundreds of segments. At 100 videos/sec, this requires tens of thousands of concurrent GPU workers. The key insight: this work forms a — steps with dependencies must be sequential, but independent work (transcoding different segments) can be massively parallelized.
The transcoding DAG
When a video upload completes, the orchestrator builds a DAG of work. Each node is a task; edges represent dependencies. The orchestrator (Temporal) schedules tasks as their dependencies complete.
Probe
Detect codec, resolution, duration
Segment
Split into 4s chunks
Transcode
N segments × M renditions
Audio
Extract + normalize
Manifest
Generate HLS/DASH playlists
Thumbnail
Extract key frames
Step-by-step breakdown
Step 1: PROBE (1 worker, ~2s)
─ Read file headers with ffprobe
─ Extract: codec, resolution, bitrate, duration, frame rate, audio tracks
─ Decide which output renditions to generate based on source quality
(don't upscale — if source is 720p, skip 1080p and 4K renditions)
Step 2: SEGMENT (1 worker, ~30s for 10-min video)
─ Split the raw video into 4-second segments at keyframe boundaries
─ Output: segment_000.ts through segment_149.ts (for 10-min video)
─ Write segments to S3 temp path: /temp/{video_id}/raw_segments/
─ This step is sequential — must complete before transcoding begins
Step 3: TRANSCODE (N×M workers in parallel, ~2-5s per segment per rendition)
─ For each segment × each rendition: spawn a worker
─ Example: 150 segments × 8 renditions = 1,200 parallel tasks
─ Each worker:
1. Downloads one raw segment from S3 (~4s of video = ~2MB)
2. Transcodes to target resolution + codec (ffmpeg)
3. Uploads output segment to S3: /segments/{video_id}/{res}/{codec}/segment_042.ts
4. Reports completion to orchestrator
─ Workers are stateless — any worker can process any segment
─ Failed segments are retried on a different worker (idempotent)
Step 4: AUDIO PROCESSING (parallel with video transcoding)
─ Extract audio track(s) from original
─ Normalize loudness (EBU R128 standard)
─ Encode to AAC at multiple bitrates (64kbps, 128kbps, 256kbps)
─ Generate subtitle tracks if speech-to-text is enabled
Step 5: MANIFEST GENERATION (1 worker, ~5s, after all transcoding done)
─ Generate media playlists for each rendition (lists all segments)
─ Generate master playlist (lists all renditions with bandwidth/resolution)
─ Write to S3: /manifests/{video_id}/master.m3u8
─ This is the "assembly" step — must wait for ALL segments to complete
Step 6: THUMBNAIL GENERATION (parallel with everything after probe)
─ Extract frames at 25%, 50%, 75% of duration
─ Run scene-detection to find visually interesting frames
─ Generate multiple sizes (120px, 320px, 640px) for different contexts
─ Upload to S3 + CDN pre-warm
Step 7: FINALIZE
─ Update video record: status='ready', manifest_url, duration_ms, thumbnail_url
─ Publish 'video-ready' event to Kafka
─ Notify creator via push notification / email
─ Clean up temp segments from S3
Codec and resolution matrix
Not every video needs every rendition. The orchestrator decides which renditions to generate based on the source quality and the platform's device distribution. Generating a 4K rendition from a 720p source is pointless (upscaling wastes compute and storage).
| Resolution | H.264 bitrate | H.265/VP9 bitrate | Target device |
|---|---|---|---|
| 360p | 800 Kbps | 500 Kbps | Low-bandwidth mobile, 2G/3G |
| 480p | 1.4 Mbps | 900 Kbps | Standard mobile, emerging markets |
| 720p | 2.8 Mbps | 1.8 Mbps | Desktop, good mobile connections |
| 1080p | 5 Mbps | 3.2 Mbps | Desktop, smart TVs, fiber |
| 4K (2160p) | 16 Mbps | 10 Mbps | 4K TVs, high-end displays |
Why H.265/VP9 alongside H.264?
is universally supported but less efficient. offer 30-50% better compression — meaning the same quality at half the bandwidth. For a platform serving 40 Tbps, a 30% bandwidth reduction saves millions in CDN egress costs monthly. The trade-off: encoding is 3-5× slower (more GPU time) and not all devices support newer codecs. The solution is to serve H.264 as fallback and H.265/VP9 to capable devices.
Orchestration with Temporal
The transcoding DAG is orchestrated by . Each video upload triggers a workflow that manages the entire DAG. Temporal handles retries, timeouts, and state persistence — if the orchestrator itself crashes, the workflow resumes from the last checkpoint.
// Simplified Temporal workflow for video transcoding
async function transcodeVideoWorkflow(videoId: string, s3Key: string): Promise<void> {
// Step 1: Probe the source file
const probeResult = await activities.probeVideo(videoId, s3Key);
// { codec: 'h264', resolution: '1920x1080', duration: 600000, fps: 30 }
// Step 2: Determine renditions based on source quality
const renditions = selectRenditions(probeResult);
// e.g., [360p/h264, 480p/h264, 720p/h264, 1080p/h264, 720p/vp9, 1080p/vp9]
// Step 3: Segment the source video
const segments = await activities.segmentVideo(videoId, s3Key, {
segmentDuration: 4, // seconds
splitAtKeyframes: true,
});
// Returns: { count: 150, tempPath: '/temp/{videoId}/raw_segments/' }
// Step 4: Fan-out — transcode all segments × all renditions in parallel
const transcodePromises = renditions.flatMap((rendition) =>
Array.from({ length: segments.count }, (_, segmentIndex) =>
activities.transcodeSegment(videoId, segmentIndex, rendition)
)
);
// Temporal handles parallelism limits, retries, and timeouts
await Promise.all(transcodePromises);
// Step 5: Audio processing (ran in parallel with step 4)
// (Temporal supports parallel branches in the DAG)
await activities.processAudio(videoId, s3Key);
// Step 6: Generate manifests
const manifestUrl = await activities.generateManifests(videoId, renditions, segments.count);
// Step 7: Generate thumbnails
const thumbnailUrl = await activities.generateThumbnails(videoId, s3Key, probeResult.duration);
// Step 8: Finalize
await activities.finalizeVideo(videoId, {
status: 'ready',
manifestUrl,
thumbnailUrl,
durationMs: probeResult.duration,
});
// Step 9: Cleanup temp files
await activities.cleanupTempSegments(videoId);
}
Cost optimization strategies
1. Spot/Preemptible instances (60-70% savings)
─ Transcoding is stateless and idempotent — perfect for spot
─ If a spot instance is reclaimed, the segment job is retried elsewhere
─ Mix: 80% spot + 20% on-demand for guaranteed baseline capacity
2. Tiered transcoding priority
─ Channels with >10K subscribers: transcode immediately (all renditions)
─ New uploads from small channels: transcode 720p first (playable fast)
─ Generate remaining renditions in background (lower priority queue)
─ If video gets 0 views in 7 days: skip remaining renditions entirely
3. Codec selection based on popularity
─ All videos: H.264 (universal compatibility)
─ Videos with >1K views: add VP9/H.265 (bandwidth savings justify cost)
─ Videos with >100K views: add AV1 (best compression, slowest to encode)
4. Resolution capping
─ Never upscale — if source is 720p, don't generate 1080p or 4K
─ For audio-only content (podcasts, music): skip video transcoding entirely
5. GPU instance right-sizing
─ Short videos (<1 min): small GPU instances (T4)
─ Long videos (>30 min): large GPU instances (A100) for parallelism
─ Batch small videos together on one instance to amortize startup cost
🎯 Why segment-level parallelism is the key insight
Without segmentation, transcoding a 10-minute video into 8 renditions takes 8 × 10 minutes = 80 minutes sequentially. With segment-level parallelism (150 segments × 8 renditions = 1,200 parallel tasks, each taking ~2-5 seconds), the entire job completes in under 30 seconds wall-clock time. This is the difference between "video ready in 5 minutes" and "video ready in 80 minutes." Interviewers love this insight.
💡 Poison video handling
Some videos crash ffmpeg — corrupted files, exotic codecs, or adversarial inputs. The orchestrator must handle this gracefully: after 3 retries on different workers, mark the rendition as failed. If all renditions fail, mark the video as "processing_failed" and notify the creator with a helpful error message. Never let a poison video block the queue or crash the system.
Adaptive Bitrate Streaming
Once a video is stored in S3, users need to watch it. The system fetches VideoMetadata from the database — that record contains the URL(s) necessary to stream the video. But how exactly does the client play the video? There are three approaches, each progressively better. Understanding why the first two fail is what makes the final solution click.
❌ Bad: Download the entire video file
The simplest approach: the client downloads the whole video from S3 via a single HTTP GET, then plays it locally. This isn't "streaming" — it's download-then-play.
| Problem | Impact |
|---|---|
| Must download entire file before playback | A 10 GB video takes 13+ minutes on 100 Mbps — unacceptable wait time |
| Single HTTP request = single point of failure | Network disruption mid-download loses all progress. Must restart from zero. |
| No quality adaptation | If bandwidth drops mid-download, the user is stuck waiting — no fallback to lower quality |
| Wastes bandwidth | User watches 30 seconds and leaves — but downloaded the entire 2-hour file |
🎯 Why this fails
This approach treats video like a file download, not a real-time experience. Users expect playback to start in seconds, not minutes. No production video platform uses this approach.
⚠️ Better: Download segments incrementally
Instead of downloading the entire file, the client downloads small segments (a few seconds each) sequentially. The player starts playback after the first segment arrives — no need to wait for the full file. In the background, it prefetches upcoming segments so playback continues seamlessly.
The client picks a video format (e.g., 1080p H.264) based on the user's device and preferences, then fetches segments one by one from that single rendition. Playback starts in seconds instead of minutes.
| Improvement over download | Remaining problem |
|---|---|
| Playback starts after first segment (~2-4s) | If bandwidth drops, the fixed quality causes buffering |
| Network failure only loses one segment, not the whole file | No adaptation — stuck at one quality level for the entire session |
| Only downloads what the user actually watches | User on 3G trying to stream 1080p = constant stalling |
🎯 Why this is better but not great
Segmented download solves the startup latency and resilience problems, but it doesn't handle the real world — where bandwidth fluctuates constantly (entering a tunnel, switching from WiFi to cellular, network congestion). The player is locked into one quality level and can't adapt.
✅ Great: Adaptive bitrate streaming (ABR)
Adaptive bitrate streaming combines segmented delivery with dynamic quality switching. The video is pre-encoded into multiple quality levels (renditions), each split into aligned segments. A manifest file indexes all available renditions and their segments. The player measures its actual download speed after each segment and switches quality up or down for the next segment — seamlessly, without interrupting playback.
This is what YouTube, Netflix, Twitch, and every modern streaming platform uses. It's the core innovation that separates modern streaming from the buffering nightmare of early internet video.
💡 Interview framing
Walk through all three approaches in 60 seconds: "Full download fails because of startup latency and no resilience. Segmented download fixes those but can't adapt to bandwidth changes. ABR solves all three by combining segments with a manifest that lets the player switch quality per-segment based on measured throughput." This shows you can evaluate alternatives, not just jump to the answer.
How ABR works — the mental model
The video is pre-encoded into multiple quality levels (renditions). Each rendition is split into small segments (2-10 seconds each). A tells the player what quality levels exist and where to find each segment. The player measures its download speed after each segment and decides whether to upgrade, downgrade, or maintain quality for the next segment.
Player starts
Fetch master manifest
Bandwidth probe
Estimate connection speed
Select quality
Pick rendition ≤ bandwidth
Fetch segment
Download 4s of video
Measure speed
Actual vs expected
Adapt
Switch quality if needed
HLS vs DASH — the two standards
Two competing protocols dominate adaptive streaming. Both work on the same principle (manifest + segments) but differ in format and ecosystem support. is the industry standard — virtually universal device support. is more flexible but less universally supported.
| Aspect | HLS | DASH |
|---|---|---|
| Manifest format | .m3u8 (plain text) | .mpd (XML) |
| Segment format | .ts or .fmp4 | Any (usually .m4s / .fmp4) |
| Apple device support | Native (Safari, iOS, tvOS) | Requires MSE polyfill |
| Android / Chrome | Supported via MSE | Native via MSE |
| DRM support | FairPlay (Apple only) | Widevine, PlayReady, FairPlay |
| Segment duration | Typically 6s (configurable) | Typically 2-4s |
| Live streaming | Yes (sliding window) | Yes (dynamic MPD) |
| Industry adoption | YouTube, Twitch, most platforms | Netflix (non-Apple), DAZN |
| Recommendation | Use as primary — universal | Use for DRM-heavy or DASH-only clients |
🎯 In practice: generate both, serve based on client
Most platforms generate both HLS and DASH manifests from the same underlying segments (using fMP4 as the common container). The player or CDN selects the appropriate manifest based on the User-Agent. Apple devices get HLS; everything else can use either. For an interview, saying "HLS as primary, DASH for DRM-specific clients" is the right answer.
Manifest file structure
The master manifest is the entry point. It lists all available renditions with their bandwidth requirements, resolution, and codec information. The player reads this once and uses it to make quality decisions throughout playback.
#EXTM3U
#EXT-X-VERSION:4
# Audio-only track for very low bandwidth
#EXT-X-STREAM-INF:BANDWIDTH=128000,CODECS="mp4a.40.2"
audio_only/playlist.m3u8
# 360p — mobile on 3G
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.4d401e,mp4a.40.2"
360p/h264/playlist.m3u8
# 480p — standard mobile
#EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=854x480,CODECS="avc1.4d401f,mp4a.40.2"
480p/h264/playlist.m3u8
# 720p — desktop / good mobile
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2"
720p/h264/playlist.m3u8
# 1080p — high bandwidth
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p/h264/playlist.m3u8
# 1080p VP9 — better compression for supported devices
#EXT-X-STREAM-INF:BANDWIDTH=3200000,RESOLUTION=1920x1080,CODECS="vp09.00.40.08,opus"
1080p/vp9/playlist.m3u8
Each rendition has its own media manifest listing individual segments in playback order. The player fetches segments sequentially from this list.
#EXTM3U
#EXT-X-VERSION:4
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:4.000,
segment_000.ts
#EXTINF:4.000,
segment_001.ts
#EXTINF:4.000,
segment_002.ts
#EXTINF:4.000,
segment_003.ts
...
#EXTINF:3.240,
segment_149.ts
#EXT-X-ENDLIST
Client-side ABR algorithm
The player's ABR algorithm decides which quality to fetch for each segment. This runs entirely on the client — the server has no role in quality selection. The algorithm balances three competing goals: maximize quality, minimize buffering, and minimize quality switches (which are visually jarring).
After downloading each segment, the player measures:
1. throughput = segment_size_bytes / download_time_seconds
2. buffer_level = seconds of video already buffered ahead of playhead
Decision rules:
IF buffer_level < 2s (danger zone — about to stall):
→ Switch DOWN immediately to lowest quality
→ Priority: avoid buffering at all costs
ELSE IF buffer_level < 5s (low buffer):
→ Select quality where bitrate ≤ 70% of measured throughput
→ Conservative — build buffer before upgrading
ELSE IF buffer_level > 15s (healthy buffer):
→ Select quality where bitrate ≤ 90% of measured throughput
→ Aggressive — can afford a brief stall if estimate is wrong
ELSE (normal operation, 5-15s buffer):
→ Select quality where bitrate ≤ 80% of measured throughput
→ Balanced — standard operation
STABILITY RULE:
→ Don't switch quality unless the new level has been "better" for
3 consecutive segments (prevents oscillation on variable networks)
Real implementations (BOLA, MPC, Pensieve):
→ Use buffer-based + throughput-based hybrid algorithms
→ Some use ML models trained on millions of sessions (Pensieve/Netflix)
→ YouTube uses a proprietary algorithm tuned for their CDN topology
Segment duration trade-offs
Segment duration is a critical design parameter that affects latency, adaptability, CDN efficiency, and seek precision.
| Duration | Pros | Cons | Best for |
|---|---|---|---|
| 2 seconds | Fast quality adaptation, precise seek, low live latency | More HTTP requests, more manifest entries, lower CDN cache efficiency | Live streaming, interactive content |
| 4 seconds | Good balance of adaptability and efficiency | Slightly slower adaptation than 2s | VOD (YouTube's choice), general purpose |
| 6 seconds | Fewer requests, better CDN cache hit rate, less overhead | Slower quality adaptation, coarser seek granularity | Long-form content (Netflix movies) |
| 10 seconds | Minimal overhead, excellent cache efficiency | Very slow adaptation, poor for variable networks | Stable high-bandwidth environments only |
💡 The segment duration answer for interviews
"4 seconds is the sweet spot for VOD — it gives the ABR algorithm enough decision points to adapt within 8-12 seconds of a bandwidth change, while keeping the segment count manageable (150 segments for a 10-minute video). For live streaming, 2 seconds reduces glass-to-glass latency."
Seek operation
When a user seeks to a new position, the player calculates which segment contains that timestamp, fetches it from CDN, and begins playback from the nearest keyframe. Because segments are aligned to keyframe boundaries during transcoding, seek is always fast — the player never needs to decode from the start of a segment to reach the target frame.
User seeks to 2:34 (154 seconds):
1. target_segment = floor(154 / 4) = segment_038
2. Flush current buffer
3. Fetch segment_038.ts from CDN (likely cached — popular videos)
4. Decode from nearest keyframe (segment starts are always keyframes)
5. Render frame at 154s
6. Prefetch segment_039, segment_040 in parallel
Total seek time:
CDN hit: ~100-200ms (fetch + decode)
CDN miss: ~300-500ms (origin fetch + decode)
Why it's fast:
→ Segments start at keyframes (no need to decode from GOP start)
→ CDN caches segments individually (seek doesn't invalidate cache)
→ Player prefetches adjacent segments speculatively
CDN & Global Delivery
For a video platform, the CDN isn't an optimization — it IS the product. At 40 Tbps peak egress, no origin infrastructure can serve traffic directly. The CDN absorbs 95%+ of all streaming requests, serving segments from edge nodes that are physically close to viewers (typically <50ms RTT). The architecture of the CDN layer determines whether users experience smooth playback or constant buffering.
Multi-tier caching architecture
Video CDNs use a multi-tier cache hierarchy to maximize hit rates while minimizing origin load. Each tier serves a different purpose and has different capacity/latency characteristics.
Client
Player request
Edge PoP
Closest to user (~20ms)
Regional Shield
Aggregates misses (~50ms)
Origin Shield
Single point to origin (~100ms)
S3 Origin
Source of truth (~200ms)
Tier 1: Edge PoP (200+ locations globally)
─ Closest to the user (same city or ISP)
─ Storage: 10-50 TB per PoP (SSD)
─ Serves: ~80% of requests (hot content)
─ TTL: 24h for segments, 1h for manifests
─ Eviction: LRU — popular content stays, long-tail evicts
Tier 2: Regional Shield (10-20 locations)
─ Aggregates cache misses from multiple edge PoPs in a region
─ Storage: 200-500 TB per shield (HDD + SSD)
─ Serves: ~15% of requests (warm content)
─ Purpose: Prevents "thundering herd" to origin when content goes viral
─ One regional shield serves 10-20 edge PoPs
Tier 3: Origin Shield (2-3 locations)
─ Single point of contact with S3 origin
─ Storage: 1-5 PB (the "last cache" before origin)
─ Serves: ~4% of requests (cold content being accessed)
─ Purpose: Collapses all origin requests to a single fetch per object
─ Request coalescing: 1000 concurrent misses for same segment → 1 S3 GET
Tier 4: S3 Origin
─ Source of truth — all segments and manifests
─ Serves: <1% of total requests (truly cold content)
─ Latency: 50-200ms depending on region
─ Cost: $0.09/GB egress — minimizing origin hits saves millions
Cache hit rates (production targets):
Edge: 80% hit rate
Edge + Shield: 95% hit rate
Total (all tiers): 99%+ hit rate
Origin sees: <1% of total streaming traffic
Cache warming strategies
Not all content should wait for the first viewer to trigger a cache fill. For content that will definitely be popular (new uploads from large channels, trending videos), proactive cache warming avoids the cold-start latency penalty for early viewers.
1. Predictive warming (for known-popular content)
─ Trigger: Video from channel with >1M subscribers finishes transcoding
─ Action: Push first 30 segments (2 minutes of video) to all edge PoPs
─ Why: First 2 minutes covers 90% of viewers who click but don't finish
─ Cost: 30 segments × 200 PoPs × 2MB = 12 GB of proactive distribution
2. Reactive warming (for viral content)
─ Trigger: Cache miss rate for a video exceeds threshold (>100 misses/min at one PoP)
─ Action: Pre-fetch all segments of that video to the regional shield
─ Why: Viral content goes from 0 to millions of views in minutes
─ The shield absorbs the thundering herd while edge caches fill organically
3. Geographic warming (for region-specific content)
─ Trigger: Video metadata indicates language/region (e.g., Hindi video)
─ Action: Warm edge PoPs in India, skip PoPs in South America
─ Why: A Hindi video won't be popular in Brazil — don't waste cache space
4. Time-based warming (for scheduled content)
─ Trigger: Creator schedules a premiere for 8 PM IST
─ Action: Warm Indian PoPs 30 minutes before premiere
─ Why: Thousands of viewers will click simultaneously at premiere time
Cache key design
The cache key determines what gets cached separately vs what shares a cache entry. A poorly designed cache key can destroy hit rates or serve wrong content.
Cache key = path only (no query params, no cookies, no headers)
Example:
/segments/vid_8f4b3c2a/720p/h264/segment_042.ts
Why path-only:
─ Same segment is identical for all viewers (no personalization)
─ Query params (auth tokens, tracking IDs) would create unique cache entries
for the same content → 0% hit rate
─ Auth is handled at the edge via signed URLs with short TTL, not query params
Signed URL approach:
─ CDN validates the signature at the edge (CloudFront signed URLs)
─ After validation, strips the signature and uses path as cache key
─ Result: authenticated access + shared cache entries
Manifest cache key (different strategy):
/manifests/vid_8f4b3c2a/master.m3u8
─ Shorter TTL (1 hour) because manifests can be updated
(e.g., new rendition added, segment URLs rotated)
─ Cache-Control: public, max-age=3600, stale-while-revalidate=60
Netflix Open Connect vs traditional CDN
At hyperscale, even commercial CDNs (Akamai, CloudFront) become too expensive. Netflix solved this with — placing custom hardware inside ISP networks. YouTube uses Google's own global edge network (GGC — Google Global Cache nodes in ISPs).
| Aspect | Commercial CDN (Akamai/CF) | Custom CDN (Open Connect/GGC) |
|---|---|---|
| Cost | $0.02-0.08/GB egress | Hardware cost only (amortized) |
| Control | Limited — vendor's caching rules | Full control over caching, routing, prefetch |
| Latency | ~20-50ms (nearest PoP) | ~1-5ms (inside ISP network) |
| Capacity | Shared with other customers | Dedicated — no noisy neighbors |
| Setup cost | Zero (pay-per-use) | Millions (hardware + ISP partnerships) |
| When to use | < 10 Tbps, early stage | > 10 Tbps, hyperscale |
Handling cache invalidation
Video segments are immutable — once transcoded, they never change. This makes caching trivial (infinite TTL, no invalidation needed). But there are cases where content must be removed from CDN quickly:
Scenario 1: Copyright takedown (DMCA)
─ Legal requirement: remove within 24 hours (often faster)
─ Action: CDN purge API → invalidate all paths matching /segments/{video_id}/*
─ Propagation: 5-30 seconds across all PoPs (CloudFront) / 2-5 min (Akamai)
─ Fallback: Origin returns 403 for purged video_id (CDN miss → blocked)
Scenario 2: Creator deletes their video
─ Soft-delete in DB (status='deleted')
─ CDN purge for manifest (segments will naturally evict via LRU)
─ S3 objects moved to "deleted" prefix (retained 30 days for appeals)
Scenario 3: Re-transcoding (quality improvement)
─ New segments get a version suffix: /segments/{video_id}/v2/720p/...
─ New manifest points to v2 segments
─ Old segments evict naturally — no explicit purge needed
─ This is why versioned paths > cache purging for planned updates
Scenario 4: Manifest update (new rendition added)
─ Manifest has short TTL (1 hour) — natural refresh
─ For urgent updates: CDN purge on manifest path only (lightweight)
─ Segments are additive — old segments remain valid
🎯 The immutability insight
Video segments are content-addressed and immutable — the same input always produces the same output. This means CDN caching is trivially correct: set TTL to 1 year, never invalidate. The only mutable objects are manifests (short TTL) and metadata (not CDN-cached). This is why video CDNs achieve 99%+ hit rates — the content never changes.
💡 What to say about CDN in the interview
"The CDN is a multi-tier cache: edge PoPs handle 80% of requests, regional shields handle 15%, and origin sees less than 1%. Segments are immutable with long TTLs. For popular content, I proactively warm edge caches. For takedowns, I use CDN purge APIs with origin-level blocking as a safety net."
View Counts & Trending
View counting sounds trivial — just increment a counter. But at 11.6K views/sec average (23K peak), a naive approach (UPDATE videos SET view_count = view_count + 1) would create a hot row that locks on every write, saturating the database. The solution is a multi-stage pipeline: fire-and-forget from the player, buffer in Kafka, aggregate in Flink, and flush to both Redis (real-time display) and the database (durable count) asynchronously.
View count pipeline
Player SDK
Fire view event
Ingestion API
Validate + dedupe
Kafka
view-events topic
Flink
1-min tumbling window
Redis
INCRBY (real-time)
Postgres
Batch UPDATE (durable)
What counts as a "view"?
Not every play event is a legitimate view. YouTube counts a view only after ~30 seconds of watch time (the exact threshold is secret and varies). This prevents bots, autoplay previews, and accidental clicks from inflating counts. The definition matters because view counts drive monetization (ad revenue) and ranking (trending algorithm).
A view is counted when ALL of these are true:
1. watched_ms >= 30,000 (30 seconds of actual playback)
2. NOT a duplicate from same session (dedupe by session_id + video_id)
3. NOT from a known bot User-Agent
4. Rate limit: max 1 view per video per user per 4 hours
5. Player was visible (not a background tab — Page Visibility API)
Where validation happens:
─ Rule 1, 5: Client-side (player SDK only fires event after 30s of visible play)
─ Rule 2: Ingestion API (Redis SET with TTL for session dedup)
─ Rule 3: Ingestion API (User-Agent blocklist)
─ Rule 4: Flink (stateful dedup window per user+video, 4h TTL)
Why not validate everything at ingestion?
─ Rule 4 requires stateful processing across millions of user+video pairs
─ Too expensive to do synchronously at 23K events/sec
─ Flink handles stateful dedup efficiently with RocksDB state backend
Redis counter strategy
The video page needs to display a view count. Reading from Postgres on every page load would be too slow and create read pressure. Instead, Redis holds the real-time counter that the Metadata Service reads. Flink increments Redis every minute with the aggregated count from that window.
// Flink writes (every 1-minute window):
// After aggregating valid views in a tumbling window
await redis.incrBy(`views:${videoId}`, windowCount);
// e.g., INCRBY views:vid_8f4b3c2a 847
// (847 valid views in the last minute for this video)
// Metadata Service reads (on every video page load):
const viewCount = await redis.get(`views:${videoId}`);
// Returns the real-time count — at most 1 minute stale
// Periodic DB flush (every 5 minutes, batch job):
// Reads Redis counters, batch-updates Postgres
// UPDATE videos SET view_count = $1, updated_at = now() WHERE id = $2
// This makes the count durable — Redis is volatile
// Why not just use Postgres directly?
// At 23K views/sec, UPDATE ... SET view_count = view_count + 1
// creates a hot row with row-level locking → ~500 updates/sec max per row
// Redis INCRBY is lock-free and handles millions of ops/sec
Trending algorithm (Top-K)
"Trending" isn't just "most views" — it's "fastest-growing views in a recent time window." A video with 1M total views but flat growth isn't trending. A video with 10K views that got 9K of them in the last hour IS trending. This requires a velocity-based scoring function computed over sliding windows.
Trending score formula:
score = views_last_1h × w1 + views_last_6h × w2 + views_last_24h × w3
where w1 = 1.0, w2 = 0.3, w3 = 0.1 (recency-weighted)
Normalization: divide by channel_subscriber_count^0.3
(prevents large channels from always dominating trending)
Implementation with Count-Min Sketch + Heap:
Computing exact Top-K across billions of videos is expensive. Instead, we use for approximate frequency counting combined with a min-heap to maintain the Top-K candidates efficiently.
Flink streaming job:
1. Input: view-events Kafka topic (partitioned by video_id)
2. Sliding window: 1-hour window, sliding every 1 minute
─ For each video_id, count views in the current window
3. Count-Min Sketch (per window):
─ Space: ~100 KB for tracking millions of videos
─ Error: <1% over-count (acceptable for trending)
─ Update: for each view event, increment CMS counters
4. Min-Heap of size K (K=100 for "Top 100 Trending"):
─ Maintains the K videos with highest trending scores
─ When a video's score exceeds the heap minimum, swap it in
─ O(log K) per update — fast even at 23K events/sec
5. Output: Every minute, emit the current Top-100 to Redis
─ Key: trending:global (sorted set with scores)
─ Key: trending:{country_code} (per-country trending)
─ The feed service reads these sorted sets directly
6. Decay: Scores naturally decay as old events leave the sliding window
─ No explicit "decay factor" needed — the window handles it
View count consistency model
| Component | Freshness | Consistency | Why acceptable |
|---|---|---|---|
| Redis counter (displayed on page) | ~1 minute stale | Eventually consistent | YouTube shows '1.2M views' — nobody notices 1-min lag |
| Postgres (durable store) | ~5 minutes stale | Eventually consistent | Backup for Redis failures; not read in hot path |
| ClickHouse (analytics) | ~2 minutes stale | Eventually consistent | Creator dashboard shows trends, not real-time counts |
| Trending list | ~1 minute stale | Approximate (CMS error) | Trending is inherently fuzzy — exact ranking doesn't matter |
🎯 Why eventual consistency is fine for view counts
YouTube famously freezes view counts at 301 views while it validates them. The displayed count is always approximate and delayed. Users don't expect real-time precision — they expect the number to go up over time. This relaxed consistency requirement is what allows us to use an async pipeline instead of synchronous DB writes.
💡 The interview-winning insight
"I separate the view count into three concerns: ingestion (fire-and-forget to Kafka), aggregation (Flink with dedup and windowing), and serving (Redis INCRBY for display, Postgres for durability). The count on the page is ~1 minute stale, which is invisible to users. This lets me handle 23K views/sec without any hot-row contention."
Recommendation Feed
The recommendation feed is what keeps users on the platform. YouTube reports that 70% of watch time comes from recommendations — not search, not subscriptions, not direct links. The system must serve personalized recommendations to 100M+ DAU with <200ms latency. This section covers the serving architecture (how recommendations are delivered), not the ML training pipeline (how models are built).
Two-stage architecture: candidate generation → ranking
With billions of videos in the catalog, scoring every video for every user on every request is computationally impossible. The solution is a funnel: first narrow the candidate set from billions to thousands (cheap, approximate), then rank those thousands precisely (expensive, ML model). This two-stage approach is used by YouTube, Netflix, TikTok, and every recommendation system at scale.
Candidate Gen
Billions → 1000 candidates
Ranking
1000 → 50 scored
Re-ranking
Diversity + freshness
Filtering
Remove watched/blocked
Serve
Top 20 to client
Stage 1: Candidate generation
The goal is to quickly retrieve ~1000 videos that are plausibly relevant to this user. Multiple candidate sources run in parallel, each contributing a subset. Speed matters more than precision here — the ranking stage will sort out quality.
Source 1: Collaborative filtering (ANN search)
─ User embedding → find nearest video embeddings in vector space
─ Uses Approximate Nearest Neighbor search (HNSW index in Pinecone/Milvus)
─ Returns: ~200 candidates in <10ms
─ Signal: "users like you watched these videos"
Source 2: Content-based (same topic/channel)
─ Given user's recent watch history, find videos with similar tags/topics
─ Elasticsearch query: match on tags, category, channel
─ Returns: ~200 candidates in <20ms
─ Signal: "you watched X, here's more like X"
Source 3: Subscription feed
─ Recent uploads from channels the user subscribes to
─ Simple DB query: videos WHERE channel_id IN (subscriptions) AND created_at > 7d
─ Returns: ~100 candidates in <10ms
─ Signal: "your subscriptions have new content"
Source 4: Trending / popular
─ Read from the trending sorted set in Redis (computed by Flink)
─ Returns: ~100 candidates in <5ms
─ Signal: "everyone is watching this right now"
Source 5: Exploration (serendipity)
─ Random sample from recent uploads outside user's usual interests
─ Returns: ~50 candidates
─ Signal: "break the filter bubble, discover new content"
Total candidates after dedup: ~500-1000 unique videos
Latency budget: <50ms (all sources run in parallel)
Stage 2: Ranking
The ranking model scores each candidate with a predicted engagement probability. This is the expensive step — a neural network evaluates hundreds of features per candidate. But with only ~1000 candidates (not billions), it's tractable.
Features fed to the ranking model (per candidate):
User features (from feature store, precomputed):
─ Watch history (last 50 videos watched, with watch percentage)
─ Search history (last 20 queries)
─ Subscription list
─ Demographics (age bucket, country, language)
─ Time of day, day of week
─ Device type (mobile users prefer shorter videos)
Video features (precomputed at index time):
─ Video embedding (from content understanding model)
─ Duration, upload date, view count, like ratio
─ Channel subscriber count, upload frequency
─ Thumbnail click-through rate (historical)
─ Average watch percentage (quality signal)
Cross features (computed at serving time):
─ User-video affinity score (dot product of embeddings)
─ Has user watched this channel before?
─ Time since user last watched this topic
─ Social signal: did friends/followed users watch this?
Model output:
─ P(click) — probability user clicks the thumbnail
─ P(watch) — probability user watches >50% of the video
─ P(like) — probability user likes the video
─ Final score = weighted combination of above
score = 0.3 × P(click) + 0.5 × P(watch) + 0.2 × P(like)
Latency: ~50-100ms for 1000 candidates on GPU inference server
Stage 3: Re-ranking (diversity + business rules)
Pure relevance ranking creates a boring feed — all videos from the same topic, same creator, same format. Re-ranking injects diversity, freshness, and business constraints without a full model re-score.
Diversity rules:
─ Max 2 videos from the same channel in top 10
─ Max 3 videos from the same topic/category in top 10
─ At least 1 video from a new (to this user) channel in top 5
─ At least 1 video uploaded in the last 24 hours in top 5
Freshness boost:
─ Videos uploaded in last 6 hours get a 1.5× score multiplier
─ Videos from subscribed channels uploaded today get 2× boost
─ Decays linearly over 48 hours back to 1×
Business rules:
─ Promoted content (ads) inserted at positions 3, 8, 15
─ Creator-boosted content (paid promotion) gets 1.3× multiplier
─ Content policy: suppress borderline content (reduce score by 0.5×)
Filtering (hard removes):
─ Videos the user has already watched (unless >30 days ago)
─ Videos the user explicitly marked "not interested"
─ Videos from blocked channels
─ Age-restricted content for underage users
─ Region-blocked content
Serving architecture
For active users (logged in within 24h), recommendations are precomputed and cached. For cold-start users (new or anonymous), recommendations are computed on-demand using a simpler model.
| User type | Strategy | Latency | Quality |
|---|---|---|---|
| Active (logged in <24h) | Precomputed feed in Redis, refreshed every 30 min | <20ms (Redis GET) | High — full model with rich history |
| Returning (logged in <7d) | On-demand with cached user features | <150ms (model inference) | Medium — features may be slightly stale |
| Cold start (new user) | Popularity-based + demographic signals | <50ms (simple lookup) | Low — no personalization, improves with engagement |
| Anonymous | Trending + geo-popular + cookie-based history | <30ms (Redis sorted set) | Minimal — country-level personalization only |
Feature store
The is the backbone of the recommendation system. It precomputes and serves features at low latency, avoiding expensive real-time computation during the ranking step.
Batch features (updated every 6 hours by Spark jobs):
─ User watch history aggregates (topics watched, avg duration, time patterns)
─ Video quality signals (avg watch %, like ratio, comment sentiment)
─ Channel features (subscriber growth rate, upload frequency)
─ Stored in: Redis Hash (user features) + DynamoDB (video features)
Real-time features (updated per event by Flink):
─ User's last 5 videos watched (sliding window)
─ Current session duration and engagement level
─ Real-time trending scores
─ Stored in: Redis (sub-ms reads)
Serving flow:
1. Recommendation service receives request for user_id
2. Parallel fetch: user features from Redis + video features from DynamoDB
3. Combine with real-time features from Redis
4. Feed combined feature vector to ranking model
5. Total feature fetch: <20ms (all from memory/cache)
Training-serving consistency:
─ Same feature computation code runs in both Spark (training) and Flink (serving)
─ Feature store logs all served features → used as training labels
─ Prevents training-serving skew (the #1 cause of model degradation)
🎯 Cold start problem
New users have no watch history — the model has nothing to personalize on. Solutions: (1) use demographic signals (country, language, device) to bootstrap, (2) show trending/popular content and learn from clicks, (3) ask explicit preferences during onboarding ("select topics you like"). After ~10 video watches, the model has enough signal to personalize effectively.
💡 What to say in the interview
"I use a two-stage funnel: candidate generation narrows billions to ~1000 using cheap retrieval (ANN search + collaborative filtering), then a ranking model scores those 1000 with a neural network. For active users, I precompute and cache the feed in Redis. The feature store serves precomputed user and video features in <20ms. Total end-to-end: <200ms for a personalized feed of 20 videos."
Scaling & Reliability
A video platform has three distinct scaling challenges: storage (petabytes growing daily), compute (transcoding at massive parallelism), and delivery (tens of terabits per second of egress). Each requires different strategies, and failure in any one creates a different user-visible symptom — upload failures, processing delays, or playback buffering.
Storage tiering — the cost survival strategy
At 8.3 PB/day of new content, storage cost is the single largest expense. The strategy is non-negotiable — without it, storage costs would bankrupt the platform within months.
Tier 1: HOT (S3 Standard) — $0.023/GB/month
─ Content: Videos uploaded in last 30 days + any video with >100 views/day
─ Size: ~250 PB (30 days × 8.3 PB/day)
─ Access: Served directly to CDN on cache miss
─ Latency: <100ms first-byte
Tier 2: WARM (S3 Infrequent Access) — $0.0125/GB/month
─ Content: Videos 30-365 days old with <100 views/day
─ Size: ~2 EB
─ Access: Served to CDN with slightly higher latency
─ Latency: <200ms first-byte
─ Retrieval fee: $0.01/GB (acceptable for occasional access)
Tier 3: COLD (S3 Glacier Instant Retrieval) — $0.004/GB/month
─ Content: Videos >1 year old with <10 views/day
─ Size: Growing unbounded (multi-EB)
─ Access: ~100ms retrieval (Glacier Instant), cached in CDN after first access
─ Retrieval fee: $0.03/GB
Tier 4: ARCHIVE (S3 Glacier Deep Archive) — $0.00099/GB/month
─ Content: Videos >2 years old with 0 views in last 90 days
─ Size: Majority of total storage
─ Access: 12-48 hour retrieval (acceptable — video is essentially "dead")
─ Only the original file is archived; renditions are deleted and re-transcoded on demand
Lifecycle automation:
─ S3 Lifecycle policies move objects between tiers automatically
─ A daily Flink job updates video "temperature" based on view velocity
─ Videos that go viral again are promoted back to HOT tier
Cost savings:
Without tiering: 3 EB × $0.023/GB = $69M/month
With tiering: ~$15M/month (78% savings)
Scaling each component
| Component | Scaling strategy | Bottleneck | Trigger to scale |
|---|---|---|---|
| Upload Service | Horizontal (stateless pods behind ALB) | CPU for presigned URL generation | Request rate > 80% capacity |
| Transcoding Workers | Horizontal (GPU spot fleet, auto-scaled on queue depth) | GPU compute | Queue depth > 1000 jobs or p99 wait > 5 min |
| Metadata DB (Postgres) | Vertical first, then shard by hash(video_id) | Write IOPS for status updates | Write latency p99 > 50ms |
| Redis (counters + cache) | Redis Cluster (hash slots across nodes) | Memory for hot video metadata | Memory usage > 75% or eviction rate > 1% |
| Kafka | Add partitions to topics, add brokers | Partition throughput (1 MB/s per partition) | Consumer lag > 5 minutes |
| CDN | Add PoPs, increase origin shield capacity | Edge storage for popular content | Cache hit rate drops below 90% |
| Recommendation Service | Horizontal (GPU inference pods) | Model inference latency | p99 > 150ms or request queue > 100 |
Failure-mode playbook
🔥 CDN edge PoP goes down
DNS-based health checks detect the failure within 30 seconds. Traffic is rerouted to the next-nearest PoP via anycast or geo-DNS failover. Users experience a brief latency increase (next PoP is farther) but no interruption. The failed PoP's cache is cold when it recovers — it warms organically from traffic.
🔥 S3 region outage
S3 cross-region replication ensures all content exists in at least 2 regions. CDN origin failover switches to the secondary region automatically. Latency increases for cache misses (cross- region fetch) but playback continues. Upload Service switches to the secondary region's bucket. Recovery: S3 replication catches up when the primary region returns.
🔥 Transcoding pipeline backed up
Queue depth grows, processing time increases. Mitigations: (1) auto-scale GPU fleet (spot instances spin up in 2-3 min), (2) prioritize by channel size (large channels first), (3) reduce rendition count temporarily (skip 4K, skip AV1), (4) alert on-call if queue depth exceeds 30-min backlog. Existing videos are unaffected — only new uploads wait longer.
🔥 Kafka cluster failure
View events are lost during the outage (fire-and-forget from player). Streaming continues unaffected — Kafka is not in the playback path. View counts freeze temporarily. Transcoding triggers are also queued in Kafka — new uploads wait until Kafka recovers. Mitigation: multi-AZ Kafka cluster with min.insync. replicas=2 prevents single-AZ failures from causing data loss.
🔥 Metadata DB primary down
Automated failover promotes a read replica to primary (~30s). During failover: video page loads from Redis cache (metadata is cached), uploads fail with 503 (can't create video record), status updates queue in memory. After failover: writes resume, queued updates flush. Streaming is completely unaffected — it doesn't touch the metadata DB.
🔥 Viral video overwhelms origin
A video goes from 0 to 10M views in 1 hour. CDN cache fills quickly for popular segments, but the first few thousand viewers hit origin. Mitigations: (1) at origin shield (1000 concurrent misses → 1 S3 GET), (2) proactive cache warming when view velocity exceeds threshold, (3) origin rate limiting with graceful 503 (CDN retries from shield cache).
Multi-region architecture
Regions: US-East, US-West, EU-West, AP-South, AP-Northeast
Per region:
─ Upload Service (accepts uploads from nearby users)
─ Metadata DB replica (read-only, async replication from primary)
─ Redis cluster (local cache, independent per region)
─ Transcoding workers (process locally uploaded videos)
─ S3 bucket (cross-region replication for durability)
Global (single primary):
─ Metadata DB primary (US-East, with replicas everywhere)
─ Kafka cluster (US-East, with MirrorMaker to other regions for analytics)
─ Recommendation model (trained centrally, deployed to all regions)
Routing:
─ DNS geo-routing sends users to nearest region
─ Uploads go to the nearest region's S3 bucket
─ Streaming goes to the nearest CDN PoP (CDN handles routing)
─ Metadata writes route to the primary region (cross-region latency acceptable for writes)
Consistency:
─ Video metadata: eventual consistency across regions (~1-2s replication lag)
─ View counts: per-region Redis, merged globally every 5 minutes
─ A user uploading in AP-South sees their video immediately (read-your-writes via sticky routing)
💡 SLO targeting per path
Streaming: 99.99% availability, <2s time-to-first-frame. Upload: 99.9% availability, <5 min processing time. Analytics: 99% availability, minutes of lag acceptable. Different tiers → different on-call priorities → different infrastructure spend. The streaming path gets the most investment because it's the user-visible product.
Trade-offs Consolidated
Every decision in this design was a trade. Video platforms are uniquely challenging because the trade-offs span cost (storage, compute, egress), quality (resolution, codec efficiency), and latency (upload-to-playable time, time-to-first-frame). Bundling them here gives you a compact story to walk the interviewer through.
| Decision | We picked | Why | What we gave up |
|---|---|---|---|
| Upload mechanism | Presigned URLs + S3 multipart | Server never touches video bytes — scales infinitely, no bandwidth bottleneck | More client complexity (chunking, retry logic, ETag tracking) |
| Transcoding timing | Async post-upload (queue-based) | Upload completes fast, processing scales independently | Video not playable immediately — 2-5 min delay |
| Segment duration | 4 seconds | Good balance of ABR adaptability and CDN cache efficiency | Slower adaptation than 2s; coarser seek than 2s |
| Codec strategy | H.264 baseline + H.265/VP9 for popular videos | Universal compatibility + bandwidth savings where it matters most | 2× transcoding cost for popular videos; codec negotiation complexity |
| CDN architecture | Multi-tier (edge → regional shield → origin shield → S3) | 99%+ cache hit rate, origin sees <1% of traffic | Operational complexity; cache warming logic; invalidation propagation delay |
| View count consistency | Eventually consistent (~1 min lag) | No hot-row contention, handles 23K views/sec without DB pressure | Displayed count is stale — unacceptable for financial systems, fine for social |
| Trending algorithm | Count-Min Sketch + min-heap (approximate) | Sub-linear space, handles millions of videos in streaming fashion | Approximate — may over-count by ~1%. Exact Top-K would require sorting all videos |
| Recommendation serving | Precomputed for active users, on-demand for others | < 20ms for 80% of users (Redis GET); saves GPU inference cost | Feed is up to 30 min stale for active users; cold-start users get generic content |
| Storage tiering | Hot/warm/cold/archive with automatic lifecycle | 78% cost reduction vs keeping everything in S3 Standard | Cold content has higher first-access latency (100-200ms vs 50ms) |
| Metadata store | PostgreSQL (sharded) + Redis cache | ACID for status transitions, rich queries for dashboards, fast reads from cache | Sharding adds complexity; cross-shard queries (search) need separate index |
| Streaming protocol | HLS primary, DASH for DRM clients | HLS has universal device support; DASH adds flexibility for DRM-heavy use cases | Generating both formats doubles manifest storage (negligible) and adds routing logic |
| Transcoding orchestration | Temporal (workflow engine) | Durable execution, automatic retries, DAG support, visibility into job state | Operational overhead of running Temporal cluster; learning curve for workflow DSL |
Where reasonable engineers disagree
💬 Transcode everything upfront vs on-demand
YouTube transcodes all renditions immediately (they have the GPU fleet). Smaller platforms transcode only 720p initially and add higher resolutions only if the video gets views. The trade-off: upfront cost vs first-viewer latency for high-res. For an interview, state both and pick based on scale.
💬 HLS vs DASH vs both
Netflix uses DASH exclusively (except Apple devices). YouTube uses HLS for everything. The "correct" answer depends on your DRM requirements and device matrix. For interviews, HLS-first is the safe answer — it works everywhere.
💬 Commercial CDN vs custom CDN
At <10 Tbps, commercial CDNs (CloudFront, Akamai) are cheaper when you factor in engineering time. At >10 Tbps, custom CDN (Netflix Open Connect, YouTube GGC) saves millions monthly. The crossover point depends on your engineering team size and ISP relationships.
💬 Cassandra vs PostgreSQL for metadata
Cassandra offers better write throughput and multi-region active-active. PostgreSQL offers richer queries, ACID, and simpler operations. At YouTube scale, you need Cassandra-like properties. At "building a YouTube competitor" scale, PostgreSQL with sharding is simpler and sufficient for years.
💬 Exact vs approximate view counts
YouTube freezes counts at 301 and validates asynchronously. Some platforms show exact real-time counts. The trade-off: exact counts require synchronous writes (hot rows) or complex distributed counters (CRDTs). Approximate counts with async validation is the pragmatic choice at scale.
🎯 The trade-off that defines seniority
The biggest divide between junior and senior answers is whether you can articulate the cost dimension. "We use H.265 for popular videos because the 30% bandwidth savings at 40 Tbps saves $2M/month in CDN egress — that justifies the 3× transcoding cost increase for those videos." Connecting architectural decisions to dollar amounts is what staff engineers do.
Follow-ups & Common Traps
The last 10 minutes of the interview separate candidates. The interviewer probes edge cases, failure scenarios, and "what if" questions that test whether you truly understand the system or just memorized a diagram. These are the questions worth pre-loading.
Curveball follow-ups
Q:A video goes viral — 10M views in 1 hour. What happens to the system?
A: CDN absorbs 99% of traffic after the first few thousand views fill the cache. Origin shield uses request coalescing — 1000 concurrent cache misses for the same segment result in 1 S3 GET. View count pipeline handles the spike via Kafka buffering (partitioned by video_id, so one partition gets hot but others are fine). Trending algorithm detects the velocity spike within 1 minute and promotes the video. The only risk: if the video was in cold storage, the first viewers experience ~200ms extra latency while the CDN warms. Proactive warming triggers when view velocity exceeds 1000/min.
Q:How do you handle a 4-hour movie upload on a flaky mobile connection?
A: 4 hours at 1080p ≈ 14 GB. With 10 MB chunks, that's 1,400 chunks. The client uploads in parallel (6 concurrent), with adaptive chunk sizing (drops to 5 MB if failures increase). If the connection drops entirely, the upload resumes from the last successful chunk — even days later. S3 multipart uploads have no timeout. We set a 7-day lifecycle policy to abort incomplete uploads and free storage. The client shows progress as percentage of chunks completed, not bytes transferred.
Q:A transcoding worker produces corrupted output. How do you detect and recover?
A: Three layers of validation: (1) ffprobe the output segment — check duration matches expected, codec is correct, no decode errors. (2) Checksum comparison — output file size should be within expected range for that resolution/bitrate. (3) Canary playback — for a random sample of segments, actually decode and verify frame count. If validation fails, the segment is retried on a different worker (different hardware). After 3 failures, the segment is flagged for manual review and the video is marked 'processing_failed' with a specific error code.
Q:How do you support 'resume where I left off' across devices?
A: The player SDK reports watch progress every 10 seconds to a lightweight 'progress' endpoint. This writes to a per-user watch history in Cassandra (partition key: user_id, sort key: video_id). When the user opens the same video on any device, the player fetches the last-known position and seeks to that segment. Consistency: last-write-wins with client timestamps. If two devices report simultaneously, the higher timestamp wins. Latency: <50ms (Cassandra local read).
Q:How do you prevent the same video from being uploaded twice (deduplication)?
A: Content-based dedup: compute a perceptual hash (not cryptographic — we want 'visually similar' not 'byte-identical') of the first 30 seconds. Store hashes in a dedicated index. On upload-complete, compute hash and check for matches. If match found: (1) for same user — ask 'did you mean to re-upload?', (2) for different user — flag for Content ID / copyright review. This catches re-uploads and mirrors but not edits (cropped, watermarked). Full Content ID is a separate ML system.
Q:How do you handle regional content restrictions (geo-blocking)?
A: Video metadata includes an 'allowed_regions' or 'blocked_regions' list. The CDN edge checks the viewer's country (from IP geolocation) against this list before serving. If blocked: return 451 (Unavailable for Legal Reasons) with a user-friendly message. Implementation: CDN edge function (CloudFront Functions / Cloudflare Workers) reads the restriction from a lightweight KV store synced from the metadata DB. No origin hit needed for blocked content.
Q:What if a creator wants to replace a video without changing the URL?
A: The video_id stays the same. New upload triggers re-transcoding. New segments get a version prefix (/segments/{video_id}/v2/...). New manifest points to v2 segments. CDN purge on the old manifest (short TTL anyway). Old segments evict naturally via LRU. During the transition window (~5 min), some viewers see old content, others see new — acceptable for a 'replace' operation. The view count resets or continues based on creator preference.
Q:How do you handle subtitles and multiple audio tracks?
A: Subtitles are separate text files (WebVTT format) referenced in the manifest as alternative renditions. Multiple audio tracks (languages, commentary) are encoded as separate audio-only segments, also referenced in the manifest. The player selects based on user preference. Storage cost is minimal (text + audio-only streams are tiny compared to video). Auto-generated subtitles use speech-to-text during the transcoding pipeline (parallel with video transcoding).
Q:How would you add live streaming to this architecture?
A: Live streaming is a fundamentally different path: RTMP/SRT ingest → real-time transcoding (no pre-segmentation) → segments written to S3 every 2-4 seconds → manifest updated continuously (sliding window, not static). The CDN serves a 'live edge' manifest with short TTL (2s). Latency target: 5-15 seconds glass-to-glass (vs minutes for VOD processing). The VOD architecture handles the recording after the stream ends. I'd add this as a separate service, not modify the existing upload pipeline.
Q:How small can the team running this be at 'startup YouTube' scale (1M videos, 10M views/day)?
A: At that scale: 3-5 engineers. Use managed services aggressively — S3 for storage, CloudFront for CDN, MediaConvert for transcoding (AWS managed), RDS Postgres for metadata, ElastiCache Redis for caching, MSK for Kafka. Custom code: upload coordination, metadata API, recommendation (simple collaborative filtering, not deep learning). The architecture is the same — just smaller instances and managed services instead of self-hosted. Scale to 100M views/day before needing dedicated infra engineers.
Common traps (and how to avoid them)
Proxying video bytes through the application server
Routing a 500 MB upload through your API server consumes all its bandwidth and memory. One upload blocks other requests.
✅Always use presigned URLs for direct-to-S3 upload. The application server only handles lightweight coordination (init, status, complete).
Synchronous transcoding (blocking upload on processing)
Making the user wait 5+ minutes for transcoding to complete before confirming upload. Terrible UX and couples upload availability to transcoding availability.
✅Return 202 Accepted immediately after upload completes. Transcode asynchronously via queue. Notify when ready.
Incrementing view_count directly in the database
UPDATE videos SET view_count = view_count + 1 at 23K/sec creates a hot row with row-level locking. Database melts.
✅Fire-and-forget to Kafka → Flink aggregates → Redis INCRBY → periodic batch flush to DB. Never touch the DB synchronously for views.
Forgetting the CDN (serving from origin)
Serving 40 Tbps from S3 directly would cost $3.6M/day in egress alone and S3 can't handle that request rate.
✅CDN is the product, not an optimization. Design the streaming path as CDN-first. Origin is the fallback, not the primary.
Storing video as a single large file (no segmentation)
A 2 GB file can't be cached efficiently, can't support seek without downloading everything, and can't adapt quality mid-stream.
✅Segment into 4-second chunks during transcoding. Each segment is independently cacheable, seekable, and quality-switchable.
Single codec / single resolution
Serving only 1080p H.264 means mobile users on 3G can't watch (buffering) and 4K TV users get subpar quality.
✅Multiple renditions (360p through 4K) with adaptive bitrate streaming. The player selects quality based on bandwidth.
Ignoring storage costs
At 8.3 PB/day, keeping everything in S3 Standard forever costs $69M/month. This bankrupts the company.
✅Implement hot/warm/cold tiering from day one. Delete unused renditions for unwatched videos. Archive originals to Glacier.
Over-engineering recommendations for the initial design
Spending 15 minutes on neural network architectures and training pipelines when the interviewer asked about video delivery.
✅Cover recommendations at the serving layer (candidate gen → ranking → serve from cache). Mention ML exists but don't deep-dive unless asked.
🔥 The deepest trap — designing for YouTube's scale from minute one
Starting with "we need 72,000 GPU workers and a custom CDN with ISP partnerships" in the first 5 minutes signals pattern-matching, not thinking. Start with managed services (MediaConvert, CloudFront, RDS), identify where they break, and add complexity with explicit triggers. "CloudFront handles our CDN needs until we hit 10 Tbps — at that point, the cost savings of Open Connect justify the engineering investment."
Quick Revision
A 2-minute scan before the interview. If you can state each of these without looking, you're ready.
Quick Revision Cheat Sheet
Scale anchors: 500 hrs/min uploaded (100 videos/sec), 1B views/day (11.6K/sec), 10M peak concurrent streams.
Storage: ~8.3 PB/day (all renditions). Hot/warm/cold tiering saves 78% cost. 3 EB/year.
Bandwidth: 40 Tbps peak egress. CDN serves 99%+. Origin sees <1% of streaming traffic.
Upload mechanism: Presigned URLs → S3 multipart. Server never touches video bytes. Resumable via chunk tracking.
Transcoding: DAG: probe → segment (4s) → transcode (N×M parallel) → manifest → finalize. Temporal orchestrates.
Renditions: 4 resolutions (360p–1080p) × 2 codecs (H.264 + VP9). Don't upscale. Add AV1 for popular videos.
Streaming protocol: HLS primary (.m3u8 + .ts segments). Master manifest → media manifest → segments. 4s segment duration.
ABR algorithm: Client-side. Measures throughput per segment. Switches quality based on buffer level + bandwidth estimate.
CDN architecture: Edge PoP (80%) → Regional Shield (15%) → Origin Shield (4%) → S3 (<1%). Request coalescing at shield.
View counts: Player → Kafka → Flink (1-min window, dedup) → Redis INCRBY + Postgres batch flush. ~1 min stale.
Trending: Count-Min Sketch + min-heap for Top-K. Velocity-based scoring (recency-weighted). Updated every minute.
Recommendations: Two-stage: candidate gen (ANN + CF + trending) → ranking (neural net). Precomputed for active users in Redis.
Metadata store: PostgreSQL sharded by hash(video_id). Redis cache for hot reads. Elasticsearch for search.
Failure isolation: CDN down → origin serves. Kafka down → views lag, streaming unaffected. Transcoding down → existing videos play.
SLOs: Streaming: 99.99% / <2s TTFF. Upload: 99.9% / <5 min processing. Analytics: 99% / minutes of lag OK.
Dominant costs: Storage (tiering saves 78%) > CDN egress (VP9 saves 30%) > Transcoding GPU (spot saves 60-70%).
🎯 The 45-minute interview arc
- 0–5 min: Clarify requirements. VOD only? Scale targets? Global? State: "upload pipeline + streaming delivery is where the complexity lives."
- 5–10 min: Capacity estimation. Derive upload rate, storage growth, streaming bandwidth. Show why CDN is mandatory.
- 10–15 min: API + data model. Upload init, chunk upload, manifest fetch. Videos table + S3 path convention.
- 15–25 min: HLD — three paths (upload, stream, analytics). One diagram. Explain why they're separated.
- 25–35 min: Deep dive on whichever the interviewer probes — likely transcoding pipeline or ABR streaming.
- 35–40 min: Trade-offs. Codec choice, segment duration, sync vs async transcoding, CDN cost vs custom.
- 40–45 min: Follow-ups. Viral video, resumable uploads, live streaming extension, storage cost.
💡 The single sentence that defines a senior answer
"The streaming path is AP — I serve from CDN cache even if origin is unreachable. Upload is CP — I must never lose a video byte. Analytics is eventual — a 2-minute lag on view counts is invisible to users. Each path gets its own consistency model, SLO, and scaling axis because their requirements are fundamentally incompatible."
What is expected at each level
🟢 Mid-level
Define API endpoints and data model clearly. Land on presigned URLs for upload and segment-based streaming for playback. Know that transcoding is async and CDN is required. Drive one deep dive (likely upload resumability or basic ABR). Don't need to know codec details or Temporal — but should converge on the right patterns with guidance.
🔵 Senior
Quickly establish the three-path architecture and spend time on transcoding DAG details (segment-level parallelism, orchestration, failure handling). Know HLS manifest structure. Discuss CDN multi-tier caching with numbers. Articulate trade-offs between codec choices with cost implications. Handle follow-ups on viral videos and storage tiering confidently.
🟣 Staff+
Drive the entire conversation proactively. Discuss cost optimization (spot instances, tiered transcoding, codec selection based on popularity). Know Open Connect vs commercial CDN trade-offs. Discuss recommendation serving architecture with feature stores. Handle any follow-up (live streaming extension, multi-region, DRM) with depth. Connect every decision to dollar amounts and operational reality.