#Design a video streaming service
AddedA video service looks like one product but is really two systems with opposite shapes. The upload side runs once per video: a creator sends a large file, and a pipeline turns it into many smaller files. It is write-heavy, CPU-bound and allowed to take minutes. The watch side runs billions of times: players fetch those small files over and over, and they must start in a second or two and never freeze. It is read-heavy, bandwidth-bound and lives almost entirely in a CDN.
The mental model is a factory and a warehouse network. The factory (transcoding) cuts each video into 6-second pieces and packs every piece in several sizes, from a thin 145 kbps version for a weak phone signal up to 1080p at several megabits. The warehouses (CDN edges) keep the popular packages close to viewers. The player is the clever shopper: before each piece it looks at how fast the last pieces arrived and how much video it has saved up, and picks which size to fetch next. That choice is called adaptive bitrate (ABR), and it is the part interviewers most like to probe, because it is where the network, the CDN and the user experience meet.
The numbers that drive the design: at YouTube scale (more than 500 hours uploaded every minute, as YouTube said in 2019, and over a billion hours watched a day, as it said in 2017) the service has to serve about 135 bytes for every byte it stores, and peak egress is in the hundreds of terabits per second. That one ratio explains why the CDN, not the database, is the centre of the design.
The heart of the watch path is the player's bitrate decision. This model uses Apple's example H.264 ladder, 6-second segments and a seeded bandwidth trace that goes from good Wi-Fi into a train tunnel and out onto 4G:
// Seeded PRNG so the bandwidth trace is the same on every run.
const mulberry32 = (a) => () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const LADDER = [145, 365, 730, 1100, 2000, 3000, 4500, 6000, 7800]; // kbps
const SEG = 6, MAX_BUFFER = 30, SAFETY = 0.8;
// Network (kbps): good Wi-Fi, then a train tunnel, then 4G, with ±20% seeded noise.
const rand = mulberry32(42);
const trace = Array.from({ length: 16 }, (_, i) => Math.round((i < 6 ? 9000 : i < 11 ? 900 : 4000) * (0.8 + 0.4 * rand())));
function pick(estimate, buffer) {
const budget = estimate * SAFETY * (buffer < 8 ? 0.5 : 1); // low buffer: be twice as careful
let rung = 0;
for (let r = 0; r < LADDER.length; r++) if (LADDER[r] <= budget) rung = r;
return rung;
}
function play(abandon, print) {
let fast = 0, slow = 0, buffer = 0, stalled = 0;
const estimate = () => Math.min(fast, slow); // two moving averages; trust the lower
trace.forEach((bw, i) => {
const est = estimate(); // what the player believes before this fetch
let rung = i === 0 ? 0 : pick(est, buffer); // start on the lowest rung
let seconds = (LADDER[rung] * SEG) / bw;
let note = "";
if (abandon && seconds > buffer && rung > 0) { // would run dry: give up after 1 s
const was = LADDER[rung];
rung = pick(bw, buffer - 1); // re-pick with what we just measured
seconds = 1 + (LADDER[rung] * SEG) / bw;
note = ` abandoned ${was}`;
}
const stall = i === 0 ? 0 : Math.max(0, seconds - buffer); // segment 0 is start-up, not a stall
stalled += stall;
buffer = Math.min(MAX_BUFFER, Math.max(0, buffer - seconds) + SEG);
fast = i === 0 ? bw : 0.5 * fast + 0.5 * bw; // reacts within a segment or two
slow = i === 0 ? bw : 0.8 * slow + 0.2 * bw; // remembers the longer trend
if (print) console.log(`${String(i).padStart(3)} ${String(bw).padStart(7)} ${String(Math.round(est)).padStart(8)}` +
` ${String(LADDER[rung]).padStart(5)} ${seconds.toFixed(1).padStart(6)}s ${buffer.toFixed(1).padStart(5)}s` +
` ${stall ? stall.toFixed(1) + "s" : "-"}`.padStart(7) + note);
});
return stalled;
}
console.log("seg network estimate kbps fetch buffer stall");
const withAbandon = play(true, true);
console.log(`stall with abandonment: ${withAbandon.toFixed(1)} s, without: ${play(false, false).toFixed(1)} s`);seg network estimate kbps fetch buffer stall
0 9364 0 145 0.1s 6.0s -
1 8814 9364 3000 2.0s 10.0s -
2 10269 9089 6000 3.5s 12.5s -
3 9611 9457 6000 3.7s 14.7s -
4 7829 9488 6000 4.6s 16.1s -
5 9096 8737 6000 4.0s 18.2s -
6 818 8917 365 3.7s 20.5s - abandoned 6000
7 945 4867 3000 19.0s 7.4s -
8 1032 2906 1100 6.4s 7.0s -
9 890 1969 730 4.9s 8.1s -
10 810 1430 145 2.1s 12.0s - abandoned 1100
11 4611 1120 730 0.9s 17.1s -
12 4393 2865 2000 2.7s 20.4s -
13 3691 3629 2000 3.3s 23.1s -
14 3516 3660 2000 3.4s 25.7s -
15 4001 3588 2000 3.0s 28.7s -
stall with abandonment: 0.0 s, without: 27.2 s- The ladder is the set of renditions the transcoder produced:
145to7800kbps. A rendition is only useful if the network can deliver it faster than real time: a 6-second segment at6000kbps is 36 megabits, so on a 9,000 kbps link it takes about 4 s to fetch, and the player gains 2 s of buffer. - Segment 0 starts on the lowest rung (
145) because the player has no measurement yet; it arrives in0.1s, so start-up is fast. Its measured throughput (9364) becomes both moving averages, and segment 1 jumps to3000, then6000from segment 2. It never takes7800: the budget is 80% of the estimate (SAFETY), about 7,000 to 7,600 kbps here, so the top rung would leave no margin. - The player keeps two moving averages of measured throughput and trusts the lower one. The fast one reacts within a segment or two; the slow one remembers the trend. Taking the minimum means "go down quickly, come back up slowly", which is what Shaka Player and hls.js do (both document a min-of-two-EWMAs estimator).
- At segment 6 the network collapses to
818kbps. The player had planned6000, which would take 44 s, far more than the 18.2 s in its buffer. It abandons the download after 1 s and re-picks using what it just measured:365. The fetch costs 3.7 s in total and the buffer still grows to20.5s. Without abandonment the same trace stalls for27.2 s. - Segment 7 shows the cost of a lagging estimate: the fast average still says
4867, so it fetches3000at 945 kbps, which takes19.0sand drains the buffer from 20.5 s to7.4s. No stall, because the buffer absorbed it, but the buffer is now below 8 s, so the next two picks are twice as cautious (1100, then730). At segment 10 the buffer is back at 8.1 s, so the player tries1100on the full budget, and abandonment turns it into145. - From segment 11 the 4G link gives about 4,000 kbps, the averages climb back, and the player settles on
2000with the buffer refilling towards the 30 s cap. - What this demonstrates: quality is a per-segment decision made by the client, from two signals (throughput and buffer). The server just stores every rung. That is why the backend design is about producing the ladder and getting segments close to viewers, not about pushing a stream.
Requirements
Functional, agreed with the interviewer:
- Creators upload videos (up to a few GB, from flaky mobile connections, so uploads must be resumable).
- The system processes each upload into streamable renditions and thumbnails; the video becomes watchable within minutes.
- Viewers watch on phones, browsers and TVs, with quality adapting to their network; they can seek.
- Viewers see title, creator and view count. Search, recommendations, comments and live streaming are out of scope (say so; live changes the segment and latency story, see the gotcha below).
Non-functional:
- Playback start under about 2 seconds and rare rebuffering: the metrics viewers feel.
- Watch path highly available (99.99% or better); the upload and processing path can be less available and slower.
- Originals are never lost: a creator's upload is the only copy. Store it durably (object storage is designed for 11 nines of durability; AWS states 99.999999999% for S3) before acknowledging.
- Cost: egress and storage dominate, so every decision is checked against bytes served and bytes stored.
- Scale: 500 hours uploaded per minute and 1 billion hours watched per day, the YouTube figures above.
Estimates
// Assumptions (YouTube-scale, from the figures quoted above; change them and re-run).
const uploadHoursPerMin = 500; // hours of new video per minute
const watchHoursPerDay = 1e9; // hours watched per day
const ladderKbps = [145, 365, 730, 1100, 2000, 3000, 4500, 6000, 7800]; // Apple's H.264 example ladder
const avgWatchKbps = 2500; // average rung actually streamed
const segmentSec = 6; // HLS target duration
const peakFactor = 2; // evening peak vs daily average
const sourceMbps = 10; // what creators upload (1080p, high quality)
const GB = 1e9, PB = 1e15;
const uploadHoursPerDay = uploadHoursPerMin * 60 * 24;
const ladderBytesPerHour = ladderKbps.reduce((a, b) => a + b, 0) * 1000 / 8 * 3600;
const storedPerDay = uploadHoursPerDay * ladderBytesPerHour;
const segmentsPerDay = uploadHoursPerDay * (3600 / segmentSec) * ladderKbps.length;
const egressPerDay = watchHoursPerDay * 3600 * avgWatchKbps * 1000 / 8;
const avgTbps = (egressPerDay * 8) / 86400 / 1e12;
console.log(`uploaded: ${uploadHoursPerDay.toLocaleString("en")} hours/day`);
console.log(`ingest: ${((uploadHoursPerDay * 3600 * sourceMbps) / 86400 / 1000).toFixed(0)} Gbps of source video, ${((uploadHoursPerDay * 3600 * sourceMbps * 1e6) / 8 / PB).toFixed(1)} PB/day of originals`);
console.log(`ladder: ${(ladderBytesPerHour / GB).toFixed(1)} GB per hour of video (all ${ladderKbps.length} rungs)`);
console.log(`new renditions: ${(storedPerDay / PB).toFixed(1)} PB/day, ${(storedPerDay * 365 / 1e18).toFixed(1)} EB/year`);
console.log(`new segments: ${(segmentsPerDay / 1e9).toFixed(1)} billion files/day`);
console.log(`egress: ${(egressPerDay / 1e18).toFixed(2)} EB/day`);
console.log(`bandwidth: ${avgTbps.toFixed(0)} Tbps average, ${(avgTbps * peakFactor).toFixed(0)} Tbps peak`);
console.log(`read:write: ${(egressPerDay / storedPerDay).toFixed(0)} bytes served per byte stored`);uploaded: 720,000 hours/day
ingest: 300 Gbps of source video, 3.2 PB/day of originals
ladder: 11.5 GB per hour of video (all 9 rungs)
new renditions: 8.3 PB/day, 3.0 EB/year
new segments: 3.9 billion files/day
egress: 1.13 EB/day
bandwidth: 104 Tbps average, 208 Tbps peak
read:write: 135 bytes served per byte stored500hours a minute is720,000hours of new video a day. Creators upload at around 10 Mbps (assumed), so ingest is about300 Gbpsand3.2 PB/dayof originals. That is large but steady, and it goes straight to object storage, never through app servers.- Summing the ladder gives 25,640 kbps, which is
11.5 GBfor one hour of video across all 9 rungs. Every uploaded hour therefore costs about 2.6 times its original's size again in renditions (11.5 GB against 4.5 GB for a 10 Mbps original):8.3 PB/day,3.0 EB/year. Storage is the second biggest cost, and it is why rarely watched videos lose their top rungs or move to colder storage classes. - At 6-second segments, one hour is 600 segments per rung, so the day's uploads create
3.9 billionsegment files. That number decides how segments are stored (see the data model): either an object per segment, which object stores can hold but makes listing and lifecycle rules heavy, or one file per rendition read with byte-range requests. - Viewing: a billion hours at an average of 2.5 Mbps is
1.13 EB/day,104 Tbpson average and about208 Tbpsat the evening peak (peak factor 2, assumed). No origin can serve that; even a single big CDN region cannot. The design must push bytes to edges and ideally into ISPs. - Bytes served per byte stored:
135. A read-heavy system usually means "add caches"; at this ratio the cache (CDN) is the system.
API
POST /v1/uploads { title, sizeBytes, contentType }
-> 201 { uploadId, partSize: 8388608, partUrls: [ ...presigned PUT URLs... ] }
PUT <presigned part URL> raw bytes of part n, straight to object storage; retry a part, not the file
POST /v1/uploads/{uploadId}/complete { parts: [ { n, etag } ] }
-> 202 { videoId, status: "processing" }
GET /v1/videos/{videoId}
-> 200 { title, status, durationSec, manifestUrl, thumbnails }
GET https://cdn.example.com/v/{videoId}/master.m3u8 served by the CDN, not by the API
POST /v1/playback-events batched: { videoId, startupMs, stalls, rendition, watchedSec }The upload is a multipart, presigned upload: the API only hands out URLs and records progress; the bytes go from the creator's device to object storage, part by part, so a dropped connection costs one part, not the whole file. This is the pattern from object storage for files and media. The 202 on complete says "accepted, not done": processing is asynchronous and the client polls GET /v1/videos/{id} or receives a notification. Playback telemetry (start-up time, stalls) is how you will know whether the ABR and CDN choices work, so it belongs in the API from the start.
Data model
| Store | What | Key and notes |
|---|---|---|
| Metadata DB (relational or wide-column) | videos(id, owner_id, title, status, duration_sec, created_at) | id; status is uploading → processing → ready | failed |
| Metadata DB | renditions(video_id, codec, height, kbps, playlist_key, bytes) | (video_id, codec, height); one row per rung |
| Object storage | originals/{videoId}/source | written once, kept forever, cold storage class after processing |
| Object storage | v/{videoId}/{codec}_{height}p/ with segments and a media playlist, plus v/{videoId}/master.m3u8 | immutable files, so they cache forever at the CDN |
| Counter store | views per video | sharded counters, aggregated asynchronously (as in comments and likes) |
Two decisions worth saying aloud. Segment files are immutable: a re-encode writes new paths and a new manifest, so the CDN never needs purging. And the 3.9 billion new segments a day can be one object each, or each rendition can be a single fragmented MP4 file that the playlist addresses with byte ranges (HLS supports this with EXT-X-BYTERANGE), which cuts the object count by 600 times per hour of video at the cost of range-request support at every cache.
High-level design
Walk the interviewer through it in two passes. Upload: the client creates an upload, sends parts straight to object storage with presigned URLs, and calls complete; the object-created event puts a job on a queue (why queues: the transcoders run at their own pace and a burst of uploads just makes the queue longer). Transcoders split, encode and package, write the renditions, and flip the video to ready. Watch: the player asks the video API for metadata and the manifest URL, then every byte of video comes from the CDN. On a miss the edge asks an origin shield (one mid-tier cache per region, so a new video causes one origin read per region rather than one per edge, see CDNs), and only the shield reads object storage.
A 10-minute upload must become 9 renditions of 100 segments each. Doing it as one job on one machine is slow; the trick is to split first, then encode the pieces in parallel:
- Split the original into chunks of one segment each. The upload's own keyframes can be anywhere, so the splitter cuts at the source's keyframes nearest each boundary (or decodes across the boundary), and every encoded chunk is made to start with a fresh keyframe (IDR). Apple's HLS authoring specification asks for an IDR every 2 seconds and 6-second segments, so each 6-second output chunk starts on a keyframe and was encoded independently of its neighbours.
- Encode every chunk at every rung as a separate task: (chunk, rung) pairs are independent, so they fan out across a worker pool.
- Package the encoded chunks into segments and write the media playlists and the master playlist.
- Publish: write the
renditionsrows and setstatus = readyin one transaction; the CDN needs no warming, because the first viewer's miss fills it.
How much parallelism buys you, with encoding assumed to run at 2× real time per rung per vCPU (a rule of thumb that varies a lot with codec and preset):
// Assumptions: encoding one rung runs at 2× real time on one vCPU (a rule of thumb; it depends heavily on codec
// and preset), and a chunk is one 6-second segment.
const rungs = 9, encodeSpeed = 2, chunkSec = 6;
const uploadHoursPerDay = 720_000;
function wallClock(videoMin, workers) {
const tasks = Math.ceil((videoMin * 60) / chunkSec) * rungs; // one task = one chunk at one rung
const taskSec = chunkSec / encodeSpeed;
const waves = Math.ceil(tasks / workers); // tasks run in parallel waves
return { tasks, seconds: waves * taskSec };
}
for (const [min, workers] of [[10, 1], [10, 100], [10, 900], [120, 900]]) {
const r = wallClock(min, workers);
const t = r.seconds >= 60 ? `${(r.seconds / 60).toFixed(1)} min` : `${r.seconds} s`;
console.log(`${String(min).padStart(3)}-min video, ${String(workers).padStart(3)} ${workers === 1 ? "worker: " : "workers:"} ${String(r.tasks).padStart(5)} tasks, ${t.padStart(8)} of encoding`);
}
const cpuHoursPerDay = (uploadHoursPerDay * rungs) / encodeSpeed;
console.log(`fleet: ${(cpuHoursPerDay / 1e6).toFixed(2)} M vCPU-hours/day = ${Math.round(cpuHoursPerDay / 24).toLocaleString("en")} vCPUs busy around the clock`); 10-min video, 1 worker: 900 tasks, 45.0 min of encoding
10-min video, 100 workers: 900 tasks, 27 s of encoding
10-min video, 900 workers: 900 tasks, 3 s of encoding
120-min video, 900 workers: 10800 tasks, 36 s of encoding
fleet: 3.24 M vCPU-hours/day = 135,000 vCPUs busy around the clock- A 10-minute video is 100 six-second chunks, times 9 rungs:
900tasks of 3 seconds of encoding each. - On
1 workerthat's45.0 min; on 100 workers it is 9 waves of tasks,27 s; on 900 workers every task runs at once and the encoding takes3 splus scheduling and I/O overhead. Splitting turns latency from "proportional to the video's length" into "proportional to one chunk". - A two-hour film with the same 900 workers needs 12 waves,
36 s. Long videos stop being a special case. - The fleet-wide number is the cost: 720,000 hours a day × 9 rungs ÷ 2 =
3.24 M vCPU-hours/day, about135,000 vCPUsbusy around the clock. That is why real services encode the top rungs lazily (only once a video gets views), use hardware encoders, and choose ladders per title instead of encoding every rung for everything.
Make every task idempotent: its output path is derived from (videoId, rung, chunk), so a retried task overwrites the same object with the same bytes. The job queue delivers at least once, a worker can die halfway, and the pipeline still converges (the idea from idempotency and exactly-once). Track the DAG's progress in a table keyed by video, so a coordinator can see which tasks are missing and re-enqueue only those. Put uploads from big creators on a priority queue if the business wants their videos live first.
The player never sees "a video"; it sees playlists. The master playlist lists the rungs, and each rung's media playlist lists its segments:
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=7200000,AVERAGE-BANDWIDTH=6000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
avc_1080p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3600000,AVERAGE-BANDWIDTH=3000000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
avc_720p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=180000,AVERAGE-BANDWIDTH=145000,RESOLUTION=416x234,CODECS="avc1.42c00d,mp4a.40.2"
avc_234p/playlist.m3u8BANDWIDTH is the peak bitrate of the rung and AVERAGE-BANDWIDTH its average; the player compares its throughput estimate with these numbers. The playlist shows three of the nine rungs, and its peak values are illustrative: Apple's spec asks for the measured peak and average to be within 10% of these attributes, and for on-demand video a peak of no more than 200% of the average. The ladder used on this page is Apple's example H.264 ladder from the HLS authoring specification (checked October 2026): 416×234 at 145 kbps, 640×360 at 365, 768×432 at 730 and 1100, 960×540 at 2000, 1280×720 at 3000 and 4500, 1920×1080 at 6000 and 7800. The same document gives an HEVC ladder that reaches 1080p at 4,500–5,800 kbps, roughly a quarter less than H.264 for the same picture, and 4K at 11,600–16,800 kbps. Apple presents it as "one possible set" of variants, not a requirement.
Segment length is a trade-off you should be able to defend. HLS's authoring spec asks for 6-second segments; DASH deployments commonly use 2 to 6 seconds (Bitmovin's 2020 guide gives 2–6 s as the typical range for on-demand video). Shorter segments let the player react sooner and cut live latency, but they mean more requests per minute, more playlist entries and more keyframes (keyframes are expensive, so compression gets worse). Longer segments compress better and cost fewer requests, but the player reacts later, and a bad pick costs more buffer. For video on demand, 6 s is a good default; for live, segments get shorter and are split into smaller chunks (low-latency HLS and CMAF chunked transfer).
The ABR algorithm is a client decision, and there are two families. Throughput-based players, like the model above, estimate bandwidth from recent downloads and pick the highest rung below a safety fraction of it. Buffer-based players (BOLA is the best-known; dash.js ships it) pick from the buffer level alone: low buffer means low rung, full buffer means high rung, which avoids trusting noisy throughput samples. Production players mix both, start on a low or middle rung for fast start-up, and abandon a download that would empty the buffer, as segment 6 showed.
At 208 Tbps the question isn't "should we use a CDN" but "how much of the catalogue must sit at the edge to keep origin egress affordable". Video popularity is heavily skewed: a few videos get most views, and millions get a handful. This model assumes a Zipf distribution with skew 0.9 over 10 million videos and asks what share of views the edge can serve if it holds the top K:
// Popularity of N videos follows a Zipf law: the k-th most popular gets views ∝ 1 / k^s.
const N = 10_000_000; // videos in the catalogue
const s = 0.9; // skew: an assumption; measure your own catalogue
const egressTbps = 208; // peak from the estimate above
const gbPerVideo = (11.5 * 8) / 60; // 8-minute average video, all rungs (11.5 GB per hour)
// Prefix sums of the weights, so "share of views in the top K" is one division.
const prefix = new Float64Array(N + 1);
for (let k = 1; k <= N; k++) prefix[k] = prefix[k - 1] + 1 / k ** s;
const share = (K) => prefix[K] / prefix[N];
console.log("edge holds top K storage hit ratio origin egress at peak");
for (const K of [1_000, 10_000, 100_000, 1_000_000, 5_000_000]) {
const hit = share(K);
const pb = (K * gbPerVideo) / 1e6;
console.log(`${K.toLocaleString("en").padStart(16)} ${(pb < 1 ? (pb * 1000).toFixed(0) + " TB" : pb.toFixed(1) + " PB").padStart(7)}` +
` ${(hit * 100).toFixed(1).padStart(8)}% ${(egressTbps * (1 - hit)).toFixed(0).padStart(5)} Tbps`);
}edge holds top K storage hit ratio origin egress at peak
1,000 2 TB 25.9% 154 Tbps
10,000 15 TB 38.6% 128 Tbps
100,000 153 TB 54.5% 95 Tbps
1,000,000 1.5 PB 74.7% 53 Tbps
5,000,000 7.7 PB 91.8% 17 Tbps- Under a Zipf law the k-th most popular video gets views in proportion to
1 / k^0.9. The prefix sums turn "share of views going to the top K videos" into one division,prefix[K] / prefix[N]. - Holding the top
1,000videos (2 TB, fits on a single server) already serves25.9%of views. That is the head of the distribution, and why a small edge in every city helps immediately. - The tail is long: to reach
74.7%you need the top million videos,1.5 PB, and the origin still has to send53 Tbpsat peak. Going from a quarter of views to half took a hundred times the storage (1,000 to 100,000 videos). - At five million videos (
7.7 PB) the hit ratio is91.8%and origin egress falls to17 Tbps. Each point of hit ratio at the top end is worth about 2 Tbps of origin traffic, which is why video companies place their own caches inside ISPs. - The skew
s = 0.9is an assumption; with a more skewed catalogue the curve rises faster. The method is the point: measure your popularity curve, then size edge storage against the origin bandwidth you can afford.
Netflix's Open Connect is the extreme version. Netflix's page on how it works with ISPs says Open Connect delivers 100% of its video traffic, close to 90% of it over direct connections with the residential ISPs its members use, and Netflix gives qualifying ISPs its cache appliances for free to put inside their networks. Those figures are old: the page is undated, but it gives viewing as "over 125 million hours" a day, the figure Digital TV Europe reported for Open Connect in March 2016, and a June 2018 APNIC blog post on Open Connect already put the direct share at close to 95%. Treat them as roughly ten-year-old figures that show the approach, not as current numbers. For an interview, the layered answer is enough: edge caches in many cities, an origin shield per region, object storage as the origin, a cache key that ignores per-user query parameters, and long TTLs because segments are immutable. For paid or private content, the player gets short-lived signed URLs or cookies that the edge validates, so access control doesn't break caching.
Pre-positioning helps the head: when a new episode of a popular show is due, push it to edges overnight ("fill") instead of letting the first viewers' misses pull it at peak. Rarely watched long-tail videos can stay at the origin and in regional shields only.
Trade-offs and what to say
| Decision | Choice | Why, and what it costs |
|---|---|---|
| Who streams the bytes | CDN, never app servers | 208 Tbps peak; app servers would cost more and add a hop |
| Upload | Multipart presigned upload to object storage | Resumable per part; API servers don't carry 300 Gbps |
| Processing | Queue + split into chunks + parallel encode | Minutes, not hours; idempotent tasks survive retries; costs a large CPU fleet |
| Segment length | 6 s for on-demand | Good compression and few requests; adapts more slowly than 2 s |
| Ladder | Per title, top rungs on demand | Saves storage and CPU on the long tail; first viewers of a newly popular video may get lower quality for a while |
| Segment storage | Immutable files (one object each, or byte ranges of one file per rung) | Infinite TTLs, no purges; billions of objects a day, or caches must support range requests |
Peak egress is 208 Tbps and the CDN hit ratio drops from 95% to 90% after a cache layer is resized. What happens to the traffic the origin has to serve?
Show answer
It doubles, from about 10.4 Tbps to 20.8 Tbps.
- The origin serves only the misses:
egress × (1 − hit ratio). - At 95%:
208 × 0.05 = 10.4Tbps. At 90%:208 × 0.10 = 20.8Tbps. - The hit ratio fell by 5 points (about 5%), but the miss ratio went from 5% to 10%, a 100% increase. Origin and shield capacity are sized on the miss ratio, which is why small hit-ratio changes at the top end matter so much.