Skip to content

02. Video Streaming / OTT — Low-Level Design

~18 min read · Part 3 of 4 (Overview → HLD → LLD → Q&A)

Data models

CREATE TABLE title (
    id          BIGINT PRIMARY KEY,
    name        TEXT, duration_sec INT,
    status      SMALLINT               -- ingesting / transcoding / ready
);

CREATE TABLE rendition (
    title_id    BIGINT, resolution TEXT, bitrate_kbps INT, codec TEXT,
    segment_cnt INT, manifest_key TEXT,   -- blob key of this rendition's segment list
    PRIMARY KEY (title_id, resolution, codec)
);

CREATE TABLE transcode_job (
    title_id    BIGINT, segment_no INT, rendition TEXT,
    status      SMALLINT,               -- pending / done / failed
    PRIMARY KEY (title_id, segment_no, rendition)
);

-- User state (KV):  progress:{user_id}:{title_id} = position_sec (last write wins)
-- Segments in blob:  {title_id}/{rendition}/seg_{n}.ts  (immutable)

transcode_job keyed by (title_id, segment_no, rendition) makes each encode unit independently trackable and idempotently retryable — a failed chunk re-runs alone. progress is deliberately last-write-wins: a resume point a few seconds stale is invisible to the viewer.

Component internals

Transcoding pipeline — chunk-level parallelism

def ingest(title_id, master):
    segments = segmenter.split(master, target_sec=6)      # ~6s GOP-aligned chunks
    for n, seg in enumerate(segments):
        for r in RENDITIONS:                              # 240p…4K × codecs
            job_queue.put(TranscodeJob(title_id, n, r, seg_ref(seg)))
    # jobs run in parallel across the worker fleet

def worker(job):
    if jobs.status(job) == "done":                        # idempotent
        return
    out = ffmpeg_encode(job.seg_ref, job.rendition)       # one chunk, one rendition
    blob.put(segment_key(job.title_id, job.rendition, job.segment_no), out)
    jobs.mark_done(job)
    if all_done(job.title_id):
        write_manifests(job.title_id)                     # assemble per-rendition manifests

Splitting on GOP-aligned boundaries lets each ~6s chunk encode independently, so a 60-minute title (~600 chunks) × 10 renditions = 6,000 units spread across the fleet. Wall-clock time is the slowest single chunk, not the sum — minutes instead of hours.

Manifest — the menu the client orders from

# Master manifest (HLS-style) lists renditions:
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080  1080p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=854x480     480p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=426x240      240p/index.m3u8

# Per-rendition manifest lists immutable segment URLs:
#EXTINF:6.0  seg_0.ts
#EXTINF:6.0  seg_1.ts   ...

The server ships this static menu; it holds no per-viewer streaming state. Every viewer of the title gets the same manifest and the same immutable segment URLs — which is exactly why segments cache perfectly at the edge.

ABR — client-side rendition selection

# runs in the player, per segment
def pick_rendition(measured_kbps, buffer_sec):
    safe = measured_kbps * 0.8                      # headroom against variance
    choice = highest_rendition_below(safe)
    if buffer_sec < LOW_WATER:                      # buffer draining → be conservative
        choice = step_down(choice)
    elif buffer_sec > HIGH_WATER:                   # healthy buffer → try up
        choice = step_up(choice)
    return choice

The player owns adaptation because the bandwidth signal lives at the player. When our 8pm viewer's wifi drops from 8 Mbps to 1 Mbps mid-episode, the next segment fetch is 480p instead of 1080p — quality dips for a few seconds, but playback never stalls.

Core algorithm — pre-warming the edge for a release

before release T:
    predict top-K titles (the new season)
    for each edge in CDN:
        for rendition in POPULAR_RENDITIONS:       # e.g. 1080p, 480p first
            push first M segments of each title     # enough to cover startup + early play
    verify edge cache-hit readiness before T

Pre-warming turns a thundering herd into cache hits. At 8pm, 10M players request seg_0 of the new episode; because the first segments are already at every edge, origin serves ~0 of them. Without pre-warm, 10M cold-cache misses would stampede origin — the herd, not the total bytes, is what would break it.

Sequence diagram — playback with a bandwidth dip

Player        Playback API      Edge/CDN            Origin
  │ GET manifest │                 │                  │
  ├──────────────▶│ (entitlement)  │                  │
  │◀── renditions─┤                 │                  │
  │ pick 1080p    │                 │                  │
  ├─ GET 1080p/seg_0 ──────────────▶│ (hit, pre-warmed)│
  │◀── segment ───────────────────  │                  │
  │ wifi drops → pick 480p          │                  │
  ├─ GET 480p/seg_5 ───────────────▶│ (miss) ─────────▶│ fill
  │◀── segment (no rebuffer) ─────── │◀─────────────────┤

Concurrency and edge cases

  • Chunk encode retry: idempotent per (title, segment, rendition); a failed chunk re-runs without touching completed ones.
  • Manifest published before all chunks done: publish only after all_done, or publish progressively (live-style) — for VOD, wait for completeness so the player never requests a missing segment.
  • Startup rendition guess: start conservative (fast start) then ramp up, so the first 2 seconds are reliable rather than optimistic.
  • Thundering herd on a cold segment: edge request coalescing sends one origin fill per segment per edge; concurrent viewers wait for that fill.
  • Resume-position races across devices: last-write-wins; a few seconds' discrepancy between phone and TV is acceptable.
  • DRM: segments are encrypted; the player fetches a license (per-title key) separately, so caching still works on the encrypted bytes.
  • Codec/device mismatch: the manifest offers multiple codecs; the player selects one its hardware decodes, falling back to H.264 universally.