Design YouTube — Video Upload, Transcoding, and CDN Delivery at Scale
A system design walkthrough of YouTube: resumable upload, the transcoding pipeline, blob storage, CDN delivery, and adaptive bitrate streaming at scale.
- Author
- Randhir Jassal
- Published
- Reading time
- 14 min read
- Views
- 10 views
"Design YouTube" is where two systems live in one product, pulling in opposite directions. The write side is a brutal batch-processing problem: someone uploads a multi-gigabyte file, and you have to turn it into a dozen resolutions and formats without melting your servers. The read side is a caching problem at planetary scale: billions of people press play and expect video to start in under a second, anywhere on Earth. The interview is about keeping those two apart — a queue-and-worker transcoding pipeline for the write, and a CDN for the read — so neither drowns the other.
A video platform ingests uploads, transcodes each into many streamable renditions, stores them, and delivers them to a global audience with adaptive quality. It is massively read-heavy (views dwarf uploads) and compute-heavy on write (transcoding is expensive). The whole design is the two halves and the blob store between them: an async transcoding pipeline feeding object storage, fronted by a CDN that absorbs essentially all the read traffic. (New to the method? Start with the framework.)
TL;DR — the design at a glance
| Concern | Decision |
|---|---|
| Upload | Resumable, chunked upload straight to blob storage |
| Processing | Transcoding pipeline — queue + stateless worker fleet, chunk → transcode → package |
| Storage | Object/blob storage for raw + renditions (petabyte scale, tiered) |
| Delivery | CDN at the edge — the origin sees only cache misses |
| Playback | Adaptive bitrate (HLS/DASH) — the player picks quality by bandwidth |
| View counts | Approximate + aggregated — never a DB increment per view |
- It's two systems: a compute-heavy async write pipeline and a cache-heavy global read path. Keep them apart.
- Uploads are huge — resumable, chunked, straight to blob storage; a flaky connection resumes, not restarts.
- Transcoding is the write core: chunk the video, transcode segments in parallel across a worker fleet, package into HLS/DASH. It's a queue-driven DAG of jobs.
- Store raw + renditions in blob storage; tier cold videos to cheap storage.
- A CDN carries the reads — views hugely outnumber uploads, so serve segments from the edge; the origin is hit only on a miss.
- Adaptive bitrate: the video is packaged into short segments at many bitrates; the player switches quality per segment based on bandwidth. The server just serves static files.
- View counts are approximate — aggregate and flush; a per-view DB increment is a hot-row disaster.
The one mental shift: you are building two systems that share a blob store — a batch transcoding pipeline (write) and a CDN-fronted static delivery path (read) — and the entire trick is keeping them decoupled. Uploads never touch the read path; playback never touches the transcoders. Get that separation and "design YouTube" becomes "design a queue-and-workers pipeline" plus "put a CDN in front of a blob store."
Step 1 — Requirements & scope
Functional:
- Upload a video (large files, unreliable networks).
- Transcode it into multiple resolutions/formats.
- Store it durably.
- Stream it to viewers with adaptive bitrate.
- Track view counts and metadata.
Parked: recommendations, comments, monetization, live streaming, DRM specifics.
Non-functional:
- Durable — never lose an uploaded video.
- Massively read-heavy — views ≫ uploads; global low-latency playback.
- Scalable transcoding — huge, bursty compute.
- Available — playback must not depend on the transcoding tier.
- Eventually consistent where it's cheap (view counts, "processing" state).
Step 2 — Back-of-envelope estimates
The two sides scale very differently:
Uploads: ~1M videos/day, each raw file tens of MB–GBs → petabytes of storage, huge transcode compute
Views: billions/day → ~tens of thousands of segment fetches/sec, but served from the CDN edge
Read:write ratio: enormous → the CDN, not the origin, carries the traffic
Two conclusions: storage and transcode compute dominate the write side (blob storage + a big worker fleet), and the CDN is not optional on the read side — it's the only way billions of views don't vaporize your origin.
Step 3 — API
POST /videos { title, ... } -> { videoId, uploadUrl } (resumable)
PUT <uploadUrl> (chunked upload straight to blob storage)
# upload completion triggers the transcoding pipeline
GET /videos/{id} -> metadata + status (processing | ready)
GET /videos/{id}/manifest.m3u8 -> HLS/DASH manifest (list of renditions + segments)
GET <cdn>/segments/... -> static video segments (served from the edge)
The client uploads directly to blob storage via a pre-signed URL (the app server never proxies gigabytes), and playback is just fetching a manifest then static segments from the CDN.
Step 4 — Data model
videos videoId, uploaderId, title, status (uploading|processing|ready|failed), createdAt
renditions videoId, resolution, bitrate, format, blobKey (the transcoded outputs)
view_counts videoId, approxCount (aggregated, eventually consistent)
# Blob storage: raw upload + all renditions + packaged HLS/DASH segments
Metadata is small and relational; the video bytes live in blob storage. A video is processing until its renditions exist, then ready.
Step 5 — High-level architecture
Uploader ──▶ [ Upload service ] ══ resumable, chunked ══▶ [ Blob: raw upload ]
│ event: uploaded
▼
[ Transcoding pipeline · queue + worker fleet ]
chunk → transcode (144p…4K, multi-codec) → package (HLS/DASH)
│
▼
[ Blob: renditions ] ──▶ [ CDN edge ] ──▶ Viewers
[ Metadata DB ] [ View counter (aggregated) ]
The write pipeline (upload → transcode → store) and the read path (CDN → viewer) meet only at the blob store. Playback never waits on a transcoder; uploads never touch the CDN.
Step 6 — Deep-dive: the hard parts
Resumable, chunked upload
A multi-gigabyte upload over mobile will drop mid-transfer. A single POST that restarts from zero is unusable. Use resumable, chunked upload: the client uploads fixed-size chunks (with offsets) directly to blob storage via a pre-signed URL, and a dropped connection resumes from the last good chunk. The app server issues the URL and reacts to the completion event — it never proxies the bytes.
The transcoding pipeline — the write core
A raw upload must become many renditions (144p→4K) across codecs, packaged for streaming. This is a big, parallel, async job:
- Chunk the raw video into segments, so segments transcode independently.
- Transcode each segment to each target resolution/bitrate — an embarrassingly parallel fan-out across a stateless worker fleet (this is where the compute goes).
- Package the transcoded segments into HLS/DASH format + a manifest.
It's a DAG of jobs driven by a message queue: workers pull segment-transcode tasks, scale horizontally with queue depth, and are idempotent and retried (a failed segment re-runs; the pipeline dead-letters what won't). This is the notification-system enqueue-and-workers pattern, scaled to CPU-heavy jobs.
Adaptive bitrate streaming (HLS/DASH)
The packaged video is a set of short segments at multiple bitrates plus a manifest listing them. The player downloads the manifest, starts at a modest bitrate, and switches up or down per segment based on measured bandwidth — 4K on fast wifi, 240p on a weak signal, adjusting continuously.
Player reads the manifest, then picks a rendition per segment by bandwidth:
4K ████████████ ~25 Mbps <- fast wifi
1080p ████████ ~8 Mbps
720p █████ ~5 Mbps
480p ███ ~2.5 Mbps
240p █ ~0.7 Mbps <- weak mobile
switches up/down per segment as bandwidth changes
The crucial part: the server serves static segments — all the adaptation logic lives in the player. That's what makes delivery a pure CDN problem.
CDN delivery — the read path
Views dwarf uploads, and viewers are global. Serve every segment from a CDN: edge caches near the viewer hold popular content, so the origin (blob store) is hit only on a cache miss. This is what makes playback start fast worldwide and keeps billions of views from ever reaching your origin. The CDN is the read architecture.
View counts at scale
Billions of views can't each UPDATE views SET count = count + 1 — that's a hot-row meltdown. Aggregate instead: buffer increments (in Redis or a stream), flush periodic batches to the metadata store, and accept approximate, eventually consistent counts. Nobody needs the view count exact to the second.
Step 7 — Bottlenecks & scaling
- Transcode compute is the write bottleneck — a big worker fleet autoscaled by queue depth, often on cheap spot/preemptible instances (jobs are idempotent, so eviction is fine).
- CDN absorbs the read load; pre-warm/cache popular videos at the edge.
- Storage is petabyte-scale — tier cold, rarely-watched videos to cheap storage.
- Pipeline resilience — idempotent jobs, retries, a DLQ for un-transcodable uploads.
- View counting via aggregation, never per-view writes.
Step 8 — Trade-offs & wrap-up
- Transcode-on-upload vs on-demand: pre-transcoding every rendition costs storage but gives instant playback; transcoding on first view saves storage but adds latency. Popular platforms pre-transcode common formats and lazily do rare ones.
- HLS vs DASH: both are adaptive segment+manifest formats (HLS is Apple's, DASH is open); package both or use CMAF to share segments.
- Exact vs approximate view counts: approximate scales; exact doesn't. Choose approximate.
- CDN cost vs origin load: the CDN costs money but is far cheaper than an origin trying to serve global video.
- Consistency: a just-uploaded video is
processinguntil renditions exist — eventual by design.
The design checklist
- Resumable, chunked upload direct to blob storage (pre-signed URL).
- Transcoding pipeline: queue + stateless worker fleet, chunk → transcode → package.
- Idempotent, retried jobs; DLQ for failures; autoscale on queue depth.
- Blob storage for raw + renditions; tier cold content.
- CDN in front — origin sees only misses.
- Adaptive bitrate (HLS/DASH); server serves static segments.
- Approximate, aggregated view counts.
The honest stuff: caveats and when it's overkill
- A few videos don't need a transcoding fleet. For a handful of uploads, a managed service (Mux, Cloudflare Stream, AWS MediaConvert) does all of this for you. Build the pipeline only at real scale.
- Never proxy the bytes through your app server. Uploading and streaming gigabytes through your API tier will fall over. The client talks to blob storage directly; your server orchestrates.
- Per-view DB increments are a hot-row outage. A popular video would serialize every view on one row. Aggregate and approximate — exactness here is a trap.
- Playback must not depend on the transcoders. If the read path touches the transcoding tier, a processing backlog becomes a playback outage. The blob store + CDN is the only thing playback needs.
- Transcoding is your biggest bill. It's CPU-heavy and bursty — autoscale hard, use spot instances, and don't transcode resolutions nobody watches.
- Without a CDN, global video is impossible. Serving billions of segments from one region is both slow for distant viewers and fatal for the origin. The CDN isn't an optimization; it's the architecture.
- Storage grows forever. Every upload lives across multiple renditions, permanently. Tiering and lifecycle policies aren't optional at petabyte scale.
In production at Mattrx
Mattrx isn't a video site, but its report generation runs on the exact same shape: a heavy artifact produced by an async worker fleet, stored in blob storage, and delivered from a CDN. Marketers export branded PDF reports of campaign performance — Mattrx renders about 1.2 million of them every 48 hours with PuppeteerSharp. The first version generated the PDF synchronously inside the request: a big report took 30–60 seconds, tied up a web worker, timed out under load, and was streamed back through the app server. We rebuilt it as this design — enqueue the job on Azure Service Bus, render it on a worker fleet, drop the PDF in Azure Blob Storage, and serve it from Azure Front Door (CDN) via a signed URL — the same upload-off-the-request-path, transcode-on-workers, deliver-from-the-edge pipeline YouTube uses for video.
| Metric | Before | After |
|---|---|---|
| Report request | Blocked 30–60s in the request (timeouts under load) | 202 Accepted, enqueue p95 ~90 ms |
| Generation | Synchronous, tied up a web worker | Async worker fleet (PuppeteerSharp) |
| Throughput | Capped by the web tier | ~1.2M reports / 48h |
| Delivery | Streamed through the app server | Azure Front Door CDN (signed URL, edge) |
| API write-path p95 | Dragged down by report load | 120 ms (fully decoupled) |
| Worker cost | Baseline | ~$1,300/mo saved (right-sized async fleet) |
The request path stopped generating anything — it just enqueues and hands back a job id — and the finished PDF is served from the CDN edge, exactly like a transcoded video segment, so a spike in report demand can't touch the interactive API.
The architecture at Mattrx
React dashboard ── "Export report" ──▶ [ Azure App Service · .NET 9 API ] ── enqueue ──▶ [ Azure Service Bus ]
│
▼
[ Worker fleet · PuppeteerSharp PDF render ]
│ upload
▼
[ Azure Blob Storage ] ──▶ [ Azure Front Door · CDN ] ──▶ download
Production implementation (Mattrx)
Here is the core of Mattrx's report pipeline on ASP.NET Core / .NET 9: the API enqueues a job and returns immediately, and a worker renders the PDF with PuppeteerSharp, stores it in Azure Blob Storage, and exposes it through the CDN via a time-limited signed URL — the same enqueue → process-on-workers → blob → CDN shape as video transcoding and delivery.
using Azure.Messaging.ServiceBus;
using Azure.Storage.Blobs;
using Azure.Storage.Sas;
using PuppeteerSharp;
namespace Mattrx.Reports;
public sealed record ReportCommand(Guid JobId, Guid TenantId, Guid CampaignId, string Format);
// ---------- API: enqueue and return a job id (nothing heavy on the request path) ----------
public sealed class ReportRequestService(ServiceBusSender sender, IReportStore reports)
{
public async Task<Guid> RequestAsync(Guid tenantId, Guid campaignId, CancellationToken ct)
{
var jobId = Guid.NewGuid();
await reports.CreatePendingAsync(jobId, tenantId, campaignId, ct);
await sender.SendMessageAsync(new ServiceBusMessage(
BinaryData.FromObjectAsJson(new ReportCommand(jobId, tenantId, campaignId, "pdf")))
{
MessageId = jobId.ToString(), // dedupe: a redelivered command renders once
Subject = "report.generate",
}, ct);
return jobId; // caller polls /reports/{jobId} or is notified when it flips to ready
}
}
// ---------- Worker: render (PuppeteerSharp) -> Blob -> CDN signed URL ----------
public sealed class ReportWorker(
IBrowser browser,
BlobContainerClient container,
IReportStore reports)
{
public async Task GenerateAsync(ReportCommand cmd, CancellationToken ct)
{
// 1) Render the report page to a PDF on the worker fleet, off the request path.
await using var page = await browser.NewPageAsync();
await page.GoToAsync(ReportUrl(cmd.TenantId, cmd.CampaignId),
new NavigationOptions { WaitUntil = [WaitUntilNavigation.Networkidle0] });
byte[] pdf = await page.PdfDataAsync(new PdfOptions { Format = PaperFormat.A4, PrintBackground = true });
// 2) Store the artifact in blob storage (the "renditions" store).
var blob = container.GetBlobClient($"{cmd.TenantId:N}/{cmd.JobId:N}.pdf");
await blob.UploadAsync(BinaryData.FromBytes(pdf), overwrite: true, ct);
// 3) Expose it through the CDN via a time-limited signed URL — the app never serves the bytes.
var sasUri = blob.GenerateSasUri(BlobSasPermissions.Read, DateTimeOffset.UtcNow.AddHours(24));
var cdnUrl = new UriBuilder(sasUri) { Host = "cdn.mattrx.co" }.Uri; // Front Door edge
await reports.MarkReadyAsync(cmd.JobId, cdnUrl, ct);
}
private static string ReportUrl(Guid tenantId, Guid campaignId) =>
$"https://render.internal.mattrx.co/report?tenant={tenantId:N}&campaign={campaignId:N}";
}
The API does no rendering — it writes a pending row, drops a command on Service Bus, and returns a job id in ~90 ms; the worker fleet does the expensive PuppeteerSharp render and stores the PDF in Blob Storage; and delivery is a signed URL fronted by Azure Front Door, so the finished report streams from the edge and a burst of exports can never drag down the interactive API — the same decoupling that keeps YouTube's playback independent of its transcoders.
The model to carry forward
A video platform is two decoupled systems sharing a blob store. The write side is a queue-and-worker batch pipeline — resumable upload, parallel transcoding across a stateless fleet, packaged into adaptive segments — and the read side is a CDN in front of static files that carries essentially all the traffic. Keep them apart: playback depends only on the blob store and the CDN, never on the transcoders; uploads never touch the read path. Say "transcoding pipeline for the write, CDN for the read, and they only meet at storage," and the whole design — upload, renditions, adaptive bitrate, view counts — hangs off that separation.
Three habits this problem teaches:
- Split the write pipeline from the read path. Batch-process on workers; serve static from a CDN; let them meet only at the blob store.
- Never move big bytes through your app server. Clients talk to storage directly; your service orchestrates and hands out URLs.
- Approximate what's expensive to be exact. View counts are aggregated and eventually consistent — precision here buys nothing and costs everything.
Further reading
- How to Crack Any System Design Interview: A Repeatable Framework
- Design a Notification System — Push, SMS & Email at Scale
- We Replaced REST with Kafka and Cut Failures by 90%
That wraps the System Design Interview series — nine "Design X" walkthroughs on one framework. Prepping for a round and want any of them drilled live? I'm always happy to help; reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.