---
title: 'Video Streaming Service'
source: 'https://academia.sh/en/courses/case-studies/video-streaming-service'
course: 'Case Studies'
language: en
updated: '2026-08-23T07:01:24+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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

```js
// 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.

```js
// 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.
