Design a Video Streaming Platform
A video streaming platform must handle two fundamentally different traffic patterns: infrequent, large creator uploads and continuous, high-concurrency viewer streams. Uploaded raw video must be durably stored, asynchronously transcoded into multiple resolution renditions, segmented for HTTP-based adaptive streaming, and distributed to a globally replicated CDN. Viewers receive only small segment fetches — the quality level adapts per segment based on real-time bandwidth — so no single server ever carries a full video stream.
Design it yourself
Don't just read it — drag components onto a canvas and get Aria's interviewer review.
Requirements
Functional
- Creators upload video files (up to 256 GB) via a resumable, chunked upload mechanism
- The platform transcodes each upload into multiple resolutions: 360p, 720p, 1080p, and 4K
- Viewers can search for and browse videos by title, tag, and category
- Video playback adapts quality in real-time based on viewer bandwidth (adaptive bitrate)
- Creators and viewers receive notifications when processing completes or a subscribed channel uploads
- View counts, likes, and comments are recorded per video
- Creators can delete a video, which must remove it from CDN and storage
Non-Functional
- 500M daily active users; peak 10M concurrent streams
- 500 hours of video uploaded every minute (YouTube 2024 scale)
- Video start latency < 2s (play button to first frame)
- 99.95% streaming availability — buffering is immediately user-visible
- Upload durability: zero data loss after the upload acknowledgement is returned
- Processed video available for streaming within 30 minutes of upload completion
- CDN cache hit rate > 95% for videos published in the last 7 days
Capacity Estimation
| Upload volume | 500 hrs/min × 60 min = 30,000 hrs/hr of raw video |
| Raw storage per hour of video | ~7 GB (1080p H.264 source) |
| Transcoded storage per video (all resolutions) | ~3.5 GB/hr (360p 0.3 + 720p 0.9 + 1080p 1.5 + 4K 3.5 ≈ 6.2 GB avg) |
| New processed storage per day | 720,000 hrs/day × 3.5 GB ≈ 2.5 PB/day |
| Peak concurrent streams | 10M streams × 4 Mbps avg ≈ 40 Tbps egress |
| CDN PoPs required | ~200 PoPs each serving ~200 Gbps = 40 Tbps |
| Metadata reads (views, search) | ~600K requests/sec at peak (60:1 read:write) |
| CDN cost driver | Egress bandwidth — minimised by maximising cache hit rate and segment reuse |
High-Level Components
Upload Service
Accepts chunked, resumable multipart uploads from creators. Issues pre-signed S3 URLs for direct chunk upload, tracks upload state in Redis, and publishes a VideoUploaded event to the transcoding queue when all chunks have been received and assembled.
Raw Video Store (S3)
Object storage bucket for raw creator uploads. Versioning disabled (immutable objects). Lifecycle policy moves objects to Glacier after 90 days post-processing. Serves as the durable source of truth for re-transcoding runs.
Transcoding Queue (Kafka / SQS)
Decouples the upload path from the CPU-intensive transcode path. Each VideoUploaded event is a message carrying the raw S3 key and target rendition set. Consumers are stateless worker pods that auto-scale based on queue depth.
Transcoder Workers
Stateless pods (GPU-enabled for H.265/AV1) running FFmpeg. Each worker processes one transcode job: downloads the raw source from S3, encodes the target resolution, segments the output into HLS .ts chunks, and uploads segments + playlist to the processed S3 bucket.
Processed Video Store (S3)
Object storage bucket for HLS segments (.ts files) and playlists (.m3u8 files) for all renditions. Organised as {videoId}/{rendition}/segNNN.ts. This bucket is the CDN origin — all CDN edge nodes pull segments from here on cache miss.
CDN (CloudFront / Fastly)
Globally distributed edge network with 200+ PoPs. Serves segment and manifest requests directly from edge cache. Cache TTL for segments is 7 days (immutable); manifests use shorter TTLs (60s) to allow adaptation. Geo-routing directs each viewer to the nearest healthy PoP.
Video Metadata Service
Stores and serves video metadata: title, description, tags, uploader ID, view count, status (processing / ready / deleted), and CDN manifest URLs. Backed by PostgreSQL with read replicas. Hot video metadata is cached in Redis with a 60-second TTL.
Search Service (Elasticsearch)
Full-text index over video titles, descriptions, and tags. Updated asynchronously via a Kafka CDC stream from the metadata database (Debezium). Supports fuzzy matching, faceted filtering by category, and view-count-weighted relevance ranking.
Notification Service
Consumes TranscodingCompleted events and SubscriptionTriggered events from Kafka. Sends push notifications, emails, and in-app alerts to creators (upload done) and subscribers (new video). Fully decoupled from the upload and playback paths.
Playback API
Returns the CDN manifest URL for a given video after checking its processing status. Increments view count asynchronously via a Kafka event (not a synchronous DB write). Returns a signed URL if the video is access-controlled.
Architecture Diagram
Deep Dives
Video Upload Pipeline — Chunked Multipart Uploads and Resumability
A 256 GB 4K source file cannot be uploaded in a single HTTP request — network interruptions, mobile connectivity drops, and browser/server timeouts make single-request uploads unreliable at this scale. The solution is chunked multipart upload with resumability.
Pre-signed URL flow: 1. Creator calls `POST /uploads` → the Upload Service generates a unique `uploadId`, creates an S3 multipart upload session, and returns a list of pre-signed part URLs (one per 100 MB chunk). 2. The client uploads each part directly to S3 using the pre-signed URLs — the Upload Service is not in the data path. S3 enforces the signature, content-length, and expiry. 3. After each successful part upload, S3 returns an `ETag` for that part. The client checkpoints the `(partNumber, ETag)` pair to local storage. 4. If the upload is interrupted, the client calls `GET /uploads/{uploadId}/state` to retrieve which parts have been confirmed. It resumes from the first missing part. 5. When all parts are uploaded, the client calls `POST /uploads/{uploadId}/complete`. The Upload Service calls `CompleteMultipartUpload` on S3 with the full part-ETag list. S3 assembles the file atomically. 6. The Upload Service validates the assembled file (size check, MIME type sniff) and publishes a `VideoUploaded` event to Kafka.
State management: Upload state (`uploadId → { s3UploadId, completedParts[], totalParts, videoMetadata }`) is stored in Redis with a 72-hour TTL. If the creator abandons the upload, a daily cleanup job calls `AbortMultipartUpload` on S3 to release reserved storage.
Why direct-to-S3 upload (bypassing the service)? The Upload Service would become a bandwidth bottleneck if video bytes flowed through it. Pre-signed URLs shift the data plane to S3 while the service retains control of the control plane (authentication, state tracking, lifecycle events).
Java — Upload Service: initiate and complete chunked multipart upload
// Upload Service — initiate multipart upload and return pre-signed part URLs
@RestController
@RequestMapping("/uploads")
public class UploadController {
private static final long CHUNK_SIZE_BYTES = 100L * 1024 * 1024; // 100 MB
private final S3PresignClient s3Presign;
private final S3Client s3;
private final RedisTemplate<String, UploadState> redis;
private final KafkaTemplate<String, VideoUploaded> kafka;
@PostMapping
public InitiateUploadResponse initiateUpload(@RequestBody @Valid InitiateUploadRequest req,
@AuthenticationPrincipal Creator creator) {
// 1. Create multipart upload session in S3
String rawKey = "raw/" + UUID.randomUUID() + "/" + req.filename();
CreateMultipartUploadResponse mpResponse = s3.createMultipartUpload(b -> b
.bucket("video-raw")
.key(rawKey)
.contentType(req.contentType())
.metadata(Map.of("creatorId", creator.id().toString())));
String s3UploadId = mpResponse.uploadId();
int totalParts = (int) Math.ceil((double) req.fileSizeBytes() / CHUNK_SIZE_BYTES);
// 2. Pre-sign a URL for each part
List<String> partUrls = new ArrayList<>();
for (int part = 1; part <= totalParts; part++) {
UploadPartPresignRequest presignReq = UploadPartPresignRequest.builder()
.signatureDuration(Duration.ofHours(2))
.uploadPartRequest(b -> b
.bucket("video-raw")
.key(rawKey)
.uploadId(s3UploadId)
.partNumber(part))
.build();
partUrls.add(s3Presign.presignUploadPart(presignReq).url().toString());
}
// 3. Persist upload state to Redis (TTL = 72 hours)
String uploadId = UUID.randomUUID().toString();
UploadState state = new UploadState(uploadId, s3UploadId, rawKey,
totalParts, new ArrayList<>(), creator.id(), req.title());
redis.opsForValue().set("upload:" + uploadId, state, Duration.ofHours(72));
return new InitiateUploadResponse(uploadId, partUrls);
}
@PostMapping("/{uploadId}/complete")
public CompleteUploadResponse completeUpload(@PathVariable String uploadId,
@RequestBody CompleteUploadRequest req) {
UploadState state = redis.opsForValue().get("upload:" + uploadId);
if (state == null) throw new NotFoundException("Upload session expired or not found");
// 4. Complete S3 multipart upload
List<CompletedPart> parts = req.parts().stream()
.map(p -> CompletedPart.builder()
.partNumber(p.partNumber()).eTag(p.etag()).build())
.toList();
s3.completeMultipartUpload(b -> b
.bucket("video-raw")
.key(state.rawKey())
.uploadId(state.s3UploadId())
.multipartUpload(u -> u.parts(parts)));
// 5. Publish event to trigger transcoding pipeline
String videoId = UUID.randomUUID().toString();
kafka.send("video-uploaded", new VideoUploaded(
videoId, state.rawKey(), state.creatorId(), state.title(),
List.of("360p", "720p", "1080p", "2160p")));
redis.delete("upload:" + uploadId);
return new CompleteUploadResponse(videoId, "PROCESSING");
}
}Transcoding Pipeline — FFmpeg Workers, Parallelism, and Output Layout
Transcoding is the most compute-intensive operation in the pipeline. A 2-hour 4K source file encoded serially into four renditions (360p, 720p, 1080p, 4K) using H.264 takes approximately 6–10 hours on a single CPU. Two levels of parallelism reduce this to under 30 minutes.
Level 1 — Rendition parallelism: The Kafka message for a `VideoUploaded` event is fanned out into one job per target rendition. Each job is an independent message on the `transcode-tasks` topic. Four separate worker pods process 360p, 720p, 1080p, and 4K simultaneously.
Level 2 — Temporal (chunk) parallelism: Each worker splits the source video into 10-minute chunks using FFmpeg's `-ss` / `-to` flags (seeking without re-encoding via keyframe alignment). Each chunk is encoded independently and then stitched back. A 2-hour video → 12 chunks × 4 renditions = 48 parallel tasks on 48 worker pods.
FFmpeg encode settings per rendition: ``` 360p: -vf scale=-2:360 -c:v libx264 -crf 28 -preset fast -b:v 800k 720p: -vf scale=-2:720 -c:v libx264 -crf 23 -preset medium -b:v 2500k 1080p: -vf scale=-2:1080 -c:v libx264 -crf 20 -preset medium -b:v 5000k 4K: -vf scale=-2:2160 -c:v libx265 -crf 22 -preset slow -b:v 15000k ```
HLS segmentation: Each rendition is segmented into 4-second `.ts` segments with `-hls_time 4`. FFmpeg outputs a per-rendition `.m3u8` playlist alongside the segments. A final manifest-generation step assembles the master `.m3u8`.
S3 output layout: ``` processed/{videoId}/ master.m3u8 ← master playlist (rendition index) 360p/ playlist.m3u8 ← rendition playlist (segment index) seg000.ts seg001.ts ... 720p/ playlist.m3u8 seg000.ts ... 1080p/ ... 2160p/ ... ```
Completion detection: A `TranscodeCoordinator` listens for `TaskCompleted` events from each worker. When all renditions for a `videoId` are done, it calls the Metadata Service to flip the video status from `PROCESSING` to `READY` and publishes a `VideoReady` event to trigger notifications.
Java — Transcode worker: chunk-parallel FFmpeg encoding with HLS segmentation
// Transcode worker — processes one rendition job
@Component
public class TranscodeWorker {
private static final int CHUNK_DURATION_SECS = 600; // 10 minutes
private final S3Client s3;
private final FFmpegRunner ffmpeg;
private final KafkaTemplate<String, Object> kafka;
@KafkaListener(topics = "transcode-tasks", concurrency = "8")
public void processTask(TranscodeTask task) {
Path workDir = Files.createTempDirectory("transcode-" + task.taskId());
try {
// 1. Download raw source from S3
Path rawFile = workDir.resolve("source.mp4");
s3.getObject(b -> b.bucket("video-raw").key(task.rawS3Key()),
ResponseTransformer.toFile(rawFile));
// 2. Split into 10-minute chunks for parallel processing
List<Path> chunks = ffmpeg.splitIntoChunks(rawFile, CHUNK_DURATION_SECS, workDir);
// 3. Encode each chunk at the target rendition
RenditionConfig config = RenditionConfig.forProfile(task.rendition());
List<Path> encodedChunks = new ArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
Path encoded = workDir.resolve("encoded_chunk_" + i + ".ts");
ffmpeg.encode(chunks.get(i), encoded, config);
encodedChunks.add(encoded);
}
// 4. Concatenate encoded chunks into a single stream
Path concatenated = workDir.resolve("full_" + task.rendition() + ".ts");
ffmpeg.concat(encodedChunks, concatenated);
// 5. Segment into HLS .ts files (4-second segments)
Path segmentDir = workDir.resolve("segments");
Files.createDirectories(segmentDir);
String segmentPattern = segmentDir.resolve("seg%03d.ts").toString();
String playlistPath = segmentDir.resolve("playlist.m3u8").toString();
ffmpeg.segmentToHls(concatenated, segmentPattern, playlistPath, 4);
// 6. Upload segments and playlist to S3
String s3Prefix = "processed/" + task.videoId() + "/" + task.rendition() + "/";
Files.list(segmentDir).forEach(file ->
s3.putObject(b -> b.bucket("video-processed").key(s3Prefix + file.getFileName()),
RequestBody.fromFile(file)));
// 7. Notify coordinator
kafka.send("transcode-completed",
new TranscodeCompleted(task.videoId(), task.rendition(),
s3Prefix + "playlist.m3u8"));
} finally {
FileUtils.deleteDirectory(workDir.toFile());
}
}
}
// Rendition config factory
public record RenditionConfig(String scale, String codec, int crf,
String preset, String targetBitrate) {
public static RenditionConfig forProfile(String profile) {
return switch (profile) {
case "360p" -> new RenditionConfig("scale=-2:360", "libx264", 28, "fast", "800k");
case "720p" -> new RenditionConfig("scale=-2:720", "libx264", 23, "medium", "2500k");
case "1080p" -> new RenditionConfig("scale=-2:1080", "libx264", 20, "medium", "5000k");
case "2160p" -> new RenditionConfig("scale=-2:2160", "libx265", 22, "slow", "15000k");
default -> throw new IllegalArgumentException("Unknown rendition: " + profile);
};
}
}HLS vs DASH — Segment Structure, Manifests, and Segment Duration Trade-offs
HLS (HTTP Live Streaming) and DASH (Dynamic Adaptive Streaming over HTTP) are the two dominant adaptive streaming protocols. Both work by splitting video into small segments and using a manifest file to tell the client which segment URLs exist at what quality levels.
HLS structure: ``` master.m3u8 (Apple-defined; EXTM3U tag) #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360 360p/playlist.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720 720p/playlist.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080 1080p/playlist.m3u8
360p/playlist.m3u8: #EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:4 #EXT-X-MEDIA-SEQUENCE:0 seg000.ts (MPEG-2 Transport Stream) seg001.ts ... #EXT-X-ENDLIST ```
DASH structure: A single `manifest.mpd` (XML) describes all representations (video + audio tracks). Segments are typically fragmented MP4 (fMP4) rather than MPEG-TS. ```xml <MPD type="static" mediaPresentationDuration="PT2H"> <Period> <AdaptationSet mimeType="video/mp4"> <Representation id="360p" bandwidth="800000" width="640" height="360"> <SegmentTemplate media="360p/seg$Number$.m4s" duration="4"/> </Representation> <Representation id="720p" bandwidth="2500000" width="1280" height="720"> <SegmentTemplate media="720p/seg$Number$.m4s" duration="4"/> </Representation> </AdaptationSet> </Period> </MPD> ```
Key differences:
| Aspect | HLS | DASH | |---|---|---| | Container | MPEG-TS (.ts) or fMP4 (.m4s) | fMP4 (.m4s) | | Manifest format | Plain text (.m3u8) | XML (.mpd) | | Native iOS/Safari support | Yes (required) | No (needs JS library) | | Open standard | No (Apple-controlled) | Yes (ISO 23009-1) | | Latency (live) | Higher (3–5 segments) | Lower (CMAF Low Latency) |
Segment duration trade-offs:
| Duration | Advantage | Disadvantage | |---|---|---| | 2 seconds | Faster quality switching; lower stall probability | More HTTP requests; higher manifest overhead; more S3 objects | | 4 seconds (recommended) | Good balance of switching agility and request overhead | Slight extra latency on quality downgrade | | 10 seconds | Fewer requests; simpler CDN cache (longer TTL per object) | Slow to react to bandwidth drops; long stall if segment download fails |
Production recommendation: 4-second segments with HLS for Apple clients and DASH elsewhere, using the same underlying fMP4 segment files (CMAF — Common Media Application Format) to avoid duplicating storage. One set of `.m4s` segments, two manifests.
Java — HLS master playlist generator with per-rendition metadata
// Manifest generator — builds master M3U8 and per-rendition playlists
@Component
public class HlsManifestGenerator {
private final S3Client s3;
/**
* Called by TranscodeCoordinator once all renditions are complete.
* Reads each rendition's playlist from S3 and builds the master playlist.
*/
public String generateMasterPlaylist(String videoId,
List<RenditionMeta> renditions) {
StringBuilder master = new StringBuilder();
master.append("#EXTM3U
");
master.append("#EXT-X-VERSION:3
");
for (RenditionMeta r : renditions) {
master.append(String.format(
"#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%s,CODECS="%s"
",
r.bandwidthBps(), r.resolution(), r.codec()));
// Relative path — CDN resolves against the videoId prefix
master.append(r.rendition() + "/playlist.m3u8
");
}
String masterKey = "processed/" + videoId + "/master.m3u8";
s3.putObject(
b -> b.bucket("video-processed").key(masterKey)
.contentType("application/vnd.apple.mpegurl")
// short TTL on manifest so clients pick up new renditions quickly
.cacheControl("public, max-age=60"),
RequestBody.fromString(master.toString()));
return masterKey;
}
}
// Rendition metadata used during manifest generation
public record RenditionMeta(
String rendition, // e.g. "720p"
String resolution, // e.g. "1280x720"
long bandwidthBps, // e.g. 2_500_000
String codec // e.g. "avc1.64001F"
) {}Adaptive Bitrate Streaming (ABR) — Bandwidth Estimation, Switching Logic, and Buffering
ABR gives the video player the ability to switch quality level per segment so it always downloads the highest quality the current bandwidth can sustain without exhausting the buffer.
The two inputs to every ABR decision: 1. Estimated download bandwidth — measured from the time-to-download of the previous segment: `bwEst = segmentSizeBytes × 8 / downloadTimeSecs`. Use an exponentially weighted moving average (EWMA) with α = 0.3 to smooth out transient spikes. 2. Buffer occupancy — seconds of decoded video already buffered ahead of the playhead. This is the safety margin; if it falls to zero, playback stalls.
Throughput-based ABR (simple, widely deployed): Select the highest bitrate rendition whose bandwidth requirement is ≤ 80% of the estimated download bandwidth. The 20% headroom accounts for HTTP overhead and bandwidth variance.
Buffer-based ABR (BOLA — used by YouTube/Netflix): Treats buffer occupancy as the primary signal, not raw bandwidth. Lyapunov optimisation maps current buffer level to a quality selection: - Buffer > 30s → step up - Buffer 15–30s → hold - Buffer 10–15s → step down - Buffer < 5s → drop to lowest immediately (panic mode)
BOLA is more stable than pure throughput-based ABR because it avoids oscillation caused by bandwidth measurement noise.
Startup sequence: On initial play, the buffer is empty and bandwidth is unknown: 1. Download the first segment at the lowest quality (fast start — typically < 300 KB) 2. Measure download time → initial bandwidth estimate 3. From segment 2, apply the ABR algorithm using the estimate 4. First frame typically rendered in < 2 seconds
Buffering strategy: - Target buffer: 30 seconds (enough to absorb a 10-second bandwidth dip without stalling) - Max buffer: 60 seconds (prevent pre-fetching too far ahead; wastes bandwidth if user seeks) - Seek handling: On a seek, flush buffer, request the segment containing the seek position at medium quality, then re-apply ABR - Pre-fetch: While watching segment N, the player pre-fetches segment N+1 (and N+2 if buffer is healthy)
Audio/video synchronisation: In fMP4 (CMAF), audio and video are in separate tracks and can be switched independently. The player downloads audio segments from the highest-quality audio rendition regardless of video quality (audio bitrate is small ≈ 128 Kbps).
Java — ABR controller with EWMA bandwidth estimation and buffer-based guard rails
// Client-side ABR quality selector (runs in the browser/native player)
public class AbrController {
private static final double EWMA_ALPHA = 0.3;
private static final double BANDWIDTH_MARGIN = 0.8; // use 80% of estimate
private static final double BUFFER_STEP_UP = 30.0;
private static final double BUFFER_HOLD_LOW = 15.0;
private static final double BUFFER_STEP_DOWN = 10.0;
private static final double BUFFER_PANIC = 5.0;
// Rendition bitrate ladder (bits per second)
private final long[] bitrateladder = {
400_000L, // 360p
1_500_000L, // 720p
4_000_000L, // 1080p
12_000_000L, // 4K
};
private double estimatedBandwidthBps = bitrateladder[0]; // start conservative
private int currentLevel = 0;
/**
* Called after each segment download completes.
*
* @param segmentBytes bytes received for the last segment
* @param downloadMs wall-clock milliseconds to download
* @param bufferSeconds decoded seconds ahead of the playhead
* @return next rendition index to request
*/
public int selectNextLevel(long segmentBytes, long downloadMs, double bufferSeconds) {
// 1. Update bandwidth estimate (EWMA)
double instantBw = (segmentBytes * 8.0) / (downloadMs / 1000.0);
estimatedBandwidthBps = EWMA_ALPHA * instantBw
+ (1 - EWMA_ALPHA) * estimatedBandwidthBps;
double effectiveBw = estimatedBandwidthBps * BANDWIDTH_MARGIN;
// 2. Buffer-based guard rails
if (bufferSeconds < BUFFER_PANIC) {
currentLevel = 0; // emergency drop
return currentLevel;
}
if (bufferSeconds < BUFFER_STEP_DOWN) {
currentLevel = Math.max(0, currentLevel - 1);
return currentLevel;
}
// 3. Throughput-based selection within buffer-healthy range
if (bufferSeconds >= BUFFER_STEP_UP) {
// Try to step up if bandwidth supports the next level
int desired = currentLevel;
for (int i = bitrateladder.length - 1; i >= 0; i--) {
if (effectiveBw >= bitrateladder[i]) {
desired = i;
break;
}
}
// Only step up one level at a time to avoid overloading
currentLevel = Math.min(currentLevel + 1, desired);
}
// else: BUFFER_HOLD_LOW <= bufferSeconds < BUFFER_STEP_UP → hold
return currentLevel;
}
}CDN Architecture — Origin Pull vs Push, Edge TTL, Geo-Routing, and Cache Invalidation
Origin pull (reactive) CDN: When a viewer requests a segment, the nearest CDN edge node checks its local cache. On a miss, the edge node fetches the segment from the origin S3 bucket, caches it locally, and serves it. Subsequent requests for the same segment from the same PoP are served from cache.
Pros: Simple to operate; no pre-population logic needed. New content becomes available at the edge as soon as the first viewer in a region requests it. Cons: Cold start — the very first viewer in each region triggers an origin fetch. For viral videos with millions of simultaneous first-viewers, this can overload origin unless combined with request coalescing (edge de-duplication of concurrent origin fetches for the same key).
Origin push (proactive) CDN: After transcoding completes, the platform proactively pushes segments to CDN edge nodes in regions predicted to have high viewership. This eliminates cold start for viral content.
Pros: Zero-latency first-viewer experience in pre-warmed regions. Cons: Wasteful for long-tail content (billions of videos are watched only once); complex to operate and predict; significant egress cost for content that never gets viewed in the pushed region.
Hybrid approach (recommended for a public platform): - Use pull CDN by default for all content. - For content expected to trend (based on upload velocity, creator subscriber count, or editorial signals), proactively push the first 30 seconds of video (enough to fill the initial buffer) to top-10 PoPs. - Use request coalescing at each edge node: if 1,000 viewers request `seg000.ts` simultaneously before it is cached, only one origin fetch is made and the response is fanned out to all 1,000 waiters.
TTL strategy: | Object type | Cache-Control | Rationale | |---|---|---| | Segment (.ts / .m4s) | `public, max-age=604800` (7 days) | Segments are immutable — once written, never changed. Long TTL maximises cache hit rate. | | Rendition playlist (.m3u8) | `public, max-age=60` | Rarely changes for VOD, but short TTL allows future re-transcoding to propagate quickly. | | Master playlist (.m3u8) | `public, max-age=60` | Same reasoning as rendition playlist. | | Thumbnail (.jpg) | `public, max-age=3600` | Updated occasionally; 1-hour TTL is a safe compromise. |
Geo-routing: A global load balancer (Anycast DNS or GeoDNS) maps each viewer's IP to the nearest healthy CDN PoP. If the nearest PoP is degraded (latency spike or partial outage), the DNS health check fails over to the next-nearest PoP within seconds. For live-streaming extensions, latency-aware routing is critical; for VOD (on-demand), throughput-aware routing dominates.
Cache invalidation on video deletion: When a creator deletes a video: 1. The Metadata Service marks the video status as `DELETED` and stops serving manifest URLs. 2. A `VideoDeleted` event is published to Kafka. 3. A CDN Invalidation Worker consumes the event and issues CDN path invalidations for `processed/{videoId}/*` (CloudFront: `CreateInvalidation` API; Fastly: `PURGE` requests). 4. The worker also schedules S3 object deletion via S3 Batch Operations (asynchronous, to avoid overwhelming S3 on bulk deletes). 5. Invalidation propagates to all 200+ PoPs within 60 seconds.
Why not rely solely on TTL expiry for deletion? A 7-day TTL on segments means deleted content could remain accessible at cached edges for up to 7 days after deletion — a legal and compliance risk (DMCA takedowns, content moderation). Explicit invalidation is mandatory for delete use cases.
Java — CDN invalidation worker: CloudFront purge + S3 batch delete on video deletion
// CDN Invalidation Worker — consumes VideoDeleted events and purges CDN + schedules S3 cleanup
@Component
public class CdnInvalidationWorker {
private final CloudFrontClient cloudFront;
private final S3Client s3;
private final VideoMetadataRepository metadataRepo;
@Value("${cdn.cloudfront.distribution-id}")
private String distributionId;
@KafkaListener(topics = "video-deleted")
public void onVideoDeleted(VideoDeleted event) {
String videoId = event.videoId();
// 1. Issue CloudFront invalidation for all paths under this video
cloudFront.createInvalidation(b -> b
.distributionId(distributionId)
.invalidationBatch(ib -> ib
.callerReference("delete-" + videoId + "-" + System.currentTimeMillis())
.paths(p -> p
.quantity(1)
.items("/processed/" + videoId + "/*"))));
log.info("CDN invalidation issued for videoId={}", videoId);
// 2. Schedule async S3 deletion via S3 Batch Operations manifest
// Build a CSV manifest of all S3 keys under this video
List<String> segmentKeys = listAllSegmentKeys("video-processed",
"processed/" + videoId + "/");
String manifestKey = "delete-manifests/" + videoId + ".csv";
String csvContent = segmentKeys.stream()
.map(k -> "video-processed," + k)
.collect(Collectors.joining("
"));
s3.putObject(b -> b.bucket("ops-manifests").key(manifestKey),
RequestBody.fromString(csvContent));
// Trigger S3 Batch Operations job (ARN configured externally)
log.info("S3 batch delete manifest written for videoId={} ({} objects)",
videoId, segmentKeys.size());
// 3. Update metadata status (belt-and-suspenders — already set by Metadata Service)
metadataRepo.setStatus(videoId, VideoStatus.DELETED);
}
private List<String> listAllSegmentKeys(String bucket, String prefix) {
List<String> keys = new ArrayList<>();
ListObjectsV2Request req = ListObjectsV2Request.builder()
.bucket(bucket).prefix(prefix).build();
s3.listObjectsV2Paginator(req)
.forEach(page -> page.contents().forEach(obj -> keys.add(obj.key())));
return keys;
}
}Key Trade-offs
HLS vs DASH
Apple's Safari and iOS do not support DASH natively — HLS is mandatory on those platforms. DASH is an open ISO standard with better tooling on Android and Smart TVs. Using CMAF (fragmented MP4) for the underlying segments allows a single set of segment files to be referenced by both manifests, keeping storage cost the same as using only one protocol.
Push CDN vs pull CDN
A push CDN pre-populates all content at all edges — prohibitively expensive for a long-tail platform with billions of videos, most of which are watched infrequently. A pull CDN only stores content at an edge node once a viewer in that region requests it. Selective push (first 30 seconds to top-10 PoPs for trending videos) eliminates cold start for the small subset of content that will receive simultaneous viral traffic.
Centralised vs distributed transcoding
A centralised transcoding server creates a single point of failure and cannot scale horizontally without coordination. A message queue (Kafka/SQS) decouples upload from transcoding, provides natural backpressure, and allows the worker pool to scale horizontally based on queue depth. Workers are stateless — any worker can process any job — enabling spot-instance or preemptible-VM usage for 60–80% cost reduction versus reserved instances.
Segment length: 2s vs 4s vs 10s
2-second segments react faster to bandwidth changes but generate 2× the number of HTTP requests and S3 objects, increasing CDN overhead and manifest size. 10-second segments reduce request count but react slowly to bandwidth drops, causing long stall events. 4 seconds is the industry consensus (used by YouTube, Netflix VOD, and HLS spec examples) — fast enough for smooth ABR quality switching, minimal enough in request overhead for practical CDN caching.
Interview Tips
- 1Separate the upload path from the playback path early in your design. Creators upload raw video to object storage — viewers never touch the raw file. The CDN serves only processed segments. This separation is the first thing interviewers evaluate.
- 2Explain why pre-signed URLs are used for uploads. The Upload Service issues pre-signed S3 URLs so video bytes flow directly from the creator's client to S3, bypassing your application servers. This prevents the service from being a bandwidth bottleneck and is a standard FAANG upload pattern.
- 3Know the HLS manifest hierarchy: master playlist → per-rendition playlist → segment files. Interviewers will ask you to sketch this. Also know that manifests have short TTLs (60s) while segments are immutable with long TTLs (7 days).
- 4For ABR, name both inputs: estimated bandwidth (EWMA of recent segment download speeds) and buffer occupancy. Buffer-based ABR (BOLA) is more stable than pure throughput-based. Mention the panic threshold (drop to lowest quality below 5s buffer) — it shows you've thought about the worst case.
- 5On CDN, avoid the shallow answer of "use CloudFront." Explain the pull model, request coalescing for viral cold-start, TTL differences between segments and manifests, and how invalidation works on delete. These details differentiate senior from mid-level candidates.
- 6Common mistake: designing the transcoding as a synchronous in-request operation. Always make transcoding async via a queue. The upload returns a `videoId` with status `PROCESSING`; the client polls or receives a push notification when the video is `READY`.
Discussion
Discussion
Sign in to join the discussion.