19 min read

Phase 5 · Chapter 3

System Design: YouTube

When the bottleneck is not your database — it's physics, bandwidth, and CPU


What this chapter does to you: YouTube is a different category of problem from URL Shortener and Twitter. The bottleneck is not reads per second or fan-out writes. The bottleneck is raw bytes — terabytes of video moving across the planet every second, and the CPU cost of transforming those bytes before they can move. After this chapter, you will understand video transcoding pipelines, adaptive bitrate streaming, CDN architecture, and why a single viral video can serve a billion views without a single request hitting YouTube's origin servers.


Step 1: Clarify Requirements

Functional requirements:

  • Users can upload videos
  • Videos are processed and available for streaming within minutes of upload
  • Users can watch videos at multiple quality levels (360p, 720p, 1080p, 4K)
  • Video quality adjusts automatically based on the viewer's connection speed
  • Users can like, comment, subscribe
  • Users get a recommended feed of videos
  • Search for videos

Non-functional requirements:

  • 500 hours of video uploaded every minute (real YouTube stat)
  • 1 billion hours of video watched per day
  • Videos must be globally accessible with low latency
  • Upload must be reliable — a network hiccup should not restart the entire upload
  • Viewing must be smooth — buffering is the enemy

What's out of scope (confirm in interview):

  • Recommendation algorithm internals (ML system — separate discipline)
  • Live streaming (different architecture)
  • YouTube Shorts (similar but different processing pipeline)

The single most important insight from these requirements:

"500 hours uploaded per minute" means your system must process video faster than it arrives. If processing takes longer than upload, you build an unbounded backlog. This constraint alone defines the entire architecture.


Step 2: Estimate Scale

Upload volume:

  • 500 hours of video per minute = 8.3 hours/second
  • Average raw video: 1 hour of 1080p ≈ 2 GB uncompressed
  • 8.3 hours/sec × 2 GB = ~16.6 GB of raw video per second arriving
  • Per day: 16.6 GB/s × 86,400 = ~1.4 PB of raw video per day

Processed output (multiple resolutions per video):

  • Each uploaded video gets transcoded into: 360p, 480p, 720p, 1080p, 2160p (4K)
  • Compression (H.264/H.265) reduces size dramatically
  • 1 hour of 1080p H.264 ≈ 8 GB (from 2 GB raw — but you're actually re-encoding)
  • 5 resolutions per video ≈ 15-25 GB of processed output per hour of input
  • Per day: ~20 PB of processed video stored

Viewing volume:

  • 1 billion hours watched/day = ~11.6 million hours/second
  • Average stream: 720p ≈ 1 Mbps
  • 11.6M hours/sec × 1 Mbps = 11.6 Tbps of outbound bandwidth
  • No single company's origin servers can serve 11.6 terabits per second.
  • This single number tells you the entire CDN strategy before you've drawn a box.

What the estimates tell you:

  1. Video processing is CPU-bound, not I/O-bound. You need massive parallel CPU.
  2. Storage is in petabytes — object storage (S3), not Postgres.
  3. Serving is a CDN problem, not an origin server problem.
  4. The database stores metadata, not video. Video never touches your DB.

Step 3: What Video Actually Is — The Physical Reality

Before designing the system, you must understand what you're dealing with.

Raw Video

A video file is a sequence of frames — images displayed in rapid succession. 30fps video = 30 still images per second. 1920×1080 pixels each. Each pixel = 3 bytes (RGB) = ~6 MB per frame uncompressed. 30 frames/sec × 6 MB = 180 MB/second of raw video data. 1 hour = 180 MB/s × 3600 = 648 GB of raw video for 1 hour.

Obviously you cannot store or stream 648 GB per hour. Video codecs compress this.

Video Codecs — Compression

A codec (coder-decoder) compresses video by exploiting two facts:

Fact 1: Adjacent frames are similar. If you're filming someone talking, the background doesn't change between frames. Instead of storing the full frame 2, store only what changed from frame 1. This is called inter-frame compression (P-frames and B-frames in H.264).

Fact 2: Human eyes are less sensitive to color detail than brightness. You can reduce color resolution by 2× without most people noticing. This is called chroma subsampling (4:2:0 in most video).

Combined, H.264 compresses 648 GB/hour to ~8 GB/hour at 1080p — 80× compression. H.265 (HEVC) achieves similar quality at ~4 GB/hour — 160× compression. AV1 (Google's codec, used on YouTube) achieves ~2-3 GB/hour — 200× compression.

The codec tradeoff: Better compression = more CPU to encode and decode. H.264 is universal — every device supports hardware decoding. AV1 is 30-50% more efficient than H.264 but requires newer hardware for decode. YouTube uses AV1 for desktop/newer mobile, H.264 as fallback.

Container Formats

A codec compresses the video stream. A container packages video + audio + metadata.

Common containers: MP4 (H.264/H.265), WebM (VP9/AV1), MKV (anything).

YouTube accepts almost any input format and normalizes everything internally. Output to viewers is MP4 or WebM depending on browser support.

Adaptive Bitrate Streaming (ABR) — The Most Important Concept

You've experienced this: you start a YouTube video, it plays in 360p, then switches to 1080p as your connection proves itself. This is Adaptive Bitrate Streaming.

How it works:

Each video is encoded into multiple versions (called renditions):

code
360p  — 400 kbps  — for slow connections
480p  — 800 kbps  — for mobile
720p  — 1.5 Mbps  — standard HD
1080p — 4 Mbps    — full HD
4K    — 15 Mbps   — for fast connections

Each rendition is then split into small segments (2-4 seconds each).

A manifest file (.m3u8 for HLS, .mpd for DASH) lists all available renditions and their segment URLs:

code
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=640x360
https://cdn.youtube.com/video/abc/360p/segment_%d.ts
#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720
https://cdn.youtube.com/video/abc/720p/segment_%d.ts
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1920x1080
https://cdn.youtube.com/video/abc/1080p/segment_%d.ts

The video player:

  1. Downloads the manifest file
  2. Measures current download speed
  3. Picks the highest rendition that fits within available bandwidth
  4. Downloads segments one by one
  5. After each segment, re-measures bandwidth and switches rendition if needed

Why segments matter:

If you encoded the entire video as one file, switching quality mid-video would require seeking in the file and re-downloading from the new position. With 2-4 second segments, the player just fetches the next segment from a different quality folder. Seamless switch at every segment boundary.

This is why YouTube can switch from 360p to 1080p mid-video without buffering — it was already playing segments. The next segment just comes from a different URL.


Step 4: The Upload Pipeline

This is where most candidates get it wrong. They say "user uploads video to server." But a 2 GB video upload to a single server has fatal problems:

  • Network failure at 1.9 GB = start over from zero
  • One server handling all uploads = single point of failure
  • Processing on the same server as upload = resource contention

The correct design: separate upload from processing, and make upload resumable.

Resumable Upload — Chunk-Based

This is the resumable upload protocol (Google's TUS protocol or similar). The server tracks bytes received per session. Clients can resume from any interruption without re-uploading completed chunks.

Implementation:

javascript
// Initiate upload
app.post('/api/upload/init', async (req, res) => {
  const { filename, fileSize, contentType } = req.body
  const uploadId = generateId()

  await redis.hset(`upload:${uploadId}`, {
    filename,
    fileSize,
    bytesReceived: 0,
    status: 'uploading'
  })

  res.json({ uploadId, uploadUrl: `/api/upload/${uploadId}` })
})

// Receive chunk
app.patch('/api/upload/:uploadId', async (req, res) => {
  const { uploadId } = req.params
  const { rangeStart, rangeEnd } = parseRangeHeader(req.headers['content-range'])

  // Write chunk to object storage (S3/R2) at the correct byte offset
  await objectStorage.uploadPart(uploadId, rangeStart, req.body)

  await redis.hset(`upload:${uploadId}`, 'bytesReceived', rangeEnd + 1)

  const { fileSize } = await redis.hgetall(`upload:${uploadId}`)

  if (rangeEnd + 1 >= parseInt(fileSize)) {
    // Upload complete — trigger processing pipeline
    await redis.hset(`upload:${uploadId}`, 'status', 'processing')
    await processingQueue.add('transcodeVideo', { uploadId })
    res.status(200).json({ status: 'processing' })
  } else {
    res.status(308).json({ bytesReceived: rangeEnd + 1 })  // 308 = Resume Incomplete
  }
})

Where the Raw File Goes

Raw video goes directly to object storage (S3, GCS, or Cloudflare R2). Never to your application server's disk. Why?

  • Object storage is infinitely scalable — you don't manage capacity
  • Multi-part upload is built in — each chunk uploaded independently
  • Object storage is durable — typically 11 nines (99.999999999%) durability
  • Your app servers are stateless — they can crash and restart without losing the upload
  • Object storage can generate signed URLs — clients can upload directly, bypassing your app server entirely

Direct-to-S3 upload (the production pattern):

javascript
// Client requests a pre-signed upload URL — expires in 15 minutes
app.post('/api/upload/presign', async (req, res) => {
  const key = `raw/${uploadId}/${filename}`
  const presignedUrl = await s3.getSignedUrl('putObject', {
    Bucket: 'raw-video-bucket',
    Key: key,
    Expires: 900,   // 15 minutes
    ContentType: req.body.contentType
  })
  res.json({ uploadUrl: presignedUrl, key })
})

The client uploads 2 GB directly to S3. Your app server never handles a single byte of the video. Your server's bandwidth is not the bottleneck.


Step 5: The Transcoding Pipeline — The Hard Part

A raw video arrives in S3. You must produce 5 renditions (360p through 4K). Each rendition requires CPU-intensive re-encoding.

Transcoding cost:

  • 1 hour of 1080p → 720p transcode ≈ 30 minutes on one CPU core
  • 1 hour → all 5 renditions = 150 minutes on one CPU core
  • 500 hours uploaded per minute = 750,000 CPU-minutes of transcoding per minute
  • You need 750,000 CPU cores running continuously just to keep up

This is why YouTube uses massively parallel transcoding workers.

The Transcoding Queue

javascript
// Triggered when upload completes
await processingQueue.add('transcodeVideo', {
  videoId,
  rawKey: 'raw/abc123/video.mp4',
  resolutions: ['360p', '480p', '720p', '1080p', '2160p']
})

The fan-out pattern from Chapter 4 (Phase 4 — Queues) applies here:

Don't transcode all resolutions sequentially on one worker. Fan out: one job per resolution, all run in parallel.

javascript
worker = new Worker('transcodeVideo', async (job) => {
  const { videoId, rawKey, resolutions } = job.data

  // Fan out: create one transcoding job per resolution
  await Promise.all(resolutions.map(resolution =>
    transcodingQueue.add('transcodeResolution', {
      videoId,
      rawKey,
      resolution,
      outputKey: `processed/${videoId}/${resolution}/`
    })
  ))
})

// Separate workers per resolution — run on different machines
worker = new Worker('transcodeResolution', async (job) => {
  const { rawKey, resolution, outputKey } = job.data

  // Download raw video from S3 (or stream it — don't buffer 2GB in RAM)
  // Run FFmpeg to transcode
  // Split output into 2-second segments
  // Upload segments to CDN origin

  await ffmpeg.transcode({
    input: `s3://${rawKey}`,
    output: `s3://${outputKey}`,
    resolution,
    segmentDuration: 2,
    codec: 'libx264',
    preset: 'veryfast'   // tradeoff: faster encoding, slightly larger output
  })

  // Notify completion
  await completionQueue.add('resolutionComplete', { videoId, resolution })
})

The Completion Tracking Problem

5 resolutions, 5 parallel workers. How do you know when all 5 are done so you can mark the video as available?

Pattern: distributed counter with threshold notification

javascript
worker = new Worker('resolutionComplete', async (job) => {
  const { videoId, resolution } = job.data

  // Mark this resolution as done
  await redis.sadd(`video:${videoId}:done_resolutions`, resolution)

  // Check if all resolutions complete
  const doneCount = await redis.scard(`video:${videoId}:done_resolutions`)
  const totalResolutions = 5

  if (doneCount === totalResolutions) {
    // All done — generate manifest file, mark video as available
    await generateManifest(videoId)
    await db.query(
      'UPDATE videos SET status = $1 WHERE id = $2',
      ['available', videoId]
    )
    await notifyUploader(videoId)
    redis.del(`video:${videoId}:done_resolutions`)
  }
})

This is the BullMQ Flow pattern from Chapter 4 (Phase 4), implemented manually. SADD + SCARD is the atomic way to track progress across distributed workers.

FFmpeg — The Tool Behind Every Video Platform

FFmpeg is an open-source tool that handles encoding, decoding, transcoding, filtering, and streaming of audio and video. Every major video platform uses it internally (or a wrapper around it).

bash
# Transcode a video to 720p, segment into 2-second HLS chunks
ffmpeg -i input.mp4 \
  -vf scale=-2:720 \          # scale to 720p, maintain aspect ratio
  -c:v libx264 \              # H.264 codec
  -crf 23 \                   # quality (0=best, 51=worst, 23=default)
  -preset veryfast \          # encoding speed vs compression tradeoff
  -c:a aac \                  # audio codec
  -hls_time 2 \               # 2-second segments
  -hls_segment_filename "segment_%03d.ts" \
  output.m3u8                 # manifest file

This single command is the core of your transcoding worker. The complexity is in managing it at scale: 750,000 CPU cores, error handling, retry on failure, cost optimization (spot instances), output validation.


Step 6: CDN Architecture — Serving at 11.6 Tbps

Your origin servers cannot serve 11.6 terabits per second. No company's servers can. The answer is a CDN.

What a CDN Actually Does

A CDN (Content Delivery Network) is a network of edge servers geographically distributed around the world. Mumbai, São Paulo, Lagos, Tokyo, Frankfurt — thousands of locations.

When a user in Mumbai watches a YouTube video:

code
Without CDN:
User (Mumbai) → YouTube servers (USA) → 200ms RTT each segment
Each 2-second segment request = 200ms latency before playback starts
Buffering every few segments

With CDN:
User (Mumbai) → CDN edge (Mumbai) → ~5ms RTT each segment
First request for this video: edge fetches from origin, caches locally
All subsequent requests: served from Mumbai edge, no origin involved

The CDN edge caches popular video segments. Mumbai users watching a viral Indian music video all hit the Mumbai edge — that video is fetched from origin exactly once, then served to millions of local users.

The Cache Hierarchy

Cache hit rates: Top 1% of videos = 99% of views. CDN edge caches that 1%. For the most popular videos (billion views), the CDN cache hit rate is ~99.9%. Origin serves the first copy and the rare 0.1% miss.

For the long tail (rarely watched videos): A video with 50 views will not be in any CDN cache. Every viewer request goes to origin. This is fine — 50 views is tiny traffic. The CDN optimizes the common case; origin handles the rare case.

Signed URLs for CDN Security

You can't let anyone access any video at any CDN URL — paid content, region-locked videos, private videos need access control.

javascript
// When user requests to watch video:
app.get('/api/watch/:videoId', authenticateUser, async (req, res) => {
  const video = await getVideo(req.params.videoId)

  // Check access rights
  if (!canWatch(req.user, video)) return res.status(403).json({ error: 'Forbidden' })

  // Generate signed CDN URL — expires in 1 hour
  const signedManifestUrl = cdn.signUrl({
    url: `https://cdn.youtube.com/videos/${video.id}/manifest.m3u8`,
    expires: Date.now() + 3600000,
    key: CDN_SIGNING_KEY
  })

  res.json({ manifestUrl: signedManifestUrl })
})

The CDN verifies the signature on every request. Expired or tampered URLs return 403. You sign the manifest URL once — the player uses it to fetch all segments, but each segment URL is derived from the signed manifest, inheriting its validity.


Step 7: The Data Model

Videos Table

sql
CREATE TABLE videos (
  id              BIGINT PRIMARY KEY,   -- Snowflake ID (from Chapter 2)
  uploader_id     BIGINT NOT NULL,
  title           VARCHAR(100) NOT NULL,
  description     TEXT,
  status          VARCHAR(20) DEFAULT 'processing',
                  -- 'processing' | 'available' | 'deleted' | 'rejected'
  duration_sec    INT,
  thumbnail_url   TEXT,                 -- CDN URL to thumbnail image
  raw_key         TEXT,                 -- S3 key for raw upload
  view_count      BIGINT DEFAULT 0,     -- denormalized counter
  like_count      BIGINT DEFAULT 0,
  created_at      TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_videos_uploader ON videos(uploader_id, created_at DESC);
CREATE INDEX idx_videos_status ON videos(status) WHERE status = 'available';

Video Renditions Table

sql
CREATE TABLE video_renditions (
  video_id       BIGINT NOT NULL,
  resolution     VARCHAR(10) NOT NULL,  -- '360p', '720p', '1080p', etc.
  manifest_url   TEXT NOT NULL,         -- CDN URL to .m3u8 file
  file_size_mb   INT,
  bitrate_kbps   INT,
  codec          VARCHAR(20),           -- 'h264', 'av1'
  PRIMARY KEY (video_id, resolution)
);

Comments — A Deep Tree Problem

Comments on YouTube have replies. Replies can have replies (one level in YouTube's case). This is a tree structure stored in a relational DB.

sql
CREATE TABLE comments (
  id            BIGSERIAL PRIMARY KEY,
  video_id      BIGINT NOT NULL,
  user_id       BIGINT NOT NULL,
  parent_id     BIGINT,                 -- null = top-level comment
  content       TEXT NOT NULL,
  like_count    INT DEFAULT 0,
  created_at    TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_comments_video ON comments(video_id, created_at DESC);
CREATE INDEX idx_comments_parent ON comments(parent_id);

Top-level comments: WHERE video_id = $1 AND parent_id IS NULL ORDER BY like_count DESC Replies to a comment: WHERE parent_id = $1 ORDER BY created_at ASC


Step 8: View Counts — The High-Frequency Counter at Extreme Scale

A viral video gets 1 million concurrent viewers. If each view increments view_count in Postgres:

  • 1M UPDATE videos SET view_count = view_count + 1 per second
  • On one row
  • All 1M updates compete for the same row lock
  • Complete deadlock. Nothing works.

This is the same problem as the like counter in Chapter 2 (Twitter), but 1,000× worse because concurrent viewers are all active simultaneously.

Solution: Distributed Counter in Redis + Periodic Flush

Exact same write-behind pattern from Twitter, but with one critical addition — sharding the counter to eliminate the single-key hotspot:

javascript
// Instead of one key per video, use N shards
const SHARD_COUNT = 100

async function recordView(videoId) {
  const shard = Math.floor(Math.random() * SHARD_COUNT)
  const key = `views:${videoId}:shard:${shard}`
  await redis.incr(key)
}

// Periodic aggregation job (every 30 seconds):
async function aggregateViews(videoId) {
  let total = 0
  const pipeline = redis.pipeline()

  for (let i = 0; i < SHARD_COUNT; i++) {
    pipeline.getdel(`views:${videoId}:shard:${i}`)   // get and delete atomically
  }

  const results = await pipeline.exec()
  total = results.reduce((sum, [err, val]) => sum + (parseInt(val) || 0), 0)

  if (total > 0) {
    await db.query(
      'UPDATE videos SET view_count = view_count + $1 WHERE id = $2',
      [total, videoId]
    )
  }
}

Why sharding? One Redis key for view counts = one key gets 1M INCR per second. Redis is single-threaded. Even at 100k ops/second capacity, 1M ops/second on one key = queue depth grows unboundedly.

100 shards = 10,000 INCR per shard per second. Well within Redis capacity. Aggregation combines them every 30 seconds. DB sees 1 update per 30 seconds per video.

The consistency tradeoff: View count might be 30 seconds stale. For YouTube, this is completely acceptable. View count is not used for financial decisions — it's a social signal.


Step 9: Video Recommendations — What You Need to Know for Interviews

You don't need to design the ML model. You need to design the serving layer.

The ML model produces recommendations offline (batch job, runs every few hours). Output: for each user, a ranked list of video IDs.

code
user:123 → [videoId:456, videoId:789, videoId:012, ...]  (ranked by predicted engagement)

This is stored in a fast key-value store (Redis or a specialized feature store):

javascript
// ML pipeline writes recommendations
await redis.zadd(`recommendations:${userId}`,
  { score: 0.95, member: 'video:456' },   // higher score = higher rank
  { score: 0.87, member: 'video:789' },
  // ...
)

// Feed API reads them
async function getRecommendedFeed(userId) {
  const videoIds = await redis.zrevrange(`recommendations:${userId}`, 0, 19)
  return Promise.all(videoIds.map(id => getVideoMetadata(id)))
}

Real-time signals (user just watched X, so show Y) are handled by a streaming pipeline (Kafka → feature computation → real-time recommendation update). This is out of scope but worth naming in an interview.


Step 10: The Complete Architecture


Step 11: Decisions Table

DecisionWhy
Direct-to-S3 uploadApp server never touches video bytes. Resumable. Scalable.
Chunk-based resumable upload2 GB upload + one network drop = complete loss without it
Separate processing queueUpload completes instantly, processing happens async
Fan-out per resolution5 resolutions in parallel = 5× faster than sequential
HLS with 2-second segmentsEnables adaptive bitrate switching at every segment boundary
CDN for video delivery11.6 Tbps cannot come from origin. CDN is the only answer.
Signed CDN URLsAccess control without hitting origin on every request
Sharded view counter1M concurrent viewers can't all hit one Redis key
Write-behind for countersDB cannot absorb 1M view-count updates/second
Snowflake IDs for videosTime-sortable, globally unique, no DB coordination needed
Postgres for metadata onlyVideo bytes never touch DB. Petabytes in object storage.

Practice Problems

Problem 1 (The Codec Tradeoff): YouTube is moving from H.264 to AV1 encoding. AV1 produces 40% smaller files at the same quality.

For your infrastructure:

  • What changes in storage cost? (Calculate from the 20 PB/day estimate)
  • What changes in CDN bandwidth cost?
  • What is the tradeoff cost on the encoding side?
  • Which users would you roll AV1 out to first, and why?

Problem 2 (Processing Pipeline Failure): Your transcoding workers are processing a video. The 720p worker finishes successfully and uploads its segments to the CDN. The 1080p worker crashes mid-encode after processing 40 minutes of a 60-minute video.

What state is the system in? How does BullMQ handle this (refer to stall detection from Chapter 4, Phase 4)? When the 1080p worker retries, does it re-encode from the beginning or from minute 40? What would you need to implement to make it resume from minute 40?


Problem 3 (CDN Cache Invalidation): A creator uploads a video. It gets processed and served from CDN. They realize the thumbnail is wrong and update it.

The thumbnail is cached at 2,000 CDN edge nodes around the world. How do you invalidate it? What is the cost of that operation? How does this connect to the cache invalidation problem from Chapter 1, Phase 4? Is there a design that avoids the invalidation problem entirely?


Problem 4 (Scale Calculation): YouTube reports 1 billion hours watched per day at an average of 720p (1.5 Mbps).

Calculate:

  • Total outbound bandwidth in Tbps
  • If CDN cache hit rate is 99.5%, how much bandwidth hits origin servers?
  • If each CDN PoP serves a 100 Gbps uplink, how many PoPs do you need globally?
  • If a PoP costs $500,000/month, what is the monthly CDN infrastructure cost for bandwidth alone?

Problem 5 (Connect Everything): A user uploads a 2-hour 4K video on AttemptIQ (imagine you add video lectures).

Trace the entire journey:

  • Upload: which protocol, where do bytes go, what makes it resumable?
  • Processing: what queue pattern, how many workers, how do you know when done?
  • Storage: where do raw and processed files live, why not Postgres?
  • Delivery: why can't your Hetzner server serve this, what layer handles it?
  • Viewing: what happens in the browser when the video player requests quality switch?

Use exact vocabulary from every chapter in this curriculum to answer. This problem spans Phase 1 (TCP/HTTP), Phase 2 (DB design), Phase 4 (Queues, Caching), and Phase 5.


Where We Are in Phase 5

code
Phase 5 — System Design HLD
  ✅ Chapter 1: URL Shortener   (read-heavy, caching, ID generation)
  ✅ Chapter 2: Twitter Feed    (fan-out, write amplification, social graph)
  ✅ Chapter 3: YouTube         (media pipeline, CDN, adaptive streaming)
  ⬜ Chapter 4: WhatsApp        (messaging, real-time delivery, end-to-end encryption)
  ⬜ Chapter 5: Uber            (geospatial, real-time matching, surge pricing)
  ⬜ Chapter 6: Google Docs     (real-time collaboration, OT vs CRDT)
  ⬜ Chapter 7: Notifications   (fan-out at scale, multi-channel delivery)

Chapter 4 (WhatsApp) is a different beast again — the bottleneck is neither storage nor bandwidth, but real-time delivery to online/offline users, message ordering guarantees, and the single-copy delivery problem (a message must never be lost or shown twice, even when the sender retries).


End of Phase 5, Chapter 3. Estimated reading time: 80-100 minutes. Practice problems: 60-90 minutes. Do not proceed to Chapter 4 until you can answer Problem 2 completely — the transcoding worker crash and partial resume question. That problem requires you to connect FFmpeg chunking, BullMQ stall detection, and idempotency into one coherent answer. If you can reason through it cleanly, Phase 4 and Phase 5 have fused into a single mental model. That's the goal.