System Design · Draft

29 — Video streaming

A video platform takes a large file from an uploader, turns it into many smaller files at different qualities, and delivers those files over ordinary HTTP to players that pick a quality second by second. Almost every hard part is about bytes: moving them reliably, storing them cheaply, and getting them close to viewers.

Requirements

Functional

  • Upload large video files, resuming after a dropped connection.
  • Transcode each upload into several resolutions and bitrates.
  • Play on browsers, phones and TVs, adapting quality to the network.
  • Video on demand first. Live streaming is covered at the end, as a variation.

Non-functional

  • Playback starts quickly and rarely stalls. Rebuffering is the failure viewers notice most.
  • Uploads don’t lose work. A two-hour upload that fails at 95% shouldn’t restart.
  • Cost scales sensibly. Storage and egress dominate the bill, so the design has to control both.
  • Processing time is predictable. A new video should be watchable within minutes, not hours.

Estimates

The method is from chapter 4. All inputs are assumptions, not figures from any real platform:

AssumptionValue
Uploads per day10,000
Average length10 minutes
Source bitrate20 Mbit/s
Bitrate ladderThe 9-rung H.264 example from Apple’s authoring spec (below), 25.64 Mbit/s in total
Peak concurrent viewers200,000
Average delivered bitrate3 Mbit/s

A source file is 20 Mbit/s×600 s/8=1.5 GB20\ \text{Mbit/s} \times 600\ \text{s} / 8 = 1.5\ \text{GB}, and all nine renditions together are about 1.92 GB. So each day adds:

10,000×(1.5+1.92) GB≈34 TB/day≈12.5 PB/year10{,}000 \times (1.5 + 1.92)\ \text{GB} \approx 34\ \text{TB/day} \approx 12.5\ \text{PB/year}

Ingest averages about 1.4 Gbit/s, which is manageable. Delivery isn’t:

200,000×3 Mbit/s=600 Gbit/s200{,}000 \times 3\ \text{Mbit/s} = 600\ \text{Gbit/s}

Egress at that rate from one origin is impractical, and it’s why a CDN is part of the design rather than an optimization. Storage grows without bound, so storage tiers matter from the first year.

Resumable upload

A single PUT of a 1.5 GB file fails often enough on mobile networks to matter, and failure means starting again. Resumable upload splits the file and lets the client ask where to continue.

The tus protocol is an open specification of this pattern:

POST /files                       Upload-Length: 1500000000       -> 201 Created, Location: /files/abc
PATCH /files/abc                  Upload-Offset: 0                 -> 204 No Content, Upload-Offset: 52428800
  (connection drops)
HEAD /files/abc                                                    -> 200, Upload-Offset: 52428800
PATCH /files/abc                  Upload-Offset: 52428800          -> 204 No Content, ...

PATCH bodies use Content-Type: application/offset+octet-stream. If the client’s offset doesn’t match the server’s, the server answers 409 Conflict and changes nothing. That makes the offset a simple form of optimistic concurrency, as in chapter 6. An Upload-Checksum extension lets the server verify each chunk.

Object stores offer a similar mechanism. S3 multipart upload accepts up to 10,000 parts of 5 MiB to 5 GiB each, with no minimum on the last part. A common design has the API issue pre-signed URLs for each part, so the bytes go from the client to object storage directly and never pass through application servers.

The upload’s metadata goes in PostgreSQL:

CREATE TABLE videos (
    video_id     bigserial   PRIMARY KEY,
    owner_id     bigint      NOT NULL,
    title        text        NOT NULL,
    status       text        NOT NULL DEFAULT 'uploading',
        -- uploading, uploaded, processing, ready, failed
    source_key   text,                    -- object storage key of the original
    duration_s   numeric(10, 3),
    created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE renditions (
    video_id     bigint  NOT NULL REFERENCES videos,
    name         text    NOT NULL,        -- '1080p-7800', '720p-4500', ...
    codec        text    NOT NULL,
    width        int     NOT NULL,
    height       int     NOT NULL,
    avg_kbps     int     NOT NULL,
    peak_kbps    int     NOT NULL,
    storage_tier text    NOT NULL DEFAULT 'hot',
    PRIMARY KEY (video_id, name)
);

CREATE TABLE transcode_tasks (
    video_id   bigint NOT NULL,
    rendition  text   NOT NULL,
    chunk      int    NOT NULL,
    status     text   NOT NULL DEFAULT 'pending',
    attempts   int    NOT NULL DEFAULT 0,
    PRIMARY KEY (video_id, rendition, chunk)
);

When the upload completes, the API sets status = 'uploaded' and writes an outbox event in the same transaction (chapter 15), which starts processing.

The transcoding pipeline

Transcoding one 10-minute video into nine renditions on one machine is slow, because each rendition means decoding and re-encoding every frame. The work splits naturally along two axes: renditions are independent of each other, and time ranges of the video are independent too, if each range starts on a keyframe.

 source.mp4 ──> [probe + split at keyframes into N chunks]
                       │
       ┌───────────────┼───────────────┐   N chunks x R renditions independent tasks
       v               v               v
 [encode c1 x R]  [encode c2 x R]  ... [encode cN x R]     (a worker pool)
       └───────────────┼───────────────┘
                       v
          [concatenate per rendition] ──> [package: fMP4 segments + HLS/DASH manifests]
                       │
                       v
          object storage ──> status = 'ready'

With 60 chunks of 10 seconds and 9 renditions, there are 540 independent tasks. Assume, purely for illustration, that one worker encodes one rendition at real-time speed. Serial transcoding then takes 90 minutes, while 540 workers finish in about 10 seconds plus overhead. Real gains are smaller, because splitting, scheduling and concatenating cost time, and chunk boundaries can cost a little compression efficiency. The shape is still the point: the pipeline is a fan-out of idempotent tasks, which a queue and worker pool handle well (chapter 8).

Some details matter here.

  • Keyframe alignment. Every rendition needs keyframes at the same timestamps, so a player can switch between renditions at segment boundaries. Apple’s HLS authoring spec recommends keyframes every two seconds and 6-second segments.
  • Idempotent tasks. A task writes to a deterministic key, such as video/123/720p/chunk-017.mp4. A retried task overwrites the same object, so duplicates are harmless.
  • Priority. Short videos and the lowest rungs can go first, so a video becomes playable at low quality before the expensive rungs finish.

The bitrate ladder

The ladder is the list of renditions. Apple’s authoring spec gives this example for H.264 at 16:9:

ResolutionAverage bitrate (kbit/s)
416 × 234145
640 × 360365
768 × 432730
768 × 4321,100
960 × 5402,000
1280 × 7203,000
1280 × 7204,500
1920 × 10806,000
1920 × 10807,800

The spec presents this as one possible set, not a rule. It also bounds how far reality can drift from what the manifest claims. For on-demand content, the measured peak bitrate must be within 10% of the declared BANDWIDTH, and it should be no more than 200% of the average. A ladder can also be tuned per title, because simple animation needs fewer bits than fast sports footage for the same visual quality.

HLS and DASH

Both deliver video as a list of short media segments over plain HTTP, described by a manifest. Because segments are ordinary files, any HTTP cache or CDN can serve them.

HLS is defined in RFC 8216. A multivariant (master) playlist lists the variants:

#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=8580000,AVERAGE-BANDWIDTH=7800000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p-7800/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3300000,AVERAGE-BANDWIDTH=3000000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
720p-3000/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=400000,AVERAGE-BANDWIDTH=365000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2"
360p-365/index.m3u8

BANDWIDTH is required, and it’s the peak segment bitrate. AVERAGE-BANDWIDTH is optional, and CODECS should be present so a player can skip variants it can’t decode. Each variant has a media playlist:

#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="init.mp4"
#EXTINF:6.006,
seg-00000.m4s
#EXTINF:6.006,
seg-00001.m4s
...
#EXT-X-ENDLIST

EXT-X-TARGETDURATION is the maximum segment duration: each segment’s duration, rounded to the nearest integer, must not exceed it. Fragmented MP4 segments need EXT-X-MAP, which points at the initialization segment, and using it requires playlist version 6 or later. EXT-X-ENDLIST says no more segments will be added. RFC 8216 also allows MPEG-2 transport stream segments, packed audio and WebVTT subtitles.

DASH (Dynamic Adaptive Streaming over HTTP, ISO/IEC 23009-1) uses an XML manifest, the MPD. Its hierarchy, as the DASH-IF timing model describes it, is: an MPD contains one or more consecutive periods; each period groups representations into adaptation sets; a representation is an initialization segment plus a sequence of media segments. Representations in one adaptation set are time-aligned, which is what lets a player switch between them. A SegmentTemplate with $Number$ or $Time$ in its URL pattern lets the player compute segment URLs without listing every one.

HLSDASH
SpecificationRFC 8216 (Apple)ISO/IEC 23009-1 (MPEG)
ManifestText playlists (.m3u8)XML (.mpd)
Segment formatsMPEG-2 TS, fMP4, packed audio, WebVTTTypically fMP4
Native playbackApple platforms, SafariThrough libraries using browser Media Source Extensions
On demand vs liveEXT-X-PLAYLIST-TYPE: VOD / EVENT, or no type for a sliding windowMPD@type="static" / "dynamic"

With fMP4 segments, both formats can point at the same segment files, with only the manifests differing. That halves segment storage compared to packaging twice, and HLS and DASH viewers share the same cache entries.

Adaptive bitrate

Neither specification says how a player should choose a variant. RFC 8216 states that switching algorithms are out of scope. The choice belongs to the player, and it’s a control problem: pick the next segment’s quality so playback never stalls, quality stays high, and it doesn’t flicker between levels.

Two families of rules exist:

  • Throughput-based. Estimate bandwidth from recent segment downloads, and pick the highest rung below the estimate times a safety factor.
  • Buffer-based. Pick quality from how many seconds are buffered: low when the buffer is nearly empty, high when it’s full. The buffer level reflects the network’s recent history without having to estimate bandwidth.

Real players combine them, and add abandonment: if a segment download is going so slowly that the buffer will run out first, cancel it and fetch a lower rung.

A simulated player

The simulation plays a 10-minute video over 20 synthetic bandwidth traces. Each trace is a random walk around 4 Mbit/s, with occasional 20–40-second drops to a few hundred kbit/s, like a tunnel or a crowded cell. These traces are an assumption, not measurements. The player buffers at most 30 seconds, starts after the first segment, and stalls when its buffer is empty.

import math
import random

LADDER = [145, 365, 730, 1100, 2000, 3000, 4500, 6000, 7800]   # kbit/s, Apple's example H.264 ladder
MAX_BUF = 30.0         # player stops fetching when this much is buffered
VIDEO_SECONDS = 600    # a 10-minute video


def make_trace(seed, seconds=1200):
    # Bandwidth in kbit/s, one value per second: a log-space random walk around 4 Mbit/s,
    # with a few 20-40 s dips to a few hundred kbit/s (a train tunnel, a busy cell).
    rng = random.Random(seed)
    level, out, dip = math.log(4000), [], 0
    for _ in range(seconds):
        level += rng.gauss(0, 0.15) + 0.02 * (math.log(4000) - level)
        if dip == 0 and rng.random() < 0.006:
            dip = rng.randint(20, 40)
        bw = math.exp(level)
        if dip:
            bw, dip = rng.uniform(200, 600), dip - 1
        out.append(max(100.0, bw))
    return out


def download(trace, t, kbits):
    # Time to download kbits starting at time t, with bandwidth constant within each second.
    start = t
    while kbits > 0:
        sec = int(t)
        bw = trace[sec % len(trace)]
        left_in_sec = sec + 1 - t
        can = bw * left_in_sec
        if can >= kbits:
            t += kbits / bw
            kbits = 0
        else:
            kbits -= can
            t = sec + 1
    return t - start


def download_abandon(trace, t, kbits, buf, level, seg):
    # Download second by second. If the rest of the segment would take longer than the
    # buffer that remains, abandon it and fetch the lowest rung instead.
    start, done = t, 0.0
    while done < kbits:
        sec = int(t)
        bw = trace[sec % len(trace)]
        step = min(sec + 1 - t, (kbits - done) / bw)
        done += bw * step
        t += step
        left = buf - (t - start)
        if level > 0 and done < kbits and (kbits - done) / bw > left and t - start >= 1:
            low = LADDER[0] * seg
            return (t - start) + download(trace, t, low), 0, done
    return t - start, level, 0.0


def fixed(level):
    return lambda st: level


def throughput_rule(st):
    # Harmonic mean of the last 5 measured throughputs, with a 0.8 safety factor.
    h = st["tput"][-5:]
    if not h:
        return 0
    est = len(h) / sum(1 / x for x in h) * 0.8
    return max([i for i, r in enumerate(LADDER) if r <= est], default=0)


def buffer_rule(st):
    # Buffer-based: lowest rung below a 10 s reservoir, highest above 25 s, linear in between.
    b = st["buf"]
    if b <= 10:
        return 0
    if b >= 25:
        return len(LADDER) - 1
    return int((b - 10) / 15 * (len(LADDER) - 1))


def play(trace, rule, seg=6.0, abandon=False):
    wasted = 0.0
    t, buf, playing = 0.0, 0.0, False
    st = {"tput": [], "buf": 0.0, "last": None}
    stall, stalls, switches, rates = 0.0, 0, 0, []
    for _ in range(int(VIDEO_SECONDS / seg)):
        if buf > MAX_BUF - seg:                    # buffer full: idle while it drains
            wait = buf - (MAX_BUF - seg)
            t += wait
            buf -= wait
        st["buf"] = buf
        i = rule(st)
        if st["last"] is not None and i != st["last"]:
            switches += 1
        st["last"] = i
        kbits = LADDER[i] * seg
        if abandon and st["tput"]:
            dt, i, w = download_abandon(trace, t, kbits, buf, i, seg)
            wasted += w
            kbits = LADDER[i] * seg if w else kbits
            st["last"] = i
        else:
            dt = download(trace, t, kbits)
        st["tput"].append(kbits / dt)
        t += dt
        if playing:
            if dt > buf:                           # buffer ran dry before the segment arrived
                stall += dt - buf
                stalls += 1
                buf = 0.0
            else:
                buf -= dt
        buf += seg
        rates.append(LADDER[i])
        playing = True                             # playback starts after the first segment
    return {
        "avg": sum(rates) / len(rates), "stall": stall, "stalls": stalls,
        "switches": switches, "wasted": wasted,
    }


if __name__ == "__main__":
    rules = [
        ("always lowest (145)", fixed(0), 6, False),
        ("always 3000", fixed(5), 6, False),
        ("always highest (7800)", fixed(8), 6, False),
        ("throughput (harmonic, x0.8)", throughput_rule, 6, False),
        ("buffer-based (10-25 s)", buffer_rule, 6, False),
        ("throughput + abandonment", throughput_rule, 6, True),
        ("buffer-based + abandonment", buffer_rule, 6, True),
        ("throughput, 2 s segments", throughput_rule, 2, False),
        ("throughput + abandon, 2 s", throughput_rule, 2, True),
    ]
    traces = [make_trace(s) for s in range(20)]
    mean_bw = sum(sum(tr[:600]) / 600 for tr in traces) / len(traces)
    print(f"20 traces, mean bandwidth over the first 10 min: {mean_bw:.0f} kbit/s")
    print(f"{'rule':30} {'avg kbit/s':>10} {'stall s':>8} {'stalls':>7} {'switches':>8} {'traces w/ stall':>15} {'wasted MB':>9}")
    for name, rule, seg, ab in rules:
        rs = [play(tr, rule, seg, ab) for tr in traces]
        n = len(rs)
        print(f"{name:30} {sum(r['avg'] for r in rs) / n:>10.0f} {sum(r['stall'] for r in rs) / n:>8.1f} "
              f"{sum(r['stalls'] for r in rs) / n:>7.1f} {sum(r['switches'] for r in rs) / n:>8.1f} "
              f"{sum(r['stalls'] > 0 for r in rs):>12}/{n} {sum(r['wasted'] for r in rs) / n / 8000:>9.1f}")

Output:

20 traces, mean bandwidth over the first 10 min: 4408 kbit/s
rule                           avg kbit/s  stall s  stalls switches traces w/ stall wasted MB
always lowest (145)                   145      0.0     0.0      0.0            0/20       0.0
always 3000                          3000     80.7    13.1      0.0           20/20       0.0
always highest (7800)                7800    529.4    59.5      0.0           20/20       0.0
throughput (harmonic, x0.8)          2708     25.1     3.3     29.3           20/20       0.0
buffer-based (10-25 s)               3504     23.8     2.6     55.2           19/20       0.0
throughput + abandonment             2128      0.0     0.0     34.4            0/20       2.4
buffer-based + abandonment           3231      0.0     0.0     61.0            0/20       5.2
throughput, 2 s segments             2768      1.7     0.5     62.0            7/20       0.0
throughput + abandon, 2 s            2575      0.0     0.0     61.3            0/20       0.2

Stall time and counts are averages per 10-minute session. The results:

  • A fixed bitrate fails either way. At 3 Mbit/s, below the 4.4 Mbit/s average bandwidth, every trace still stalled, for 81 seconds on average, because averages hide the drops. The lowest rung never stalled and looked terrible.
  • Adaptation alone cut stalls by more than two-thirds, but not to zero. Both rules still stalled in nearly every session. A drop that starts in the middle of a 6-second segment at 3 Mbit/s leaves the player waiting for 18 Mbit at a few hundred kbit/s, and no choice of the next segment helps.
  • Abandonment removed stalls entirely on these traces. The cost was a little wasted data, 2–5 MB per session, and somewhat lower average quality for the throughput rule.
  • Shorter segments reduced the damage, from 25 to 1.7 seconds of stall, because less is committed per decision. They also mean more requests, more manifest entries and slightly worse compression, which is why 2 and 6 seconds are both common choices.
  • The buffer-based rule achieved the highest quality among the adaptive rules, at the cost of twice as many quality switches.

Rebuffering depends as much on what the player can undo as on how well it predicts.

The CDN and cache hit ratio

A CDN puts caches near viewers. Video suits it, because segments are immutable once written: a URL never changes content, so segments can be cached with long lifetimes and no invalidation. Manifests for on-demand video are immutable too. Live manifests change every few seconds, and get short TTLs.

CDNs are typically tiered:

 viewers ──> edge PoPs (many, small) ──> mid-tier / shield (few, large) ──> origin (object storage)

The mid tier, often called an origin shield, collects misses from many edges. A segment that’s popular in several regions is fetched from the origin once, not once per edge. A simulation shows the effect. Videos have Zipf-distributed popularity, viewers watch an exponentially distributed number of segments (most stop early), each viewer stays on one rung, and each edge is an LRU cache. The sizes are in segments, and the setup is an assumption:

import random
from collections import OrderedDict


class LRU:
    def __init__(self, cap):
        self.cap, self.d = cap, OrderedDict()

    def get(self, k):
        if k in self.d:
            self.d.move_to_end(k)
            return True
        return False

    def put(self, k):
        self.d[k] = True
        if len(self.d) > self.cap:
            self.d.popitem(last=False)


def run(edges, edge_cap, mid_cap, requests=2_000_000, videos=20_000, seed=1):
    rng = random.Random(seed)
    weights = [1 / (r + 1) for r in range(videos)]       # Zipf popularity, s = 1
    edge = [LRU(edge_cap) for _ in range(edges)]
    mid = LRU(mid_cap) if mid_cap else None
    e_hit = m_hit = origin = 0
    sessions = rng.choices(range(videos), weights=weights, k=requests // 20)
    for v in sessions:
        e = edge[rng.randrange(edges)]             # a viewer lands on one edge
        watched = min(100, int(rng.expovariate(1 / 20)) + 1)   # most viewers stop early
        rung = rng.choice((3, 4, 5))               # the rung this viewer settles on
        for seg in range(watched):
            key = (v, rung, seg)
            if e.get(key):
                e_hit += 1
                continue
            e.put(key)
            if mid and mid.get(key):
                m_hit += 1
                continue
            if mid:
                mid.put(key)
            origin += 1
    total = e_hit + m_hit + origin
    return e_hit / total, origin / total


if __name__ == "__main__":
    print(f"{'edges':>5} {'edge cap':>8} {'mid-tier cap':>12} {'edge hit':>8} {'origin share':>12}")
    for edges, ecap, mcap in [(8, 20_000, 0), (8, 20_000, 200_000), (32, 20_000, 0), (32, 20_000, 200_000)]:
        hit, org = run(edges, ecap, mcap)
        print(f"{edges:>5} {ecap:>8,} {mcap:>12,} {hit:>8.1%} {org:>12.1%}")

Output:

edges edge cap mid-tier cap edge hit origin share
    8   20,000            0    37.1%        62.9%
    8   20,000      200,000    37.1%        37.9%
   32   20,000            0    34.1%        65.9%
   32   20,000      200,000    34.1%        37.8%

The mid tier doesn’t change the edge hit ratio. It cut the share of requests reaching the origin from 63–66% to 38%. With more edges, each sees a smaller slice of the traffic and hits less often, which is exactly the case the mid tier helps most. The absolute numbers depend entirely on the assumed catalog, popularity and cache sizes. What carries over is the structure: origin load depends on the hit ratio of the last tier before it, not the first.

The design choices that raise hit ratios are mostly about URLs:

  • One URL per object. Query strings with session tokens make every viewer’s URL unique and every request a miss. Signed URLs should sign a path prefix or use cookies, so the segment URL itself stays shared.
  • Shared segments across HLS and DASH, as above.
  • Fewer, well-chosen rungs. Each rung splits the audience, and so splits the cache.

Storage cost by tier

Object stores offer classes with lower storage prices and added retrieval costs and minimum storage durations. In S3, for example, Standard-IA bills at least 30 days, Glacier Instant Retrieval and Flexible Retrieval at least 90 days, and Deep Archive at least 180 days. Prices vary by region and change over time, so the calculation below uses assumed price ratios rather than real prices: hot 1.0, infrequent access 0.5, archive 0.1, per GB-month.

Video popularity decays with age, so most of a library is rarely watched. Four policies, applied to three years of uploads at the assumed rate:

# Storage after 3 years of uploads, in "hot GB-month units" per month.
# Price ratios are assumptions: hot 1.0, infrequent access 0.5, archive 0.1 per GB-month.
PRICE = {"hot": 1.0, "ia": 0.5, "archive": 0.1}
UPLOADS_PER_DAY = 10_000
SOURCE_GB = 1.5                      # 10 min at 20 Mbit/s
LADDER = [145, 365, 730, 1100, 2000, 3000, 4500, 6000, 7800]   # kbit/s
def rung_gb(kbps): return kbps * 1000 * 600 / 8 / 1e9
DAYS = 3 * 365

def monthly_cost(policy):
    total = {"hot": 0.0, "ia": 0.0, "archive": 0.0}
    for age in range(DAYS):          # one day's uploads per age
        for tier, gb in policy(age):
            total[tier] += gb * UPLOADS_PER_DAY
    return total, sum(PRICE[t] * gb for t, gb in total.items())

renditions = sum(rung_gb(r) for r in LADDER)
top3 = sum(rung_gb(r) for r in LADDER[-3:])
policies = {
    "A: everything hot":                       lambda a: [("hot", SOURCE_GB + renditions)],
    "B: source to archive after 7 days":       lambda a: [("hot" if a < 7 else "archive", SOURCE_GB), ("hot", renditions)],
    "C: B + renditions to IA after 90 days":   lambda a: [("hot" if a < 7 else "archive", SOURCE_GB),
                                                          ("hot" if a < 90 else "ia", renditions)],
    "D: C + drop top 3 rungs after 1 year":    lambda a: [("hot" if a < 7 else "archive", SOURCE_GB),
                                                          ("hot" if a < 90 else "ia", renditions - (top3 if a >= 365 else 0))],
}
base = None
print(f"{'policy':40} {'hot PB':>7} {'IA PB':>6} {'archive PB':>10} {'relative cost':>13}")
for name, pol in policies.items():
    t, c = monthly_cost(pol)
    base = base or c
    print(f"{name:40} {t['hot']/1e6:>7.2f} {t['ia']/1e6:>6.2f} {t['archive']/1e6:>10.2f} {c/base:>13.2f}")

Output:

policy                                    hot PB  IA PB archive PB relative cost
A: everything hot                          37.48   0.00       0.00          1.00
B: source to archive after 7 days          21.16   0.00      16.32          0.61
C: B + renditions to IA after 90 days       1.84  19.33      16.32          0.35
D: C + drop top 3 rungs after 1 year        1.84   9.31      16.32          0.22
  • Archiving the source saved the most for the least risk. The original is only needed to re-transcode, for a new codec or a corrected ladder.
  • Moving old renditions to infrequent access nearly halved the cost again. The CDN absorbs most reads of popular videos, so retrieval fees apply mainly to cache misses on old content, which are rare by definition. They aren’t free, though, and a video that goes viral years later costs retrieval fees until it’s moved back.
  • Dropping the top rungs for old videos saved another third, because the top three rungs hold 71% of the ladder’s bytes. They can be re-created from the archived source if a video becomes popular again.

Live versus on demand

Live streaming uses the same segments and manifests, with a different timeline. The encoder produces each segment as the event happens, and the media playlist grows or slides.

On demandLive
ManifestComplete, ends with EXT-X-ENDLISTUpdated as segments appear; no EXT-X-ENDLIST until the end
HLS playlist typeVOD, which can’t changeEVENT (append only) or none (sliding window)
DASHMPD@type="static"MPD@type="dynamic", mapped to wall-clock time
TranscodingParallel by chunk, as fast as workers allowReal time, one pass, one segment at a time
LatencyIrrelevantSegment duration × segments buffered, plus encoding and CDN
Manifest cachingLong TTLTTL shorter than the segment duration

RFC 8216 sets the timing rules for live HLS. A server publishes a new playlist version no earlier than half and no later than 1.5 times the target duration after the previous one. A client waits at least the target duration before reloading a changed playlist, or half of it after an unchanged one. A client shouldn’t start playback within three target durations of the end of a live playlist. With 6-second segments, that last rule alone puts the viewer about 18 seconds behind real time. This is why low-latency variants use shorter segments, or partial segments that a player can fetch before the whole segment is finished.

Live has two other differences. A popular live event sends every viewer to the same few newest segments at the same moment, which is the stampede from chapter 7, and the CDN has to coalesce those requests into one origin fetch. And a lost live segment is lost, so live ingest usually runs redundant encoders.

Failure modes

FailureEffectMitigation
Upload interruptedPartial fileResumable protocol; offsets; expire abandoned uploads
Transcode worker diesA missing chunkIdempotent tasks with deterministic output keys; retry from the queue
A corrupt or unusual sourcePipeline stalls on one videoProbe first; a retry limit, then failed with a reason
Misaligned keyframesGlitches when the player switches rungsFixed GOP in the encoder; validate segment boundaries
Manifest claims less bandwidth than realPlayers pick too high and stallMeasure peak and average after encoding
Edge outageViewers in one region affectedDNS or anycast steering to other PoPs
Origin overloaded by missesSlow starts everywhereMid-tier shield; request coalescing
Network drop mid-segmentRebufferingAbandonment; shorter segments; a buffer-aware rule

The takeaway

Video streaming is a file pipeline followed by a cache hierarchy. Uploads resume by offset, transcoding fans out by chunk and rung, and HLS and DASH turn the result into plain HTTP files a CDN can cache forever. In the simulated player, the biggest cut in rebuffering came from abandoning a doomed download, more than from predicting bandwidth better. Storage costs are controlled by age: archive the source, demote old renditions, and keep only what’s still watched.