Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 51

    Design a Video Streaming Platform

    TechLearningPro's lesson player used to download a single MP4 through the application tier. It worked for a seminar with a dozen viewers and failed the week a required lesson was a

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Design a Video Streaming Platform

    Advanced System Design & Software Architecture · Lesson 51 · Real-World System Design Case Studies

    Lesson Overview

    TechLearningPro's lesson player used to download a single MP4 through the application tier. It worked for a seminar with a dozen viewers and failed the week a required lesson was assigned to every cohort at once. The origin's network interface saturated, learners on conference Wi-Fi could not drop to a smaller rendition because there was only one file, and an instructor who replaced a video left half the cohort watching the old file from a cache nobody could name. The primers at /learn/system-design/netflix-video-streaming and /learn/high-level-design/video-streaming say "transcode, object storage, CDN, adaptive bitrate." They do not say who may upload, when a rendition becomes playable, how a revoke reaches the edge, or what the player does when the manifest exists and one bitrate does not.

    This lesson designs Course Reel, the video-on-demand path for lesson media in one home region. Upload is a resumable session directly to object storage. A queue of workers produces a small ladder of HTTP Live Streaming renditions. Playback is a short-lived authorization and a manifest; the media bytes come from a CDN that pulls from object storage. The application tier does not carry the video. Lesson 52 will widen this into a global control plane, multi-CDN steering, and catalog publication across regions. This lesson stays on the lifecycle of one title: bytes in, packages out, authorized playback, and a revoke that means something.

    The method is still lesson 23. The estimate, done the way lesson 24 requires, shows egress in terabytes per day and a control-plane QPS that is ordinary. That split is the architecture. Caching applies to manifests and to CDN objects, with a purge story. Queues isolate transcode from the upload request. Idempotency makes a retried complete-upload safe.

    We will not invent a streaming vendor's private control API. Packaging follows the public HTTP Live Streaming specification, RFC 8216. Uploads use short-lived signed PUT URLs, the ordinary object-storage pattern. Authorization tokens are ours.

    Learning Objectives

    By the end of this lesson you will be able to:

    1. Separate the control plane (upload session, metadata, entitlement, manifest URL) from the data plane (segment bytes on a CDN).
    2. Estimate source storage, packaged storage, and egress, and show why the application fleet must not relay video.
    3. Specify resumable upload, transcode job states, and playback APIs, including failure statuses.
    4. Describe a rendition ladder, segment duration, and the manifest as the contract the player uses to switch bitrates.
    5. Place caches on manifests and segments with a revoke path that matches the unpublish scenario.
    6. Keep unpublished and private lessons off anonymous CDN URLs by using scoped, expiring authorization.
    7. Explain partial transcode failure, poison inputs, and what the player sees while a ladder is incomplete.
    8. Record the trade-off between pack-once HLS and a design that stores only the original file.

    Prerequisites

    • Lesson 24's habit of separating request rate from byte rate. This case is won or lost on bytes.
    • Lesson 27, caching, including HTTP caching of immutable media. Segment URLs that include a content hash may be cached longer than manifests.
    • Lesson 33, queues, for transcode jobs. A job is not done because the message was consumed; it is done when the manifest is committed.
    • Lesson 35, rate limiting, on upload session creation so a buggy client cannot mint infinite signed URLs.
    • Lesson 37, idempotency, on complete-upload.
    • Lesson 44's instinct for tokens that expire and are scoped. We use that shape without redesigning zero trust.
    • Familiarity with the idea of a playlist of media segments. RFC 8216 is the reference for HLS. MPEG-DASH is a peer format; this iteration ships HLS so the player has one manifest type.
    • Lesson 52 assumes this lifecycle and does not replace it. Global catalog and multi-region residency are intentionally deferred.

    Detailed Core Concepts

    Product boundary

    Actors are instructors who upload, learners who play lessons they are entitled to, and staff who unpublish a video that must stop being playable. A title belongs to a lesson in the catalog. The catalog remains the owner of "this lesson exists and this user is enrolled." Reel owns media state: upload, processing, renditions, and the bytes' locations.

    In scope:

    • Create an upload session for a lesson the caller may edit.
    • Resumable upload of a source file to object storage, bounded in size and content type.
    • Asynchronous packaging into a fixed ladder of H.264 renditions in HLS, plus a poster image.
    • Playback API that checks entitlement and returns a URL to the master playlist and a short-lived cookie or query token the CDN will honor.
    • Unpublish, which stops new authorizations and purges the CDN path.
    • Player-side adaptive bitrate using the ladder the packager wrote. Reel does not run a per-viewer transcoder.

    Out of scope:

    • Live class streaming, low-latency HLS, and interactive webinars. Those change latency budgets and packaging. A reopen condition sits in the records.
    • Digital rights management license servers. Private lessons use authenticated tokens and TLS. A studio-grade DRM vendor is a boundary we can add when a licensor requires it, not a box we draw because diagrams of streaming platforms usually include one.
    • User-generated public video, comments on the timeline, and a recommendation ranker. Recommendations at global scale are lesson 52's boundary. Here the "next lesson" decision stays in the catalog.
    • Client-side upload of every rendition. The source is the instructor's file. The ladder is our job.
    • Serving video bytes from a Spring controller. That design is what failed the cohort assignment.

    Functional behavior

    An editor creates an upload session: lesson id, declared byte length, and content type. The response includes a session id and one or more signed PUT URLs for the source object, plus an offset protocol for resume. The simplest resume that is still honest is: the client asks for the next byte offset, the service asks object storage for the current object size, and the client continues. Multipart upload, as documented by major object stores, is the mechanism when the file is large. We describe the pattern — initiate, upload parts, complete — without pasting a vendor's private dialect as if it were our domain model.

    When the client calls complete, the service verifies the object exists and the size matches the declared length within a tolerance of zero for the completed object, marks the session complete, and enqueues one transcode job. A retry of complete with the same idempotency key does not enqueue a second job.

    Workers download the source from object storage on a private network, validate that it is a readable audiovisual file, and write renditions and a master playlist back to a prefix keyed by title id and a content generation. They then commit the title row to ready with the manifest key. Until that commit, playback returns 409 with state processing. A failed validation commits rejected with a reason the editor can read: not a media file, exceeds duration cap, no audio track when the lesson requires one.

    Playback: the learner's client calls POST /v1/titles/{id}/authorizations with the session's user. Enrollment is checked. On success the response carries the master playlist URL and an expiry. The player fetches the playlist and segments from the CDN. The player, not the server, chooses the next variant based on throughput and buffer, which is the definition of adaptive bitrate in HLS. Reel's job is to make several variants exist and to describe them honestly in the master playlist.

    Unpublish sets the title to unpublished, refuses new authorizations, and submits a CDN purge for that title's prefix. Segment URLs include the generation id so an old generation is not addressable by a new manifest. Players that already have a manifest can keep fetching segments until their token expires. The scenario below chooses that window on purpose.

    Quality scenarios

    Playback start. An entitled learner, on a typical broadband link, requests authorization and then the master playlist. p95 time to receive the master playlist is under 400 ms measured at the client for a warm CDN edge, excluding the time to fill the decode buffer. Authorization itself is under 150 ms p95 at the application load balancer. These numbers are home-region numbers. Lesson 52 owns intercontinental promises.

    Upload resilience. An editor uploading a 2 GB source on a network that drops once can resume and reach processing without creating a second title. The measure is a test, not a hope: kill the client after half the bytes, resume, complete once, observe one job.

    Unpublish. After unpublish commits, new authorization calls receive 403 within 5 seconds. CDN edge caches of the manifest expire or purge within 60 seconds. Already issued tokens die at their expiry, at most 15 minutes. The scenario does not claim that a laptop which has already buffered the whole lesson will forget the bytes. It claims we stop handing out new access and stop long-lived public URLs.

    Packaging latency. For a source at or under 20 minutes, p95 time from complete to ready is under 30 minutes in the home region at the stated daily volume. A 30-minute SLO is a queue and worker pool problem. It is not a request-timeout problem. The complete API returns as soon as the job is durable.

    Availability of authorization is 99.9 percent monthly. Availability of bytes at the CDN is the CDN contract plus our origin's ability to fill a miss. We write both numbers. Conflating them hides an origin outage behind a pretty edge hit ratio.

    The ladder and the manifest

    A ladder is a small set of encodings of the same timeline. For course video we ship five variants:

    VariantVideo bitrate (nominal)Frame size
    low400 kbps426×240
    base800 kbps640×360
    mid1.4 Mbps854×480
    high2.8 Mbps1280×720
    top5.0 Mbps1920×1080

    Audio is a single AAC track around 128 kbps, shared in the sense of "present in each variant" for a simple package. A separate audio rendition is a later optimization. Segment duration is 6 seconds. Shorter segments switch bitrate faster and increase request overhead and playlist size. Longer segments delay the first switch and make a bad network choice stick. Six seconds is a common engineering compromise for lesson video, not a law. The record says why, and the reopen condition is a measured start-up time or a CDN request-cost change.

    The master playlist lists each variant's bandwidth and the URL of its media playlist. Media playlists list segment URLs. RFC 8216 is the format. Players that implement it will switch without a custom protocol. A homegrown "the server tells you the bitrate" channel is extra machinery that mobile players will not honor when the network changes mid-segment. Put the information in the playlist.

    The source file is retained until packaging succeeds and for 30 days after, for re-package if we change the ladder. It is not served to learners. A title that is ready can be re-packaged into a new generation. The previous generation remains until purge completes, then becomes eligible for deletion. Playback always authorizes the generation stored on the title row.

    Immutable segments, mutable manifests

    Segment object keys include the generation id: titles/{titleId}/g/{generation}/v/{variant}/seg_{n}.ts or fragmented MP4 if we later switch containers. The bytes at that key never change. CDN and browser caches may hold them for a long time. The master playlist key is stable per generation too, so it is also immutable. What changes on unpublish is whether we will authorize the URL, plus a purge so a leaked URL without a valid token dies at the edge.

    If we had used one stable URL per lesson whose bytes changed when an instructor replaced the file, every cache in the world would be a correctness bug. Generations are how replace works: new generation, flip the title pointer, purge the old prefix on a timer. Learners who started on the old generation finish on the old generation until token expiry, then refresh and see the new one. Say that in the product note so support does not call it data loss.

    Control plane versus data plane

    Authorization, catalog entitlement, upload sessions, and job status are the control plane. They are request/response, small JSON, a relational database, a queue. Segment download is the data plane. It is object storage plus CDN. The player's relationship to our Spring application ends at the authorization response, aside from progress callbacks that are small and belong to the learning record, not to the byte path.

    This split is also a security boundary. Object storage is not public-listable. The CDN is allowed to read the bucket via an origin access control the cloud vendor documents: the edge fetches as itself, learners do not receive the bucket's root credential. Learners present a token scoped to a URL prefix and an expiry. The token is signed with a key the CDN and Reel share, rotated. A token for title A does not fetch title B. A token past expiry receives 403 at the edge.

    What the primers skip

    The Netflix-style primer lists transcode, object storage, CDN, and a client algorithm. Course Reel has to decide entitlement, generation immutability, the unpublish window, poison files, and the fact that a 99 percent CDN hit ratio still leaves an origin problem on a cold title. It also has to refuse live video and DRM rather than draw them in grey. Those refusals are the difference between a poster and a design.

    Architecture and Design Diagrams

    mermaid
    C4Context
    title Course Reel — system context
    Person(editor, "Instructor", "Uploads and replaces lesson video")
    Person(learner, "Learner", "Plays an entitled lesson")
    System(reel, "Course Reel", "Upload sessions, packaging, playback authorization")
    System_Ext(catalog, "Catalog and enrollment", "Lesson identity and entitlements")
    System_Ext(obj, "Object storage", "Source files and packaged segments")
    System_Ext(cdn, "CDN", "Caches segments and playlists near learners")
    Rel(editor, reel, "Creates upload sessions", "HTTPS")
    Rel(learner, reel, "Requests playback authorization", "HTTPS")
    Rel(learner, cdn, "Fetches playlists and segments", "HTTPS")
    Rel(reel, catalog, "Checks edit rights and enrollment", "HTTPS")
    Rel(reel, obj, "Signs uploads and reads job outputs", "HTTPS")
    Rel(cdn, obj, "Origin pull on cache miss", "HTTPS")
    mermaid
    flowchart LR
    editor[Editor]
    learner[Learner player]
    edge[Load balancer]
    api[Reel API]
    db[(Title and job tables)]
    queue[Transcode queue]
    worker[Packager workers]
    obj[(Object storage)]
    cdn[CDN]
    editor --> edge
    learner --> edge
    edge --> api
    api --> db
    api --> queue
    api --> obj
    queue --> worker
    worker --> obj
    worker --> db
    api --> cdn
    learner --> cdn
    cdn --> obj
    mermaid
    sequenceDiagram
    participant E as Editor
    participant A as Reel API
    participant O as Object storage
    participant Q as Queue
    participant W as Worker
    E->>A: POST upload session
    A-->>E: session id and signed PUT
    E->>O: PUT source bytes, resumable
    E->>A: POST complete, idempotency key
    A->>O: confirm object size
    A->>Q: enqueue job
    A-->>E: 202 processing
    Q->>W: deliver job
    W->>O: read source, write generation
    W->>A: commit ready or rejected
    mermaid
    sequenceDiagram
    participant P as Player
    participant A as Reel API
    participant C as Catalog
    participant D as CDN
    participant O as Object storage
    P->>A: POST authorization
    A->>C: entitlement
    alt not entitled or unpublished
    A-->>P: 403
    else ready
    A-->>P: playlist URL and token expiring in 15 min
    P->>D: GET master playlist
    alt edge hit
    D-->>P: playlist
    else miss
    D->>O: origin pull
    O-->>D: playlist
    D-->>P: playlist
    end
    P->>D: GET segments for chosen variant
    end

    Technical Explanation

    Upload session

    The session row stores owner, lesson id, declared length, content type, object key, status (open, completed, aborted, expired), and an expiry of 24 hours. Signed PUT URLs expire in 15 minutes and can be refreshed by the owner while the session is open. Refresh does not change the object key. The signature covers method, key, content type, and maximum length so a client cannot swap in a different key or an unbounded body.

    Resume: GET /v1/uploads/{id} returns the bytes already durable. The client sends the remainder. If the store's multipart API is in use, the session remembers the multipart id and the completed part list. Those are stored fields, not a memory map on one API node. Any API instance can continue the session. Lesson 26's stateless rule holds.

    Complete is idempotent. The first successful complete enqueues a job whose id is the session id, so a duplicate message is a duplicate key in the worker. The worker's first step is an atomic transition of the title from uploading to processing. If it is already processing or ready for this generation, the worker exits successfully. That is lesson 37 applied to a queue consumer.

    Limits: maximum source 8 GB, maximum duration 3 hours, content type on an allowlist of common video types. The type allowlist is a hint. The worker validates the actual container. A lie in the content type must not become a stored object that the packager will execute as a script. Workers run ffmpeg-class tools in a sandbox with no fleet credentials beyond object storage read of the source prefix and write of the destination prefix. A poisoned file can waste CPU. It should not read another title's prefix.

    Packaging

    The job message contains title id, session id, source key, and the generation id allocated at complete time. Workers scale on queue depth. Autoscaling on depth, with a cap, beats a fixed fleet sized for the busiest upload day and idle otherwise. Poison messages: after two failures that are validation errors, commit rejected and do not retry forever. After infrastructure failures, retry with backoff and a dead-letter queue once the retry budget from lesson 33 is spent. A human can requeue from the dead letter. A file that crashes the packager is rejected with unreadable, not an infinite crash loop. Isolate the process so one bad file does not kill the host's other jobs; a container per job or a process supervisor is the practical form.

    The worker writes the generation to a temporary prefix and publishes the master playlist last. If it dies halfway, the incomplete prefix is garbage, collected by a sweeper, and the title is not ready. Players never see a master playlist that points at missing segments. The commit of ready happens after a check that every variant playlist and the first and last segment of each variant exist. That check is cheaper than a learner hitting a hole at minute 19.

    Progress for the editor is the job state on the title, polled or pushed. We do not stream transcode percentages through the same path as playback. An approximate percent is fine and labeled approximate.

    Playback and adaptive bitrate

    The player fetches the master playlist, picks a starting variant using the playlist's bandwidth hints and a conservative first choice (the base or mid variant, not top), and requests segments in order. If throughput drops, it switches at a segment boundary to a lower variant. If the buffer is healthy, it may move up. That algorithm lives in the player. Server-side "we will pick for you" forces every segment through a decision service and still guesses wrong about the last mile. Reel's responsibility is accurate BANDWIDTH and RESOLUTION tags so the player's guess is about the real files.

    Seek is a playlist problem. With 6-second segments, a seek to minute 10 is a request for the segment index floor(600/6). The media playlist is the index. We do not build a custom byte-range protocol for v1. Range requests against a single MP4 remain useful for the source file during packaging and are not the learner protocol.

    A missing segment at the CDN, origin error, is a player retry of that segment, then a switch down if the variant looks broken. If the whole origin is down, the edge can serve what it still holds and will fail on misses. Authorization can still succeed, and the player shows a playback error. Those are different dependencies. The UI should say "video unavailable" rather than "you are not enrolled" when the token was issued and the edge returned 5xx.

    Caching, CDN, and the load balancer

    API instances sit behind a stateless load balancer. They do not cache entitlements longer than a few seconds; enrollment changes should stick quickly. The expensive cache is the CDN.

    Cache keys are the object URLs, which include the generation. Segments: long TTL, immutable. Playlists for a generation are immutable too, so they can share that policy. The authorization response is Cache-Control: no-store. It contains a token and must not be reused by a shared cache across users.

    Origin shielding, if the CDN product offers a mid-tier cache, collapses many edge misses for a cold lesson into a smaller number of origin reads. For a required lesson released to every cohort, the first minute is a thundering herd on one playlist and the first segments of the popular variant. Shielding plus a pre-warm — a job that fetches the first N segments of base and mid through the CDN after publish — keeps the origin from seeing every learner. Pre-warm is a best-effort publisher step, not a correctness requirement. Correctness is that origin can serve the objects.

    Purge on unpublish lists the generation prefix. Purge is eventually consistent at a CDN. The token expiry is the guarantee we control. Both are in the scenario so nobody deletes the token check and "relies on purge."

    Security

    Editors: IdP authentication plus catalog "can edit this lesson." The signed upload URL is the editor's capability to write one key until it expires. It is not a bucket credential.

    Learners: IdP plus enrollment check at authorization time. The CDN token is audience-restricted, prefix-restricted, short-lived, and bound to the generation. Logs at the CDN may include the token id, not the user's other profile data, if we put a user id in the token only as an opaque subject for abuse tracing. We do not put the user's email in the URL.

    Unpublished titles fail closed. A bug that defaults to allow when catalog times out is the wrong failure. Authorization returns 503 if enrollment cannot be decided. The player retries. It does not play.

    SSRF is less tempting here than in the shortener, but workers must not accept a source URL on the public internet. The source is an object key in our bucket. An editor who could pass https://169.254.169.254/ as the source would aim the worker at the cloud metadata service. The API never takes a source URL from the client. It allocates the key.

    Secrets: signing keys for CDN tokens live in a secret store, rotate, and overlap for the longest token lifetime so a rotation does not break tokens already issued. Two public keys at the edge during rotation is the usual pattern. We describe the overlap. We do not invent a vendor's key-management API.

    Abuse: rate-limit session creation. Alert on authorization failures that spike for one title, which can mean a leaked token being reused after we thought we had unpublished. A denylist of token ids supports early revoke inside the 15-minute window if we are willing to operate it. v1 relies on short expiry plus purge; the denylist is the reopen if a leaked token in that window is unacceptable for a particular title. Write that down rather than pretending expiry is instant revoke of bytes already authorized.

    Failure handling

    DependencyNoticeUser seesPersistedRefusal
    Object storage down during uploadPUT errorsEditor resumes later against the same session if the store returnsPartial object or notA second title on retry
    Complete when object missingHEAD fails409 session not readySession stays openEnqueue a job
    Worker crash mid-packageJob visibility timeout, redeliveryEditor still sees processingIncomplete prefix, not readyMarking ready optimistically
    Poison fileValidator errorEditor sees rejected and a reasonSource retained for inspection for 7 daysInfinite retry
    Catalog timeout on playCall timeout503, player retriesNothingPlaying anyway
    CDN purge failurePurge API errorNew auth already 403; old token may work until expiryUnpublished flagReporting unpublish as "globally forgotten"
    Queue backlogAge of oldest jobEditor waits; SLO burnJob rowDropping jobs silently

    The worker's visibility timeout must exceed the worst legitimate package time, or a slow 3-hour source will be delivered twice. Two workers writing the same generation must be safe: write to a temp prefix, commit metadata once. The second worker sees ready or sees the lock row and exits. A conditional update WHERE status = 'processing' AND generation = ? is the lock. Lesson 36 would call an external lock optional and inferior to this row.

    Trade-offs

    HLS ladder versus one MP4. One file is simpler and punishes every learner with the same bitrate. We pay CPU and storage, about two times the source in the estimate below, and gain start-up and rebuffer behavior on weak networks.

    Package on upload versus package on first view. On upload, the first learner finds the ladder ready and we spend CPU even if nobody watches. Course video is assigned; someone will watch. Package on upload. A speculative user-generated site might choose the other way. We are not that site.

    15-minute token versus 12-hour token. Short tokens tighten unpublish and force the player to refresh during a long lesson. The player refreshes authorization when the token is near expiry and continues at the same segment index. That refresh is a requirement on the client. A 12-hour token is easier and widens the leak window.

    Retain source versus delete after package. Retain 30 days so a ladder change can reprocess. Storage cost is in the estimate. Deleting immediately saves money and makes a bad ladder permanent until the instructor uploads again.

    6-second segments versus 2-second. Faster switching, more objects, more requests. Course content is not sports. Six seconds stands until measurement says start-up or rebuffer is wrong.

    Practical Implementation or Design Walkthrough

    Assumptions

    IdAssumption
    V150,000 titles in the hot catalog
    V2Average source is 20 minutes at about 8 Mbps camera export, roughly 1.2 GB
    V3200 new or replaced sources per day
    V4200,000 lesson plays per day, average watch 15 minutes
    V5Average delivered bitrate 2.0 Mbps after adaptation
    V640 percent of a day's bytes are delivered in a 4-hour evening peak
    V7Five variants as in the ladder table, plus audio inside each
    V8CDN hit ratio 97 percent after the title is warm; cold titles miss
    V9Home region only

    Arithmetic

    Source ingress per day:

    [ 200 \times 1.2 \text{ GB} = 240 \text{ GB/day} ]

    That ingress goes to object storage, not through the API process. The API sees 200 complete calls, a trivial rate.

    Packaged size per title. Bitrate times duration, divided by 8 for bytes:

    [ 20 \times 60 \times \frac{0.4 + 0.8 + 1.4 + 2.8 + 5.0}{8} \text{ MB} ]

    Sum of bitrates is 10.4 Mbps. Times 1,200 seconds is 12,480 megabits, about 1.56 GB of video, plus audio roughly (1{,}200 \times 128{,}000 / 8 \approx 19) MB per variant if audio is duplicated, about 0.1 GB. Plan 1.7 GB packaged per title plus the 1.2 GB source during retention. Call it 3 GB while the source is retained and 1.7 GB after.

    Catalog storage:

    [ 50{,}000 \times 1.7 \text{ GB} \approx 85 \text{ TB} ]

    plus sources for recent uploads, small next to the catalog if only 30 days of sources are kept: (200 \times 30 \times 1.2 \text{ GB} \approx 7 \text{ TB}). Object count is the hidden cost. Six-second segments, 200 segments per variant, five variants, is 1,000 segment objects per title, plus playlists. Fifty thousand titles is about 50 million objects. Lifecycle rules and listing costs matter. The database does not store a row per segment. Keys are computable from generation, variant, and index. The database stores the title, the generation, the ladder that was actually produced, and duration.

    Egress per day:

    [ 200{,}000 \times 15 \times 60 \times \frac{2.0 \times 10^6}{8} \text{ bytes} ]

    Fifteen minutes is 900 seconds. Times 2 Mbps is 1,800 megabits, 225 MB per play. Times 200,000 plays:

    [ 200{,}000 \times 225 \text{ MB} = 45{,}000{,}000 \text{ MB} \approx 45 \text{ TB/day} ]

    Evening peak, 40 percent of bytes in 4 hours:

    [ 0.4 \times 45 \text{ TB} = 18 \text{ TB in } 14{,}400 \text{ s} \approx 1.25 \text{ GB/s} ]

    An application fleet relaying that would be the outage. A CDN is the data plane. Origin traffic at a 97 percent hit ratio is about 3 percent of egress, roughly 1.4 TB/day average, with a worse fraction on a brand-new required lesson before the cache warms. Pre-warm and shielding exist for that hour. The saturated element is origin bandwidth on a cold popular title, not the authorization QPS.

    Authorization QPS. Plays are not all simultaneous. Two hundred thousand plays per day is about 2.3 starts per second average. If a cohort of 5,000 starts within five minutes:

    [ \frac{5{,}000}{300} \approx 17 \text{ starts/s} ]

    That is a small API. Cache the catalog's entitlement decision for a few seconds if the catalog asks, and do not cache it for minutes. The scale path is CDN capacity and origin fill, plus worker throughput for packaging. Worker math: 200 jobs per day, each reading 1.2 GB and writing 1.7 GB. If one job takes 20 minutes of worker time, 200 jobs are about 67 worker-hours per day, which is a handful of workers even with a daytime spike. The SLO of 30 minutes p95 needs spare workers so a queue does not form behind a few long sources. Size the pool so the day's work finishes with headroom, and autoscale on queue age.

    Ten-times multiplier, labeled M3: 450 TB/day egress and a cold-title origin problem that is also ten times sharper. The API is still small. The design change is multi-CDN and regional origins, which is lesson 52, plus more workers. It is not a microservice per bitrate.

    API

    POST /v1/lessons/{lessonId}/uploads

    • Auth, editor. Body: contentType, byteLength, idempotencyKey.
    • 201 session id, object key, signed upload instructions, session expiry.
    • 403 cannot edit. 413 declared length over the cap. 415 type not allowed. 429 too many open sessions.

    GET /v1/uploads/{sessionId}

    • Returns durableOffset or completed parts.

    POST /v1/uploads/{sessionId}/complete

    • Idempotent. 202 title id, generation, status processing. 409 if the object is short. 200 with the same body on replay.

    GET /v1/titles/{titleId}

    • Editor view of status: processing, ready, rejected, unpublished, plus error reason and generation.

    POST /v1/titles/{titleId}/authorizations

    • Learner auth. 200 playlist URL, token, expiresAt no later than 15 minutes, generation id.
    • 403 not entitled or unpublished. 409 not ready. 404 unknown. 503 entitlement unknown.

    POST /v1/titles/{titleId}/unpublish

    • Editor or staff. 204. Idempotent.

    Progress pings from the player go to the learning-record API, not to Reel. Mention the boundary so Reel does not become a second progress system.

    Schema

    sql
    CREATE TABLE video_title (
    id uuid PRIMARY KEY,
    lesson_id uuid NOT NULL UNIQUE,
    status varchar(16) NOT NULL,
    generation bigint NOT NULL DEFAULT 0,
    duration_s integer,
    reject_reason varchar(64),
    updated_at timestamptz NOT NULL
    );
    CREATE TABLE upload_session (
    id uuid PRIMARY KEY,
    title_id uuid NOT NULL REFERENCES video_title(id),
    owner_id uuid NOT NULL,
    object_key varchar(512) NOT NULL,
    byte_length bigint NOT NULL,
    content_type varchar(128) NOT NULL,
    status varchar(16) NOT NULL,
    idempotency_key varchar(80) NOT NULL,
    multipart_id varchar(256),
    expires_at timestamptz NOT NULL,
    UNIQUE (owner_id, idempotency_key)
    );
    CREATE TABLE package_job (
    session_id uuid PRIMARY KEY,
    title_id uuid NOT NULL,
    generation bigint NOT NULL,
    state varchar(16) NOT NULL,
    attempts integer NOT NULL DEFAULT 0,
    updated_at timestamptz NOT NULL
    );

    Segment rows are absent on purpose. A ladder descriptor can be a JSON column on video_title written at commit time: the variants actually produced and their playlist paths. If a variant fails and we still ship a shorter ladder, the manifest and this column must agree. Prefer failing the job over shipping a ladder the column does not describe.

    Spring and worker notes

    The API is a Spring service. Signing a PUT uses the cloud SDK you already operate, behind a port UploadSigner so tests do not need the network. The controller never opens the video file.

    java
    @PostMapping("/titles/{titleId}/authorizations")
    public ResponseEntity<AuthResponse> authorize(@PathVariable UUID titleId, Principal principal) {
    VideoTitle title = titles.get(titleId);
    if (title == null) {
    return ResponseEntity.notFound().build();
    }
    if (title.status() != Status.READY) {
    return ResponseEntity.status(HttpStatus.CONFLICT).build();
    }
    Entitlement ent = catalog.entitlement(principal.getId(), title.lessonId());
    if (ent == Entitlement.UNKNOWN) {
    return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
    }
    if (ent == Entitlement.DENIED) {
    return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
    }
    SignedPlayback signed = tokens.issue(title, Duration.ofMinutes(15));
    return ResponseEntity.ok(new AuthResponse(signed.url(), signed.expiry()));
    }

    The catalog client has a timeout under the 150 ms budget, with a bulkhead so a slow catalog does not exhaust the API pool. Lesson 41's timeout and bulkhead apply here as ordinary resilience, not as a new framework.

    Workers are a separate process, also Spring if that is the house style, consuming the queue with manual acknowledgement after the status commit. Acknowledge after ready or rejected is durable. Acknowledging on receipt loses the job when the process dies. Concurrency per worker is low, because each job saturates CPU and disk. Scale out processes instead of threads.

    Tests

    • Resume after a dropped PUT, one job, one generation.
    • Complete retried after commit does not create a second generation.
    • Worker killed after writing half the segments leaves status processing and a redelivery finishes or a second worker no-ops once ready.
    • Unreadable input becomes rejected and stops retrying.
    • Authorization without enrollment is 403 and issues no token.
    • Catalog timeout is 503 and issues no token.
    • Token for generation N fails to be accepted as a URL for generation N+1 in a verifier test against the token prefix rules.
    • A player test, even a small one, starts at mid, is given a slow-network fixture, and requests a lower variant. That test belongs to the player repo and is part of this design's acceptance because the ladder is useless if the client ignores it.

    Records

    ADR-051-1. Bytes are served from a CDN pulling object storage. The API issues authorization and does not proxy media. Rejected: streaming the MP4 through Spring. Reopen only if a regulatory constraint forbids a CDN and we accept the egress ceiling in writing.

    ADR-051-2. Immutable generations, HLS with 6-second segments, five-rung ladder, tokens of 15 minutes. Rejected: mutable lesson URL; single bitrate; day-long public URLs. Reopen the segment duration if start-up or request cost measurements contradict the assumption. Reopen DRM only when a content contract requires a license server.

    Enterprise Real-World Scenarios

    The required lesson at 09:00

    A core lesson is assigned to 8,000 learners who start within fifteen minutes. Authorization QPS is about (8{,}000 / 900 \approx 9) per second if the rush is even, higher if it is not. The API is fine. The CDN sees 8,000 requests for the same master playlist and the first segments of whichever variant the players prefer. If players all start at top, the edge fills a 5 Mbps stream times 8,000, which is 40 Gbps of one title, and learners on weak links rebuffer before they switch down. The player change is to start at mid. The platform change is pre-warm of mid and base for the first minute of video, origin shielding, and an alert on origin requests per title. Support's symptom is "the video spins for some people." The metric is origin 5xx and time-to-first-segment, not API CPU.

    Unpublish during that hour, because the file is the wrong cut, must flip authorization immediately and purge. Learners mid-play continue until the token expires and the buffer drains. Product and support need that sentence in the runbook. A design that promises "the wrong video disappeared from every laptop at the moment of the click" will be declared broken the first time it is used correctly.

    The upload that was a zip of slides

    An instructor uploads a zip, or a truncated MP4, or a file with an exotic codec the packager cannot decode. The worker fails, retries, and fails again. Without a terminal rejected state, the title sits in processing all weekend and the queue retries ahead of healthy jobs if backoff is wrong. With the terminal state, the editor sees "we could not read this file" and uploads a standard export. The source is retained seven days for an engineer to confirm the diagnosis, then deleted. The incident review checks that the worker sandbox had no extra credentials, because the interesting failure is not the zip. The interesting failure is a crafted file that makes the media tool fetch a remote subtitle URL. The packager runs with network disabled except for object storage. That policy is part of the design.

    Best Practices

    • Count bytes and objects, not only requests. Video designs that quote QPS and ignore egress have not started.
    • Keep the API off the media path. Sign uploads and playback; do not proxy them.
    • Make packaged bytes immutable and name them by generation. Replace is a pointer flip.
    • Commit ready only after the manifest and its segments exist.
    • Bound token lifetime and scope tokens to a prefix. Treat CDN purge as a helper, not the only revoke.
    • Fail closed when entitlement is unknown.
    • Cap and validate uploads. Sandbox the packager. Put poison inputs in rejected.
    • Start players on a modest rung. Put accurate bandwidth numbers in the master playlist.
    • Pre-warm the likely first segments of a required lesson. Shield the origin.
    • Separate Reel's media state from catalog entitlement and from learning progress.

    Common Mistakes and Anti-Patterns

    Proxying bytes through the application. It converts a CDN problem into an outage of the site that also serves quizzes. The estimate is the argument.

    One mutable URL per lesson. Caches will serve the previous cut after a replace, or you will disable caching and melt the origin. Generations fix this.

    Public buckets. A guessable key becomes a leak that bypasses enrollment. Private origin plus a scoped token is the minimum.

    Marking the title ready when the job is enqueued. The first learner is the integration test, and they fail at segment 3. Commit after the objects exist.

    Infinite retry of a bad file. It blocks workers and hides a content problem as an incident. Terminal rejection is a product state.

    Server-picked bitrate for each segment. It adds a dependency on the hot path and still cannot see the client's buffer. Use HLS as it is specified.

    Caching the authorization response at a shared HTTP cache. The next learner receives someone else's token. no-store and a private response.

    A row per segment in the primary database. Fifty million rows of "seg_17 exists" will dominate operations without helping the player, who already has the playlist.

    Assuming the CDN hit ratio saves a cold title. The first cohort pays origin costs. Design the miss.

    Troubleshooting and Design Review

    Symptom: Players spin, API latency is normal, CDN origin errors spike for one title.

    Likely cause: missing objects for a generation marked ready, or a hot miss storm, or a bad origin credential after a rotation.

    Investigation: fetch the playlist from an edge and from origin with the same token rules. Compare object listing to the ladder column. Check origin error codes.

    Corrective action: if objects are missing, flip the title back to processing or rejected and repackage. If objects exist, add shielding and pre-warm, and fix credentials. Do not raise API replicas.

    Symptom: Learners still watch a video an hour after unpublish.

    Likely cause: token lifetime longer than the scenario, purge not submitted, or a second CDN hostname that was never purged. Also possible: the player cached the manifest on disk and a token that lasts hours.

    Investigation: token exp, purge job log, hostnames in the playlist.

    Corrective action: align TTL, purge every hostname, shorten tokens. Explain buffered playback to support so they do not expect amnesia on the device.

    Symptom: Upload complete returns 202 and the title never leaves processing.

    Likely cause: queue consumer down, worker crashing on this file, or the conditional update never matching because generation ids diverged.

    Investigation: queue depth, dead-letter count, worker logs for the session id, job row attempts.

    Corrective action: requeue if it is infrastructure; reject if it is the file; fix the generation bug if the worker is packaging a different id than the row.

    Symptom: The bill for object requests jumps after a ladder change to 2-second segments.

    Likely cause: request amplification. Four times as many segment GETs for the same watch time, worse cache behavior on short objects.

    Investigation: requests per minute of watch, segment duration in the playlist.

    Corrective action: restore the duration the record chose unless a start-up metric justifies the cost. This is a product and finance decision, not only an engineering preference.

    Design-review questions: Where do bytes flow, and where must they not flow? When does a title become playable? How does replace avoid stale caches? What is the unpublish window for new viewers versus a viewer who already has a token? What happens when catalog times out? What is peak egress and residual origin egress? How does a poison file end?

    Interview Questions and Answers

    Q1. Why not stream the MP4 from the application servers?

    At the estimate, evening traffic is on the order of a gigabyte per second, and a single required lesson can concentrate that. Application servers sized for JSON will saturate their NICs, and a video spike will take down unrelated APIs if they share a fleet. Object storage plus a CDN is built for this byte shape. The application keeps authorization, which is a few requests per second even when the bytes are large. I show both numbers so the split is a consequence of the arithmetic.

    Q2. What is adaptive bitrate in this design, and what is the server's job?

    The packager produces several encodings and an HLS master playlist that names their bandwidth and resolution. The player picks a variant and can switch at segment boundaries when throughput or buffer changes. The server does not choose the next segment for the viewer. Its job is an accurate playlist, segments that actually exist, and a token that lets the CDN serve that generation. A server-side switcher would sit on the hot path and would still be guessing about the last mile.

    Q3. How do you make upload survive a dropped connection?

    The session and the object key are durable. The client asks how many bytes or which parts are already stored and continues. Complete is idempotent on the session, and the job id is the session id so a retried complete or a redelivered queue message cannot create a second generation. The API process holds no upload in memory, so a load balancer may send the next call to any instance.

    Q4. When is a title ready for playback?

    After the worker has written the master playlist, each variant playlist, and the segments those playlists name, and after a database transaction moves the title to ready for that generation. Enqueue time is processing. A crash mid-write leaves the title unready and the job retryable. Learners receive 409 until the commit. I would rather delay the first view than serve a playlist with holes.

    Q5. How do you unpublish, given CDN caches?

    The title status flips, and new authorizations get 403 immediately. Tokens already issued expire within 15 minutes and are prefix-scoped to the generation. We also purge the CDN prefix, knowing purge is eventual. Segment bytes are immutable, so we are not trying to edit cached objects in place. A device that has already buffered video will finish that buffer. The scenario states that limit so product does not promise instant amnesia.

    Q6. How do you stop one learner's URL from playing a private lesson for someone else?

    The origin bucket is not public. The CDN fetches origin with its own authority. The learner's token is signed, expires, and matches the title prefix and generation. Authorization checks enrollment first and returns 503 if that check cannot be completed, so a catalog outage does not become a free play. URLs are unguessable generations, and unguessable is not the control. The control is the token and the enrollment check.

    Q7. What is your storage and egress math, in outline?

    About 50,000 titles times roughly 1.7 GB packaged is about 85 TB, plus a short retention of sources. Two hundred thousand plays a day at 15 minutes and about 2 Mbps is about 45 TB of egress a day. A 97 percent CDN hit ratio still leaves on the order of a terabyte a day at origin, and a cold popular title is worse until the cache fills. Upload ingress is a few hundred gigabytes a day, aimed at object storage. Authorization QPS stays small. Those four sentences are the capacity plan.

    Q8. How do you handle a file that makes the transcoder crash?

    Validation errors and crashes end in rejected after a small retry budget, with a reason for the editor. The message goes to a dead letter rather than blocking the partition forever. Workers have no general network access and can only read the source prefix and write the destination prefix. I do not retry a zip file for a week. I do retry when object storage blipped, because that is our failure, not the instructor's file.

    Q9. Why generations instead of overwriting the object?

    Overwrite fights every cache and makes rollback impossible. A new generation is a new prefix. The title row points at the generation learners should start. Old tokens finish the old generation. CDN caches of immutable keys can use a long TTL, which is the cost win. The database stores the pointer, not a row per segment.

    Q10. Where would you use a queue, a cache, and a shard?

    The queue is the transcode job, acknowledged after the status commit. The cache that matters is the CDN on immutable segment keys, plus no-store on authorization. I would not shard the title database at a few authorization requests per second and 50,000 rows. I would add workers and CDN capacity first. Lesson 30 applies if metadata ever outgrows one primary, which this estimate does not show. The shard temptation comes from the byte count. Bytes are not in that database.

    Q11. What does the player do when one variant is broken and another works?

    It retries the segment, then switches to a lower variant at the next boundary, which HLS allows if the playlists are aligned in time. Our packager uses the same segment duration and timeline so switches do not skip. If every variant fails, the player surfaces a playback error distinct from 403. Authorization already succeeded; this is a data-plane failure. I do not send the player back through a "pick a bitrate" API on our origin for every segment.

    Q12. How does this differ from a global streaming platform?

    Course Reel packages and authorizes titles for one home region, with one CDN and a catalog it consults but does not own. A global platform, lesson 52, adds regional entitlement latency, multi-CDN steering, catalog publication, encoding cost at a much larger ladder and library, residency of viewing history, and a recommendations path that must be allowed to fail. The segment format and the "API does not carry bytes" rule remain. I would not paste a second region into this design without a latency scenario that needs it.

    Hands-On Exercises

    Exercise: Replace a lesson video without stranding viewers

    Problem statement. Instructors must upload a corrected video for a lesson that learners are already watching. Some learners are mid-video. Some have not started. The corrected video must become the one new sessions play, and the previous video must stop being offered within the unpublish window you choose. Design the generation flip, the player behavior, and the capacity impact of keeping both generations for a day.

    Tasks.

    1. Write the user-visible behavior for a learner mid-playback and a learner who starts after the flip.
    2. State token expiry, purge, and what you will not promise about buffered bytes.
    3. Estimate extra storage for one day of overlap given 500 replacements and the packaged size you assume. Label the assumption.
    4. Specify the API calls and title states from upload complete through the pointer flip.
    5. Draw the sequence for a player that must refresh authorization near token expiry and land on the new generation at a segment boundary.
    6. Explain how immutable cache keys prevent the old bytes from being served as the new generation.
    7. Note failure if the new package is rejected after the old generation was already playing. The old one must remain the authorized generation.
    8. Record an ADR that rejects "overwrite the same URL."

    Expected deliverables. Scenarios, arithmetic, state notes, one sequence diagram, ADR.

    Evaluation rubric.

    Assessment areaWeightWhat "good" looks like
    Requirements and assumptions15%Mid-play and new-play behavior differ explicitly
    Architecture and design quality25%Pointer flip and immutable keys are coherent
    Technical correctness20%Rejected upload cannot strand the lesson with no generation
    Scalability, reliability, and security20%Overlap storage is computed; tokens stay scoped
    Trade-off reasoning10%ADR names who waits and who is refreshed
    Documentation and communication10%Support could explain the window from the packet

    A design that overwrites objects in place, or that drops the old generation before the new one is ready, is not ready for review.

    Lesson Summary

    Course Reel splits a small control plane from a large data plane. Editors upload a source directly to object storage through a resumable, idempotent session. Workers package an immutable HLS generation and only then mark the title ready. Learners get a short-lived, prefix-scoped authorization; the CDN serves segments and playlists and pulls misses from a private origin. The application never relays the video.

    The numbers that drive the shape are about 85 TB of packaged catalog, about 45 TB of egress per day, and an evening peak around a gigabyte per second, against an authorization rate of a few starts per second except during a cohort rush that is still modest. Origin misses on a cold required lesson, poison inputs, and unpublish versus token lifetime are the failure designs that matter. One MP4 proxied by Spring, mutable URLs, and public buckets are the designs the estimate and the entitlement rules reject.

    Lesson 52 takes this lifecycle as given and asks what changes when the audience, the catalog, and the edges are global: regional control planes, encoding economics, multi-CDN failure, and a recommendations component that is not allowed to sit on the critical path of pressing play.

    Further Reading

    Ready to mark this lesson complete?Track your journey across the entire course.