Designing a Video Streaming Pipeline: Transcoding, Adaptive Bitrate, and CDN Delivery at Scale
Video streaming is one of the most infrastructure-intensive problems in software. This article walks through every layer: ingest, transcoding, manifest generation, CDN delivery, and adaptive bitrate selection, with the tradeoffs that actually matter in production.
Video streaming looks deceptively simple from the outside. You upload a file, viewers press play. Under that surface is one of the most infrastructure-intensive pipelines in software: you need to transcode content into a dozen quality variants, package it into time-aligned segments, generate manifests that players can parse, cache everything at the edge globally, and then implement an ABR algorithm that picks the right quality for each viewer’s connection in real time.
This article covers the full pipeline from upload to playback, with the design decisions that matter and the TypeScript examples that make the abstractions concrete.
Ingest: Getting Video Into the System
The ingest pipeline handles raw video arriving from encoders, browsers, or production studios. The concerns here are different depending on whether you are building VOD (video on demand) or live streaming, but the fundamentals are shared.
Chunked upload for VOD. A raw 4K video file can easily be 20-50 GB. You never accept this as a single HTTP POST. Instead, you issue presigned upload URLs for fixed-size chunks (typically 50-200 MB) and have the client upload them in parallel. Once all chunks are confirmed, you reassemble them server-side or pass chunk manifests directly to the transcoding workers.
The ingest API tracks upload state:
interface UploadSession {
sessionId: string;
assetId: string;
totalChunks: number;
receivedChunks: Set<number>;
status: "pending" | "assembling" | "queued" | "failed";
storagePrefix: string; // s3://bucket/raw/{assetId}/
}
async function finalizeUpload(session: UploadSession): Promise<void> {
if (session.receivedChunks.size !== session.totalChunks) {
throw new Error(
`Missing chunks: expected ${session.totalChunks}, got ${session.receivedChunks.size}`
);
}
await objectStorage.compose({
destination: `${session.storagePrefix}source.mp4`,
sources: Array.from({ length: session.totalChunks }, (_, i) =>
`${session.storagePrefix}chunk-${i}`
),
});
await transcodingQueue.enqueue({
assetId: session.assetId,
sourceKey: `${session.storagePrefix}source.mp4`,
priority: "standard",
});
}
Container probing. Before transcoding starts, probe the container to extract codec, bitrate, frame rate, duration, and audio track metadata. This drives which codec profile to use and which rungs of the ABR ladder are worth generating. A 480p source has no business being upscaled to 4K.
Live ingest. For live streams, the source encoder (OBS, hardware encoder, or a broadcast switcher) sends an RTMP or SRT stream to an ingest edge node. That node converts the incoming stream to HLS or DASH segments in near real time and writes them to a staging store. The latency floor here is determined by segment duration: shorter segments (2 seconds) reduce latency but increase manifest polling load and CDN pressure.
Transcoding Architecture
Transcoding is CPU and GPU heavy, embarrassingly parallel by asset, and latency-sensitive for anything live. The architecture has three layers: a job queue, worker pools, and a results store.
Codec selection. H.264 (AVC) remains the compatibility baseline. H.265 (HEVC) achieves similar quality at roughly half the bitrate but requires more compute to encode and is not supported everywhere. AV1 is the long-term trajectory: royalty-free, better compression than HEVC, increasingly hardware-accelerated. A practical production approach:
- H.264 for all renditions (universal compatibility)
- H.265 or AV1 for high-quality tiers, served only to capable players with codec detection
The ABR ladder. An ABR (adaptive bitrate) ladder is the set of quality renditions you produce. Each rung has a resolution, target bitrate, and codec profile. A typical ladder for a streaming platform:
| Resolution | Bitrate (H.264) | Frame Rate | Use Case |
|---|---|---|---|
| 240p | 300 Kbps | 30fps | very poor connections |
| 360p | 600 Kbps | 30fps | mobile, low bandwidth |
| 480p | 1200 Kbps | 30fps | standard mobile |
| 720p | 2500 Kbps | 30fps | desktop HD |
| 720p | 3500 Kbps | 60fps | gaming/sports |
| 1080p | 5000 Kbps | 30fps | desktop full HD |
| 1080p | 8000 Kbps | 60fps | high motion content |
| 2160p | 20000 Kbps | 30fps | 4K capable devices |
Every rung is transcoded independently, allowing parallel workers. A job scheduler partitions the asset into fixed-length GOP-aligned chunks (group of pictures), dispatches chunks across workers, then stitches the output segments together. This reduces per-rendition latency from hours to minutes on large assets.
Worker model. Workers pull from a priority queue. Live content gets highest priority. New VOD uploads get standard priority. Re-encoding jobs (codec upgrades) run as background work. Workers emit progress events that the ingest coordinator consumes to update asset status.
interface TranscodeJob {
assetId: string;
sourceKey: string;
renditions: RenditionSpec[];
segmentDurationSeconds: number; // typically 6 for VOD, 2 for live
outputPrefix: string;
}
interface RenditionSpec {
name: string; // e.g. "720p30"
width: number;
height: number;
videoBitrate: number; // bits per second
audioBitrate: number;
codec: "h264" | "h265" | "av1";
profile: string; // e.g. "high", "main"
}
async function processTranscodeJob(job: TranscodeJob): Promise<RenditionResult[]> {
const results: RenditionResult[] = [];
await Promise.all(
job.renditions.map(async (rendition) => {
const segments = await ffmpegTranscode({
input: job.sourceKey,
rendition,
segmentDuration: job.segmentDurationSeconds,
outputPrefix: `${job.outputPrefix}/${rendition.name}/`,
});
results.push({
renditionName: rendition.name,
segments,
totalDuration: segments.reduce((acc, s) => acc + s.duration, 0),
});
})
);
return results;
}
Manifest Generation: HLS and DASH
Once segments exist, you need a manifest that tells the player what renditions are available, where the segments live, and how to sequence them.
HLS (HTTP Live Streaming) uses .m3u8 files. There is a master playlist pointing to per-rendition media playlists, and each media playlist lists individual segment URLs with their durations.
DASH (Dynamic Adaptive Streaming over HTTP) uses an XML-based MPD (Media Presentation Description) file. The structure is semantically equivalent: a top-level document references period, adaptation set, and representation elements that map to quality variants and segments.
For VOD content, manifests are generated once after transcoding and stored statically. For live content, media playlists are regenerated on every segment boundary.
Here is a TypeScript function that generates an HLS master playlist and per-rendition media playlists:
interface HLSManifestOptions {
assetId: string;
renditions: RenditionResult[];
segmentBaseUrl: string; // e.g. https://cdn.example.com/segments
targetDuration: number;
}
function generateHLSMasterPlaylist(options: HLSManifestOptions): string {
const lines: string[] = ["#EXTM3U", "#EXT-X-VERSION:6", ""];
for (const rendition of options.renditions) {
const spec = rendition.spec;
const bandwidth = spec.videoBitrate + spec.audioBitrate;
const resolution = `${spec.width}x${spec.height}`;
const playlistUrl = `${options.segmentBaseUrl}/${options.assetId}/${spec.name}/playlist.m3u8`;
lines.push(
`#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},RESOLUTION=${resolution},CODECS="avc1.640028,mp4a.40.2",NAME="${spec.name}"`
);
lines.push(playlistUrl);
lines.push("");
}
return lines.join("\n");
}
function generateHLSMediaPlaylist(
rendition: RenditionResult,
baseUrl: string,
assetId: string
): string {
const lines: string[] = [
"#EXTM3U",
"#EXT-X-VERSION:6",
`#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...rendition.segments.map((s) => s.duration)))}`,
"#EXT-X-PLAYLIST-TYPE:VOD",
"",
];
for (const segment of rendition.segments) {
lines.push(`#EXTINF:${segment.duration.toFixed(6)},`);
lines.push(`${baseUrl}/${assetId}/${rendition.renditionName}/${segment.filename}`);
}
lines.push("#EXT-X-ENDLIST");
return lines.join("\n");
}
One critical detail: segment durations must be consistent across renditions at every position in the timeline. If segment 5 in the 720p rendition covers timestamps 30.0-36.0 seconds, segment 5 in every other rendition must cover the same range. This alignment is what allows the player to switch renditions mid-stream without a seek or buffer stall. Misalignment causes player-side discontinuities that manifest as stutters or brief black frames.
CDN Delivery: Edge Caching for Video Segments
Video is CDN-native. The access pattern is high read volume, low write volume, large objects, and highly cacheable once the manifest is published. The architecture has three tiers:
- Origin storage: object storage (S3, GCS, R2) holding the source of truth for all segments and manifests.
- Origin shield: a single CDN PoP in the same region as origin storage. All cache misses from edge nodes flow through the shield before hitting origin. This collapses potentially thousands of simultaneous cache miss requests for the same segment (during a viral spike) into one or a few requests to origin.
- Edge PoPs: 50-200 geographically distributed nodes serving players directly.
The cache key for segments is straightforward: the full URL path. Segments are immutable once written, so cache TTLs can be set to 1 year. The manifest is where it gets interesting.
For VOD, the master playlist is also immutable. Set a long TTL, cache it everywhere. For live, media playlists change every few seconds. You want edge nodes to serve the current playlist quickly, but you cannot cache it for long. A typical pattern:
- Live media playlist TTL at the edge:
segmentDuration / 2seconds - Live media playlist TTL at the origin shield: 1 second
- Serve stale while revalidating to avoid thundering herd
Segment routing in TypeScript. A lightweight edge function can handle routing logic: geolocation-based PoP selection, signed URL validation for DRM-protected content, and routing between standard and premium CDN providers based on asset tier.
interface SegmentRequest {
assetId: string;
rendition: string;
segmentIndex: number;
viewerRegion: string;
token?: string;
}
interface RouteResult {
cdnUrl: string;
cacheControl: string;
requiresSignature: boolean;
}
function routeSegmentRequest(
request: SegmentRequest,
config: StreamingConfig
): RouteResult {
const asset = config.assets.get(request.assetId);
if (!asset) throw new Error(`Unknown asset: ${request.assetId}`);
const requiresSignature = asset.drm !== "none";
// Route premium assets to a higher-tier CDN with better peering
const cdnBase =
asset.tier === "premium"
? config.premiumCdnBase
: config.standardCdnBase;
const segmentPath = `/${request.assetId}/${request.rendition}/seg-${request.segmentIndex}.ts`;
const cdnUrl = requiresSignature
? signUrl(`${cdnBase}${segmentPath}`, config.signingKey, 3600)
: `${cdnBase}${segmentPath}`;
// Segments are immutable, cache for 1 year
const cacheControl = "public, max-age=31536000, immutable";
return { cdnUrl, cacheControl, requiresSignature };
}
Player-Side ABR Selection
The player receives the master playlist, fetches rendition playlists, and must decide at each segment boundary which rendition to download next. This is the ABR algorithm. Getting it wrong means either rebuffering (too aggressive in quality selection) or unnecessarily poor quality (too conservative).
Three major algorithm families:
Throughput-based (BOLA variants, most HLS players). Estimate available bandwidth from recent segment download times, subtract a safety margin, and pick the highest rendition that fits comfortably. Simple, works well when bandwidth is stable. Struggles with bursty networks where one slow measurement leads to unnecessary quality drops.
Buffer-based (MPC, BOLA). Drive quality selection based on current buffer occupancy rather than raw throughput. If the buffer is healthy (say, above 15 seconds), you can afford to attempt higher quality. If the buffer is thin (below 5 seconds), drop quality regardless of current throughput. This is more stable under network variance.
Hybrid (most production players). Combine both signals: use throughput estimates when bandwidth is clearly changing, use buffer level as a stability floor. Netflix, YouTube, and the major open-source players (hls.js, Shaka Player) all use hybrid approaches with tuning knobs exposed via configuration.
A simplified TypeScript rendition selector:
interface ABRState {
bufferLevelSeconds: number;
estimatedBandwidthBps: number;
currentRenditionIndex: number;
renditions: RenditionSpec[]; // sorted ascending by bitrate
}
function selectNextRendition(state: ABRState): number {
const { bufferLevelSeconds, estimatedBandwidthBps, renditions } = state;
// If buffer is critically low, drop to lowest quality immediately
if (bufferLevelSeconds < 3) {
return 0;
}
// Apply safety factor: only use 80% of estimated bandwidth
const usableBandwidth = estimatedBandwidthBps * 0.8;
// Find the highest rendition that fits within usable bandwidth
let targetIndex = 0;
for (let i = renditions.length - 1; i >= 0; i--) {
const totalBitrate = renditions[i].videoBitrate + renditions[i].audioBitrate;
if (totalBitrate <= usableBandwidth) {
targetIndex = i;
break;
}
}
// Buffer-based stability: do not upgrade if buffer is thin
if (bufferLevelSeconds < 8 && targetIndex > state.currentRenditionIndex) {
return state.currentRenditionIndex; // hold current quality
}
// Prevent more than one step up per decision to avoid oscillation
if (targetIndex > state.currentRenditionIndex + 1) {
return state.currentRenditionIndex + 1;
}
return targetIndex;
}
The one-step-up rule prevents quality oscillation under variable networks, which is subjectively worse than steady lower quality even if the average quality would be higher.
Live vs VOD: The Key Differences
The pipeline is structurally similar but the operational constraints diverge sharply.
| Dimension | VOD | Live |
|---|---|---|
| Manifest type | Static, cached at edge | Dynamic, regenerated per segment |
| Latency target | Seconds to minutes (first play) | 2-30 seconds end-to-end |
| Segment duration | 6-10 seconds (efficiency) | 2-4 seconds (latency) |
| Error recovery | Retry with long timeout | Must continue from next keyframe |
| Transcoding deadline | Minutes to hours post-upload | Under 1 segment duration |
| DVR/seek | Full asset, any position | Sliding window or full archive |
| Origin shield pressure | Low (content stabilizes) | High (all viewers polling frequently) |
One consequence: live transcoding workers must emit segments before the next segment arrives. A 2-second segment at the ingest means you have under 2 seconds to transcode and write. This forces hardware-accelerated encoding (NVENC, Quick Sync) for live workloads, whereas VOD can use software encoders (libx264, libsvtav1) which are slower but produce better quality-per-bit.
DRM Integration Points
DRM (digital rights management) slots into the pipeline after transcoding, before CDN delivery. The two dominant systems are Widevine (Google, used in Chrome/Android) and FairPlay (Apple, used in Safari/iOS). A production platform typically implements both via a DRM aggregator (Axinom, EZDRM, Pallycon) that handles key management and license issuance.
The integration points:
- Key generation: at packaging time, generate a content key per asset (or per asset tier). Store the key in a key management service, not in your application database.
- Segment encryption: the packager encrypts each segment using AES-128 (common-encryption / CENC standard). The encryption key ID is embedded in the manifest’s
#EXT-X-KEYtag (HLS) or the MPD’sContentProtectionelement (DASH). - License delivery: when the player encounters a DRM-protected manifest, it sends a license request containing a challenge to your license proxy. The proxy validates the viewer’s entitlement, then forwards to the DRM provider to get a license that the player decrypts and uses to decrypt segments.
- CDN signed URLs: for segments, use short-lived signed URLs (15-60 minutes) so that redistributing a URL does not grant indefinite access.
DRM does not protect against screen capture, but it raises the cost of piracy enough to satisfy most content licensing requirements.
Cost and Latency Tradeoffs
The cost structure of video streaming is dominated by three things: storage, transcoding compute, and CDN egress.
Storage. A 1-hour video at 8 renditions averages roughly 15-20 GB of segment data. At $0.023/GB/month (S3 standard), that is under $0.50/month per hour of content. At scale this adds up, but storage is rarely the constraint. Use storage lifecycle policies: keep the top 4-5 renditions in hot storage, archive the rest to infrequent access tiers until demand materializes.
Transcoding. A 1-hour video at 8 renditions using software encoding on a c5.4xlarge takes roughly 2-4 hours of CPU time. At $0.68/hr, that is $1.36-2.72 per hour of content. GPU-accelerated encoding (g4dn) runs faster but at higher instance cost. The break-even depends on your content volume and latency SLA.
CDN egress. At $0.085/GB (typical CDN pricing), a 1080p stream at 5 Mbps costs roughly $2.30/viewer-hour. This is the dominant cost at scale. Strategies to reduce it: compress aggressively (AV1 or HEVC for capable clients), cache hit ratio optimization via consistent segment URLs, and negotiating committed-use CDN pricing once volume is predictable.
The live latency ladder. Every optimization that reduces latency increases cost or complexity:
| Latency Target | Approach | Added Complexity |
|---|---|---|
| 30 seconds | Standard HLS/DASH, 6-10s segments | Minimal |
| 10 seconds | 2-4s segments, reduced manifest TTL | Higher CDN poll pressure |
| 5 seconds | Low-latency HLS (LL-HLS), chunked transfer | Significant player work |
| Under 2 seconds | WebRTC, custom UDP transport | Requires CDN with WS/WebRTC support |
Most platforms sit in the 5-30 second range. Sub-2-second latency requires a fundamentally different delivery stack (WebRTC SFU rather than HLS CDN) and is only justified for use cases like live auctions or interactive broadcasts.
Where Things Break in Production
A few failure modes that are not obvious until you operate this at scale:
Segment misalignment. If a worker crashes mid-transcode and is retried with a different GOP boundary, the segments from that rendition will not align with others. Players switching renditions will stall. The fix: transcode all renditions from the same GOP-split source, not independently.
Manifest staleness under CDN misconfiguration. If a live media playlist is cached too aggressively at the edge, viewers will replay the same segments indefinitely. Always set explicit Cache-Control headers on manifests and verify them at the CDN layer, not just the origin.
ABR oscillation on mobile networks. Mobile connections have high variance. An ABR that responds too quickly to bandwidth changes will produce visible quality switching every few seconds, which is worse UX than a stable lower quality. Tune the switching window and add hysteresis to the upswitch threshold.
Origin overload on viral spikes. Without an origin shield, a viral live event generates one CDN cache miss per PoP per segment, multiplied by the polling interval. At 2-second segments, a live event with viewers spread across 100 CDN PoPs generates 50 requests/second to origin from the CDN alone. An origin shield collapses this to 1-2 requests/second to origin.
The Design in One View
The full pipeline: upload (chunked, presigned) to raw object storage, probe the container, enqueue transcoding jobs by rendition, workers produce GOP-aligned segments into output storage, packager generates HLS/DASH manifests, origin shield caches segment tier, CDN edge serves players globally, and each player runs an ABR algorithm against the buffer state and bandwidth estimates it measures on every segment download.
The pieces are individually well-understood. The challenge is operating them reliably together: keeping manifests consistent, segment boundaries aligned, CDN configuration correct, DRM licenses responsive, and ABR algorithms tuned for your content type and audience network conditions. Each layer has its own failure mode, and the interactions between them are where production incidents happen.
Build each stage with explicit handoff contracts: structured job payloads, verified segment alignment before publishing manifests, and CDN cache behavior tested with synthetic probes before live traffic depends on it.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.