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:
| Assumption | Value |
|---|---|
| Uploads per day | 10,000 |
| Average length | 10 minutes |
| Source bitrate | 20 Mbit/s |
| Bitrate ladder | The 9-rung H.264 example from Apple’s authoring spec (below), 25.64 Mbit/s in total |
| Peak concurrent viewers | 200,000 |
| Average delivered bitrate | 3 Mbit/s |
A source file is , and all nine renditions together are about 1.92 GB. So each day adds:
Ingest averages about 1.4 Gbit/s, which is manageable. Delivery isn’t:
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:
| Resolution | Average bitrate (kbit/s) |
|---|---|
| 416 × 234 | 145 |
| 640 × 360 | 365 |
| 768 × 432 | 730 |
| 768 × 432 | 1,100 |
| 960 × 540 | 2,000 |
| 1280 × 720 | 3,000 |
| 1280 × 720 | 4,500 |
| 1920 × 1080 | 6,000 |
| 1920 × 1080 | 7,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.
| HLS | DASH | |
|---|---|---|
| Specification | RFC 8216 (Apple) | ISO/IEC 23009-1 (MPEG) |
| Manifest | Text playlists (.m3u8) | XML (.mpd) |
| Segment formats | MPEG-2 TS, fMP4, packed audio, WebVTT | Typically fMP4 |
| Native playback | Apple platforms, Safari | Through libraries using browser Media Source Extensions |
| On demand vs live | EXT-X-PLAYLIST-TYPE: VOD / EVENT, or no type for a sliding window | MPD@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 demand | Live | |
|---|---|---|
| Manifest | Complete, ends with EXT-X-ENDLIST | Updated as segments appear; no EXT-X-ENDLIST until the end |
| HLS playlist type | VOD, which can’t change | EVENT (append only) or none (sliding window) |
| DASH | MPD@type="static" | MPD@type="dynamic", mapped to wall-clock time |
| Transcoding | Parallel by chunk, as fast as workers allow | Real time, one pass, one segment at a time |
| Latency | Irrelevant | Segment duration × segments buffered, plus encoding and CDN |
| Manifest caching | Long TTL | TTL 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
| Failure | Effect | Mitigation |
|---|---|---|
| Upload interrupted | Partial file | Resumable protocol; offsets; expire abandoned uploads |
| Transcode worker dies | A missing chunk | Idempotent tasks with deterministic output keys; retry from the queue |
| A corrupt or unusual source | Pipeline stalls on one video | Probe first; a retry limit, then failed with a reason |
| Misaligned keyframes | Glitches when the player switches rungs | Fixed GOP in the encoder; validate segment boundaries |
| Manifest claims less bandwidth than real | Players pick too high and stall | Measure peak and average after encoding |
| Edge outage | Viewers in one region affected | DNS or anycast steering to other PoPs |
| Origin overloaded by misses | Slow starts everywhere | Mid-tier shield; request coalescing |
| Network drop mid-segment | Rebuffering | Abandonment; 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.