Lesson 11 / 14
Video Streaming Service
The case where multiple representations of the same content are kept: budgeting the transcoding pipeline in processor-seconds, measuring the bitrate ladder's hit rate against the viewer bandwidth distribution, the shrinking bandwidth utilization gained per rung, and three separate constraints eliminating three separate ladders.
Contents
In the previous case, the object was a meaningless byte sequence to the service: stored as written, served as read. This case removes that assumption. The uploaded file is not stored just once; multiple representations are produced from its content, and which representation is served is decided by the requester’s bandwidth at that moment. The new line item is the transcoding pipeline, and the new trade-off is how many rungs adaptive streaming’s bitrate ladder carries.
The decision to measure is this: each rung costs both stored bytes and processor-seconds, but in return delivers a stream that better fits the viewer’s bandwidth. The rung count is chosen where these two curves cross, and the crossing point depends on the distribution of viewer bandwidth.
Constraints
Functional requirement: producing multiple quality rungs from the uploaded source copy, splitting each rung into fixed-duration segments, handing the client the segment list, and letting the client switch between rungs.
Non-functional requirement, in numbers: the share of viewers who cannot play because their bandwidth does not carry even the lowest rung stays under 10 percent; stored bytes do not exceed 2.5 times the source copy; processors continuously allocated to encoding do not exceed 1500; an hour of video does not take more than 10 minutes to go from upload to watchable.
Scope reduction: content protection, live streaming, recommendations, subtitle generation, and ad placement are not designed.
Assumptions
| Code | Assumption | Value | Rationale |
|---|---|---|---|
| VY1 | daily uploaded video hours | 4,000 | the daily total across uploaders |
| VY2 | source copy’s bit rate | 20,000 kbit/s | unencoded upload |
| VY3 | daily watched hours | 900,000 | watching runs far heavier than uploading |
| VY4 | peak multiplier | 3 | peak hour’s ratio to the daily average |
| VY5 | retention period | 365 days | the catalog is kept for a year |
| VY6 | encoding cost | 0.4 + 0.25 × Mbit/s | processor-seconds per video-second |
| VY7 | viewer bandwidth distribution | 0.25/1.5 + 0.50/6 + 0.25/20 | a three-class mixture, exponential, Mbit/s |
| VY8 | bandwidth’s usable share | 0.8 | protocol overhead and fluctuation margin |
| VY9 | segment duration | 4 s | rung switching happens at this granularity |
| VY10 | edge cache hit rate | 0.95 | segments are immutable and kept for a long time |
VY6 and VY7 are this design’s two load-bearing assumptions: one sets the production cost, the other the hit rate. Both are reasoned, not measured.
Scale
// video-scale.mjs — the scale computation from the VY table; all of it is arithmetic const VY = { uploadedHours: 4000, sourceKbit: 20_000, watchedHours: 900_000, retentionDays: 365, fixedCpu: 0.4, cpuPerKbit: 0.25, segmentSec: 4 }; const RUNGS = [400, 1200, 3000, 6000, 12_000]; // chosen bitrate ladder rungs, kbit/s const total = RUNGS.reduce((a, b) => a + b, 0); const cpu = RUNGS.reduce((a, k) => a + VY.fixedCpu + VY.cpuPerKbit * (k / 1000), 0); const videoSec = VY.uploadedHours * 3600; for (const [name, d] of [ ["watched / uploaded hours", VY.watchedHours / VY.uploadedHours], ["rung total kbit/s", total], ["encoding processor-sec / video-sec", cpu], ["daily stored TB", (videoSec * (VY.sourceKbit + total) * 1000) / 8 / 1e12], ["year-end stored PB", (videoSec * (VY.sourceKbit + total) * 1000 * VY.retentionDays) / 8 / 1e15], ["daily segments produced", (videoSec / VY.segmentSec) * RUNGS.length], ]) console.log(name.padEnd(36) + d.toFixed(2).padStart(14)); console.log(`\nan hour of video requires ${(3600 * cpu).toFixed(0)} processor-sec:`); for (const n of [30, 60, 120]) console.log(` ${n} workers -> upload-to-watchable ${((3600 * cpu) / n / 60).toFixed(2)} min`);
watched / uploaded hours 225.00 rung total kbit/s 22600.00 encoding processor-sec / video-sec 7.65 daily stored TB 76.68 year-end stored PB 27.99 daily segments produced 18000000.00 an hour of video requires 27540 processor-sec: 30 workers -> upload-to-watchable 15.30 min 60 workers -> upload-to-watchable 7.65 min 120 workers -> upload-to-watchable 3.83 min
These numbers are in the calculation class. Three of them drive the design. Every uploaded hour is watched for 225 hours: an encoding cost paid once is read two hundred twenty-five times, which on its face makes preparing the content at upload time, not at watch time, the sound choice. 18,000,000 segments are produced per day — that is the object count the distribution layer sees, not the video count. And the last line meets a constraint directly: the 10-minute latency constraint requires at least 46 workers; at 30 workers the figure runs to 15.30 minutes and breaks the constraint, at 60 workers it stays at 7.65 minutes.
Rung Selection
The rung count is defensible only once the viewer bandwidth distribution is given. The model below sets up no network and runs no encoder: it draws bandwidth from VY7’s three-class mixture, picks the highest rung that fits each viewer’s usable bandwidth, and compares four ladders over the same set of viewers.
// rung-selection.mjs — in-process model: how accurate rung selection is against the viewer // bandwidth distribution. No network, no encoder; the distribution is a three-class mixture and is written as a model. const VIEWERS = 200_000, SEED = 20260731, SHARE = 0.8; // VY8: the usable share of bandwidth const CLASS = [[0.25, 1500], [0.50, 6000], [0.25, 20_000]]; // VY7: share, average kbit/s const SOURCE = 20_000, FIXED = 0.4, CPU_PER_KBIT = 0.25; // VY2, VY6 const WATCHED_HOURS = 900_000, PEAK = 3, HIT = 0.95; // VY3, VY4, VY10 const UPLOADED_SEC = 4000 * 3600; // VY1: video-seconds encoded per day let state = SEED; const rand = () => { state = (state * 1103515245 + 12345) % 2147483648; return state / 2147483648; }; function bandwidth() { // pick a class, draw from its exponential let u = rand(), i = 0; while (i < CLASS.length - 1 && u > CLASS[i][0]) { u -= CLASS[i][0]; i += 1; } return -Math.log(1 - rand()) * CLASS[i][1]; } const bandwidths = Array.from({ length: VIEWERS }, bandwidth); const LADDER = { "1 rung": [3000], "3 rungs": [800, 3000, 9000], "5 rungs": [400, 1200, 3000, 6000, 12_000], "8 rungs": [300, 600, 1000, 1800, 3000, 5000, 8000, 12_000] }; function measure(m) { let notPlayable = 0, selectedTotal = 0, utilizationTotal = 0; for (const b of bandwidths) { const available = b * SHARE; const s = m.filter((k) => k <= available).pop(); if (s === undefined) { notPlayable += 1; continue; } selectedTotal += s; utilizationTotal += s / available; } const playable = VIEWERS - notPlayable; const totalKbit = m.reduce((a, b) => a + b, 0); return { notPlayable: notPlayable / VIEWERS, avgSelected: selectedTotal / playable, utilization: utilizationTotal / playable, storage: (SOURCE + totalKbit) / SOURCE, cpu: m.reduce((a, k) => a + FIXED + CPU_PER_KBIT * (k / 1000), 0) }; } console.log(`model: ${VIEWERS} viewers, seed ${SEED}, class shares ` + `${CLASS.map(([p, o]) => `${p}/${o}`).join(" ")} kbit/s (exponential)\n`); console.log("ladder".padEnd(11) + "not playable".padStart(13) + "avg selected".padStart(13) + "bandwidth utilization".padStart(23) + "storage factor".padStart(16) + "sustained processors".padStart(22) + "peak edge Gbit/s".padStart(18) + "origin Gbit/s".padStart(15)); const R = {}; for (const [name, m] of Object.entries(LADDER)) { const r = measure(m); R[name] = r; const edge = (WATCHED_HOURS * 3600 * r.avgSelected * 1000 / 86_400) * PEAK / 1e9; console.log(name.padEnd(11) + `${(r.notPlayable * 100).toFixed(2)}%`.padStart(13) + r.avgSelected.toFixed(0).padStart(13) + `${(r.utilization * 100).toFixed(1)}%`.padStart(23) + r.storage.toFixed(2).padStart(16) + ((UPLOADED_SEC * r.cpu) / 86_400).toFixed(0).padStart(22) + edge.toFixed(2).padStart(18) + (edge * (1 - HIT)).toFixed(2).padStart(15)); } const k = Object.keys(LADDER); console.log("\nbandwidth utilization gained per rung:"); for (let i = 1; i < k.length; i += 1) { const d = LADDER[k[i]].length - LADDER[k[i - 1]].length; console.log(` ${k[i - 1]} -> ${k[i]}: ${(((R[k[i]].utilization - R[k[i - 1]].utilization) * 100) / d).toFixed(2)} points/rung`); } const disabled = measure([400, 1200, 3000, 6000]); // if the top rung is disabled console.log(`top rung disabled: avg selected ${disabled.avgSelected.toFixed(0)} kbit/s, edge output ` + `x${(disabled.avgSelected / R["5 rungs"].avgSelected).toFixed(2)}, not playable unchanged at ${(disabled.notPlayable * 100).toFixed(2)}%`); const customCpu = WATCHED_HOURS * 3600 * (FIXED + CPU_PER_KBIT * 6); // one stream per viewer const ladderCpu = UPLOADED_SEC * R["5 rungs"].cpu; console.log(`per-viewer custom encoding: ${customCpu.toExponential(2)} processor-sec/day, ${(customCpu / ladderCpu).toFixed(1)}x ` + `the 5-rung ladder's (${(ladderCpu).toExponential(2)})`);
model: 200000 viewers, seed 20260731, class shares 0.25/1500 0.5/6000 0.25/20000 kbit/s (exponential) ladder not playable avg selected bandwidth utilization storage factor sustained processors peak edge Gbit/s origin Gbit/s 1 rung 45.75% 3000 43.2% 1.15 192 337.50 16.88 3 rungs 13.77% 3846 56.5% 1.64 733 432.71 21.64 5 rungs 8.91% 4442 64.9% 2.13 1275 499.72 24.99 8 rungs 6.08% 4607 73.8% 2.58 1854 518.24 25.91 bandwidth utilization gained per rung: 1 rung -> 3 rungs: 6.66 points/rung 3 rungs -> 5 rungs: 4.22 points/rung 5 rungs -> 8 rungs: 2.96 points/rung top rung disabled: avg selected 3242 kbit/s, edge output x0.73, not playable unchanged at 8.91% per-viewer custom encoding: 6.16e+9 processor-sec/day, 55.9x the 5-rung ladder's (1.10e+8)
The ratios in the table are measurements, the cost columns are in the calculation class; the measured ratios depend on this run’s seed, while the differences that separate the constraints are magnitudes independent of the seed. Bandwidth utilization gained per rung drops from 6.66 to 4.22 to 2.96 points: each new rung gains less than the one before it, but its cost does not drop to match.
Design
The measurement points to the five-rung ladder; the design is built on it. The transcoding pipeline sits on a message queue and competing consumers (the Application Layer and Service Interaction course’s Queues and Workflows topic). The parameter is worker count: 60 workers make an hour of video watchable in 7.65 minutes. The same topic’s priority queue puts new uploads ahead of the re-encoding jobs that backfill the catalog; this keeps the latency constraint binding only on new uploads. The job message does not carry bytes, it carries the source object’s key — the Claim Check Pattern (the Resilience and Reliability course’s Distributed Correctness topic). An encoding job is idempotent (same topic); the uniqueness key is the pair of video ID and rung, so a job re-run after being left half-finished does not produce a second output.
The distribution side is built on a content delivery network and edge cache (the Traffic Layer course’s Entry Points topic). The parameter is the hit rate: VY10’s 95 percent brings the peak edge traffic of 499.72 Gbit/s down to 24.99 Gbit/s at the origin. Because segments never change once produced, there is no question of a staleness window; the same topic’s push-based distribution moves a newly uploaded piece of content’s first segments to the edge before it is ever watched, and the rest arrives by pull. The segment list and the segments are static files (same topic, Static Content Hosting), so there is no application code on the read path.
Deliberately unused pattern: circuit breaker. The circuit breaker from the Resilience and Reliability course’s Fault Isolation topic has no place in the encoding layer, because workers make no synchronous call; they pull from the queue and re-surface a failed job — there is no call path to cut. The second is Command and Query Separation (the Scaling the Data Layer course’s Read–Write Separation topic): the read path is not a query, it is fetching a file whose name is already known, and deriving a separate read model has no payoff.
Eliminated Alternatives
Three alternatives are eliminated by three separate numbers, and each is caught by a different constraint.
One rung is the cheapest: a storage factor of 1.15, 192 sustained processors. But 45.75 percent of viewers cannot carry that single rung and cannot play at all — four and a half times the 10 percent constraint. Three rungs also stays above the constraint, at 13.77 percent.
Eight rungs is best on the hit side: bandwidth utilization of 73.8 percent, 6.08 percent not playable. It breaks two constraints at once: a storage factor of 2.58 (limit 2.5) and 1854 sustained processors (limit 1500). The 8.9 points of utilization it gains cost 21 percent more on storage and 45 percent more on processors.
Per-viewer custom encoding — producing each stream on the fly, fitted exactly to the viewer’s bandwidth — solves the hit-rate problem completely, because utilization reaches 100 percent and no extra rung is stored. It requires 6.16 billion processor-seconds a day, 55.9 times the five-rung ladder’s 110 million processor-seconds. The reason is the first line of the scale calculation: encoding is paid not once per upload but once per watch, and watching runs at 225 times uploading.
What remains is five rungs: 8.91 percent not playable, a storage factor of 2.13, 1275 sustained processors — all three constraints are met. Which constraint change flips the alternative: if the storage limit rises to 2.6, eight rungs gets in and raises bandwidth utilization by 8.9 points; if the viewer bandwidth distribution narrows (the three classes move closer together), fewer rungs suffice and three rungs starts meeting the constraint.
Failure Behavior and What Is Sacrificed
When the encoding layer goes down entirely, the catalog keeps playing; only new uploads fail to become watchable and back up in the queue. This is graceful degradation (the Fault Isolation topic), and its cost is suspending the latency constraint. When the origin goes down, the edge cache keeps serving 95 percent of requests, because segments are immutable and there is no risk of a stale copy. On the output side, when the peak load limit is forced, the top rung is disabled: the average selected bit rate drops from 4442 to 3242 kbit/s, edge output falls to 0.73x, and the not-playable share of viewers holds unchanged at 8.91 percent — degradation lowers quality, it does not leave anyone out.
What is sacrificed: bandwidth utilization was left at 64.9 percent. A third of the viewer’s usable bandwidth sits idle, and that is the price of keeping storage under 2.5x and encoding under 1500 processors.
Summary
- Every uploaded hour is watched for 225 hours; this ratio is why the encoding cost is paid at upload time rather than watch time, and it is also what makes per-viewer custom encoding 55.9 times more expensive.
- The hit rate of the bitrate ladder can only be measured together with the viewer bandwidth distribution: at five rungs, bandwidth utilization is 64.9 percent, and 8.91 percent of viewers cannot play.
- Utilization gained per rung drops from 6.66 to 4.22 to 2.96 points while the cost per rung does not drop; that is why the ladder has an end.
- Three alternatives are eliminated by three separate constraints: one rung by its not-playable share (45.75 percent), eight rungs by its storage (2.58) and processor (1854) limits, per-viewer custom encoding by processor-seconds.
- The edge cache brings output down from 499.72 Gbit/s to 24.99 Gbit/s, and because segments are immutable, the question of a staleness window never even opens.
Next Step
In this case too, data was stored as written: a piece was produced, put somewhere, and later read back unchanged. Every byte produced was worth storing. The next case removes that assumption. There, records are produced by the hundreds of thousands per second, none of them meaningful on its own, and storing all of them is impossible. The question becomes: how long is a given record kept raw, when is it aggregated, and how far does an answer read from aggregated data diverge from one read from raw data.
To keep your progress and take notes, Log in
My notes
Log in to take notes.