Command Palette

Search for a command to run...

All projects
Level 3 — Advanced HLD + LLD, capstone project

Netflix — Video Streaming at Global Scale

Every piece you've already learned, assembled into one system: ingest, transcode, catalog, CDN, playback, recommendations, resilience — with full Spring Boot code for each.

45 min read

Why this project exists

Phase 12 gave you Netflix as a one-page HLD brief — enough to survive an interview. This project is the other half: what would it actually take to BUILD the thing? Every section below reuses a concept you already studied in an earlier phase, and links straight back to it. If a piece feels unfamiliar, that's the signal to go re-read that topic — this project is the 'connect the dots' exercise the whole syllabus has been building toward.

How to use this page

Don't just read the code — trace it. Every Java class here maps to a box in the architecture diagram. Before scrolling to a deep dive, try to guess what the class needs to do and what could go wrong; then compare against what's written.

1. The Brief

We are building the two things a user actually experiences — 'upload once, transcode everywhere' for the content pipeline, and 'press play, watch instantly, never buffer' for the viewer — plus the operational spine (catalog, recommendations, resilience) that makes both possible at 260M+ subscribers.

Functional requirements

  • —Studios/partners upload a master video file; the platform must produce every resolution/bitrate variant needed for adaptive streaming.
  • —Viewers browse a catalog (search, categories, 'because you watched X') and press play on any title.
  • —Playback must adapt to the viewer's network in real time — switch quality mid-stream without stopping.
  • —The system tracks watch progress ('resume from 12:34') and viewing history per profile.
  • —Recommendations are personalized per profile, refreshed as viewing behavior changes.

Non-functional requirements — the numbers that drive every decision below

  • —260M subscribers, ~70% daily active → ~180M DAU, each streaming ~2 hours/day on average.
  • —Playback start latency: < 2 seconds from 'press play' to first frame (this is the metric Netflix is famous for optimizing).
  • —Availability: 99.99% for playback (this is the money path); 99.9% is acceptable for the upload/transcode pipeline (it's asynchronous, a few minutes of delay is invisible to viewers).
  • —Durability: master files and all transcoded variants must never be lost — multi-region replication, non-negotiable.
  • —Consistency: watch-progress and 'continue watching' must be read-your-writes per device; the catalog and recommendations are fine to be eventually consistent (Phase 13.4).

2. Capacity Math

Rough numbers, in the spirit of Phase 9.9 — round aggressively, and let every number justify a decision.

CapacityMath.mdmarkdown

The back-of-envelope math that decides 'CDN-first' before a single line of code is written.

PLAYBACK (the read path — this is 99% of the traffic)
  180M DAU x 2 hours/day streaming
  avg bitrate ~5 Mbps (mixed SD/HD/4K)
  → per-user data rate  = 5 Mbps
  → concurrent streamers at peak (~20% of DAU during peak hours) = 36M
  → PEAK EGRESS BANDWIDTH = 36M x 5 Mbps ≈ 180 Tbps

  180 Tbps cannot come from one datacenter's uplink — full stop.
  → this number alone FORCES a CDN / edge-cache architecture (Open Connect).
  → 99%+ of this bandwidth must be served from edge caches inside ISPs,
     not from Netflix's own origin. (See Phase 9.5 CDN.)

UPLOAD / TRANSCODE (the write path — tiny by comparison)
  ~1,000 new title-hours ingested per day (originals + partner content)
  each transcoded into ~10 variants (240p...4K, H.264 + AV1)
  average variant size ≈ 1.5 GB/hour of video
  → storage added per day = 1,000 x 10 x 1.5 GB ≈ 15 TB/day
  → over 5 years, with replication (x3) ≈ 82 PB
  → transcode compute: 1,000 hours x 10 variants, at roughly
    1 CPU-hour per output-hour (realistic for modern encoders)
    ≈ 10,000 CPU-hours/day → a modest, autoscaled worker fleet, NOT
    something that needs to happen in real time (this is why it's async).

CATALOG READS
  Browsing generates far more requests than playback starts:
  180M DAU x ~15 catalog/search requests/session ≈ 2.7B requests/day
  ≈ 31k requests/s average, peaked ~5x ≈ 155k/s
  → this is a classic cache-aside (Phase 10.2) + read-replica (Phase 8.1) problem,
    NOT a bandwidth problem — completely different scaling lever from playback.

3. High-Level Architecture

Two almost-independent systems share one catalog: the CONTROL PLANE (small traffic, decides WHAT to play and tracks WHO watched what) and the DATA PLANE (enormous traffic, decides HOW FAST bytes move). Conflating them is the single most common mistake in a Netflix HLD interview answer.

NetflixFullArchitecturediagram

Every box below gets its own deep-dive with real code further down this page.

Rendering diagram…

Notice: the App fetches video SEGMENTS directly from Open Connect, never through the Playback Service. The control plane's only job on the hot path is handing out a signed manifest URL — everything else is the CDN's problem. That single arrow is the whole 'why Netflix can serve 180 Tbps' answer.

4. Data Model

schema.sqlsql

The catalog side. Watch-progress deliberately lives in a SEPARATE, strongly-consistent store — see the callout below.

CREATE TABLE titles (
  id            BIGSERIAL PRIMARY KEY,
  name          TEXT NOT NULL,
  type          TEXT NOT NULL CHECK (type IN ('MOVIE','SERIES')),
  release_year  INT,
  metadata_json JSONB NOT NULL DEFAULT '{}',   -- genres, cast, synopsis — flexible, rarely queried by field
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE video_assets (                     -- one per episode/movie cut
  id            BIGSERIAL PRIMARY KEY,
  title_id      BIGINT NOT NULL REFERENCES titles(id),
  duration_secs INT NOT NULL,
  status        TEXT NOT NULL DEFAULT 'PROCESSING'
                CHECK (status IN ('PROCESSING','READY','FAILED')),
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_assets_title ON video_assets(title_id);

CREATE TABLE video_variants (                   -- the output of transcoding — one row per rendition
  id            BIGSERIAL PRIMARY KEY,
  asset_id      BIGINT NOT NULL REFERENCES video_assets(id),
  resolution    TEXT NOT NULL,                  -- '240p','480p','720p','1080p','4K'
  codec         TEXT NOT NULL,                  -- 'h264','av1'
  bitrate_kbps  INT NOT NULL,
  storage_key   TEXT NOT NULL,                  -- content-addressed key into the variant store
  UNIQUE (asset_id, resolution, codec)
);

-- WATCH PROGRESS: separate store, strongly consistent, tiny rows, insane write volume.
-- (Every few seconds of playback pings this. It must NEVER be in the same
--  database as the catalog — completely different access pattern and
--  consistency requirement. This split IS the interview-winning insight.)
CREATE TABLE watch_progress (
  profile_id    BIGINT NOT NULL,
  asset_id      BIGINT NOT NULL,
  position_secs INT NOT NULL,
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (profile_id, asset_id)             -- upsert target; last-write-wins per device is fine here
);

The mistake everyone makes

Putting watch_progress in the same normalized schema as titles/video_assets. Progress writes happen orders of magnitude more often than catalog writes, and need strong consistency (Phase 13.3) while the catalog is happy with eventual (Phase 13.4). Mixing them means either the catalog inherits progress's write load, or progress inherits the catalog's slower, more heavily-indexed table shape. Split the store by ACCESS PATTERN, not by 'they're both about videos.'

5. Deep Dive — Ingest & Transcode Pipeline

A studio uploads a master file directly to blob storage (same presigned-URL trick as Phase 4's File Storage LLD). That upload publishes an event; a pool of workers picks it up and produces every rendition in the bitrate ladder, entirely decoupled from the request that started it.

UploadController.javajava

The API surface: the server never touches the video bytes — it only hands out a place to put them.

@RestController
@RequestMapping("/api/ingest")
public class UploadController {
  private final BlobStore blobs;               // same port as Phase 4's File Storage LLD
  private final VideoAssetRepository assets;
  private final ApplicationEventPublisher events;

  public UploadController(BlobStore blobs, VideoAssetRepository assets,
                           ApplicationEventPublisher events) {
    this.blobs = blobs; this.assets = assets; this.events = events;
  }

  public record InitUploadRequest(long titleId, int durationSecs) {}
  public record InitUploadResponse(long assetId, String presignedPutUrl) {}

  @PostMapping("/uploads")
  public InitUploadResponse initUpload(@RequestBody InitUploadRequest req) {
    VideoAsset asset = assets.save(new VideoAsset(req.titleId(), req.durationSecs()));
    String uploadKey = "raw/" + asset.getId() + "/master.mp4";
    String url = blobs.presignedPutUrl(uploadKey, Duration.ofHours(2));
    return new InitUploadResponse(asset.getId(), url);
  }

  // Called by the blob store's webhook once the upload actually completes —
  // the API server was never in the data path for the multi-GB file itself.
  @PostMapping("/uploads/{assetId}/complete")
  public ResponseEntity<Void> completeUpload(@PathVariable long assetId) {
    events.publishEvent(new MasterUploadedEvent(assetId));   // → Kafka, see below
    return ResponseEntity.accepted().build();
  }
}
TranscodeWorker.javajava

One consumer, one ladder of outputs. Idempotent by design — see the checklist below the code.

@Component
public class TranscodeWorker {
  private static final List<Rendition> LADDER = List.of(
      new Rendition("240p",  400,  "h264"),
      new Rendition("480p", 1200,  "h264"),
      new Rendition("720p", 3000,  "h264"),
      new Rendition("1080p",6000,  "h264"),
      new Rendition("1080p",4200,  "av1"),      // AV1: ~30% smaller at same quality, costs more CPU to encode
      new Rendition("4K",  16000,  "av1")
  );

  private final VideoAssetRepository assets;
  private final VideoVariantRepository variants;
  private final Transcoder transcoder;          // wraps ffmpeg; swappable for tests
  private final BlobStore blobs;

  public TranscodeWorker(VideoAssetRepository assets, VideoVariantRepository variants,
                          Transcoder transcoder, BlobStore blobs) {
    this.assets = assets; this.variants = variants; this.transcoder = transcoder; this.blobs = blobs;
  }

  @KafkaListener(topics = "master-uploaded", groupId = "transcode-workers")
  public void onMasterUploaded(MasterUploadedEvent event) {
    VideoAsset asset = assets.findById(event.assetId()).orElseThrow();
    byte[] master = blobs.get("raw/" + asset.getId() + "/master.mp4");

    for (Rendition r : LADDER) {
      // IDEMPOTENCY: skip work already done — a redelivered event (Kafka is
      // at-least-once, see Phase 10.13) must not re-encode or double-charge compute.
      if (variants.existsByAssetIdAndResolutionAndCodec(asset.getId(), r.resolution(), r.codec()))
        continue;

      byte[] output = transcoder.encode(master, r.resolution(), r.codec(), r.bitrateKbps());
      String key = "variants/%d/%s-%s.mp4".formatted(asset.getId(), r.resolution(), r.codec());
      blobs.put(key, output);
      variants.save(new VideoVariant(asset.getId(), r.resolution(), r.codec(), r.bitrateKbps(), key));
    }

    if (variants.countByAssetId(asset.getId()) == LADDER.size()) {
      asset.markReady();                        // flips status: PROCESSING -> READY
      assets.save(asset);
    }
  }

  public record Rendition(String resolution, int bitrateKbps, String codec) {}
}
// Failure mode: worker crashes mid-ladder → Kafka redelivers → the existsBy
// check above means only the REMAINING renditions are (re)encoded, not all 6.

What could go wrong here (the interview follow-ups)

  • —A corrupted master file: the Transcoder should validate the container/codec before spending CPU, and route failures to a dead-letter topic (Phase 10.15) with the asset marked FAILED for a human to review.
  • —A poison title (e.g. a 10-hour 8K master) starving the worker pool: bound each job with a timeout and route by expected cost to a separate 'heavy' worker pool — the Bulkhead pattern (Phase 11.11) applied to compute, not just threads.
  • —Cost: AV1 encodes take ~5x the CPU of H.264 for ~30% smaller files. Netflix's real answer is to encode AV1 only for the highest-volume titles, where the storage+bandwidth savings at scale outweigh the one-time encode cost — a trade-off you should say out loud.

6. Deep Dive — Catalog Service

This is a textbook cache-aside (Phase 10.2) service. The catalog is read millions of times more often than it's written (a new title ships far less often than it's browsed), so every design choice optimizes reads.

CatalogService.javajava

Cache-aside with a short TTL — exactly the pattern from Phase 10.2, applied to a real entity.

@Service
public class CatalogService {
  private final TitleRepository titles;
  private final StringRedisTemplate redis;
  private final ObjectMapper json;
  private static final Duration TTL = Duration.ofMinutes(10);

  public CatalogService(TitleRepository titles, StringRedisTemplate redis, ObjectMapper json) {
    this.titles = titles; this.redis = redis; this.json = json;
  }

  public TitleView get(long titleId) {
    String key = "title:" + titleId;
    String cached = redis.opsForValue().get(key);
    if (cached != null) return readJson(cached);            // cache hit — the 99% path

    Title title = titles.findById(titleId).orElseThrow();
    TitleView view = TitleView.from(title);
    redis.opsForValue().set(key, writeJson(view), TTL);      // populate for next reader
    return view;
  }

  // Called when a title's metadata changes (new season, rating update, etc.)
  @CacheEvict                                                 // conceptually — evict, don't write-through
  public void onTitleUpdated(long titleId) {
    redis.delete("title:" + titleId);
    // Deliberately NOT re-populating here: the next reader repopulates it
    // (lazy loading). Re-populating on every write would race with concurrent
    // readers doing the same — see Phase 10.2's "evict on write" rule.
  }

  private TitleView readJson(String s) { try { return json.readValue(s, TitleView.class); } catch (Exception e) { throw new UncheckedIOException((IOException) e); } }
  private String writeJson(TitleView v) { try { return json.writeValueAsString(v); } catch (Exception e) { throw new RuntimeException(e); } }
}

At Netflix's actual scale, a single Postgres instance for titles/catalog eventually needs to become read-replicated (Phase 8.1) — the catalog is written by a handful of ingest pipelines and read by hundreds of millions of devices, which is the canonical read-replica shape, not a sharding shape (sharding would be the answer if WRITES were the bottleneck, which they aren't here).

7. Deep Dive — Playback & Adaptive Streaming

The Playback Service's entire job is to answer one question fast: 'here is a signed URL to a manifest, and here's where you left off.' Everything after that is the client and the CDN talking directly to each other.

PlaybackStartSequencediagram

The path that must complete in under 2 seconds — every hop here is on the latency budget.

Rendering diagram…
PlaybackController.javajava

The two calls in the diagram's 'par' block, fired concurrently — and wrapped for resilience, since Section 8 shows what happens when Progress or Catalog is slow.

@RestController
@RequestMapping("/api/playback")
public class PlaybackController {
  private final ProgressService progress;
  private final CatalogService catalog;
  private final ManifestSigner signer;             // signs CDN URLs with a short-lived token

  public PlaybackController(ProgressService progress, CatalogService catalog, ManifestSigner signer) {
    this.progress = progress; this.catalog = catalog; this.signer = signer;
  }

  public record PlayRequest(long assetId, long profileId, String deviceClass) {}
  public record PlayResponse(String manifestUrl, int resumeAtSecs) {}

  @PostMapping("/play")
  public PlayResponse play(@RequestBody PlayRequest req) {
    // fired concurrently — see Section 8 for the resilience wrapper around each
    CompletableFuture<Integer> resumeAt = CompletableFuture.supplyAsync(
        () -> progress.getPosition(req.profileId(), req.assetId()));
    CompletableFuture<List<VariantView>> variants = CompletableFuture.supplyAsync(
        () -> catalog.variantsFor(req.assetId(), req.deviceClass()));

    CompletableFuture.allOf(resumeAt, variants).join();
    String manifestKey = "manifests/" + req.assetId() + "/master.m3u8";
    String signedUrl = signer.sign(manifestKey, Duration.ofMinutes(6));  // short TTL: URL leaks shouldn't leak forever

    return new PlayResponse(signedUrl, resumeAt.join());
  }

  @PutMapping("/progress")                          // the heartbeat from the sequence diagram
  public ResponseEntity<Void> heartbeat(@RequestBody ProgressUpdate update) {
    progress.upsert(update.profileId(), update.assetId(), update.positionSecs());
    return ResponseEntity.noContent().build();       // fire-and-forget from the client's view
  }
}

8. Deep Dive — Resilience

The Recommendation Service (Section 9) is a NICE-TO-HAVE on the playback path — if it's slow, playback should degrade gracefully, never hang. This is Phase 11.8's Circuit Breaker and Phase 11.11's Bulkhead, applied to a call that would otherwise sit on the critical path.

ResilienceConfig.javajava

Resilience4j: a dedicated thread pool (bulkhead) so a slow recommendation call can never starve playback threads, plus a circuit breaker that stops calling a service that's clearly down.

@Configuration
public class ResilienceConfig {

  @Bean
  public CircuitBreaker recommendationCircuitBreaker() {
    CircuitBreakerConfig config = CircuitBreakerConfig.custom()
        .failureRateThreshold(50)                      // trip after 50% of calls fail
        .waitDurationInOpenState(Duration.ofSeconds(10)) // then stay open (fail fast) for 10s
        .slidingWindowSize(20)
        .build();
    return CircuitBreaker.of("recommendations", config);
  }

  @Bean
  public ThreadPoolBulkhead recommendationBulkhead() {
    ThreadPoolBulkheadConfig config = ThreadPoolBulkheadConfig.custom()
        .maxThreadPoolSize(8)          // recommendations get AT MOST 8 threads, ever
        .coreThreadPoolSize(4)
        .queueCapacity(20)
        .build();
    return ThreadPoolBulkhead.of("recommendations", config);
  }
}

@Service
public class ResilientRecommendationClient {
  private final RecommendationService delegate;
  private final CircuitBreaker breaker;
  private final ThreadPoolBulkhead bulkhead;

  public ResilientRecommendationClient(RecommendationService delegate, CircuitBreaker breaker,
                                        ThreadPoolBulkhead bulkhead) {
    this.delegate = delegate; this.breaker = breaker; this.bulkhead = bulkhead;
  }

  public List<TitleView> recommendationsFor(long profileId) {
    Supplier<CompletionStage<List<TitleView>>> call =
        ThreadPoolBulkhead.decorateSupplier(bulkhead, () -> delegate.recommendationsFor(profileId));
    try {
      return CircuitBreaker.decorateCompletionStage(breaker, call).get()
          .toCompletableFuture().get(300, TimeUnit.MILLISECONDS);   // hard timeout too
    } catch (Exception e) {
      return fallbackRow();          // "Popular on Netflix" — a static, always-available row
    }
  }

  private List<TitleView> fallbackRow() { return CatalogDefaults.POPULAR_ROW; }
}
// Home screen: 8 rows, one of which is recommendations. If that ONE row fails,
// the other 7 (genres, continue-watching, new releases) still render instantly —
// this is what "degrade gracefully" means in code, not just in a slide.

9. Deep Dive — Recommendations (sketch)

A full recommendation engine is its own multi-quarter project (collaborative filtering, embeddings, A/B-tested ranking models) — out of scope here. But the SHAPE every candidate should know: pre-compute scores offline, serve them from cache, and never run a model inference on the request's hot path.

RecommendationService.javajava

Offline job produces scores; the online path is a pure cache read — the same 'compute ahead of time, serve fast' idea as the transcode pipeline.

@Service
public class RecommendationService {
  private final StringRedisTemplate redis;         // sorted set per profile: titleId -> score

  public RecommendationService(StringRedisTemplate redis) { this.redis = redis; }

  // ONLINE PATH — must be sub-millisecond. No model runs here.
  public List<TitleView> recommendationsFor(long profileId) {
    Set<String> topIds = redis.opsForZSet()
        .reverseRange("rec:" + profileId, 0, 19);    // top 20 by pre-computed score
    return CatalogClient.hydrate(topIds);             // batch-fetch titles (cached, Section 6)
  }

  // OFFLINE PATH — a nightly batch job (Spark/Flink in practice), sketched here as a scheduled task.
  @Scheduled(cron = "0 0 3 * * *")                    // 3 AM, off-peak
  public void recomputeScoresNightly() {
    for (Profile profile : allProfilesBatch()) {
      Map<Long, Double> scores = ScoringModel.score(profile.watchHistory());  // the actual ML lives here
      String key = "rec:" + profile.id();
      redis.delete(key);
      scores.forEach((titleId, score) -> redis.opsForZSet().add(key, titleId.toString(), score));
    }
  }
}

10. Scaling the System — 10x and 100x

Bottleneck at current scaleFirst breaks atThe fix
Single-region Catalog DB primary~10x write volume from ingest metadataRead replicas per region (Phase 8.1); writes stay on one primary since ingest volume is still small relative to reads
Transcode worker pool queue depthA viral content deal ships 10,000 hours in one weekAutoscale worker pool on queue depth (Phase 9.8); pre-negotiate burst capacity with the cloud provider
Open Connect edge cache missesA new region launches with no pre-positioned contentPre-position the top N% of the catalog by predicted popularity BEFORE launch, not reactively (Phase 9.5)
Watch-progress write throughput100x concurrent streams (say, a global simultaneous live event)Shard progress by profileId (Phase 8.3) — it's a pure key-value access pattern, ideal shard candidate
Recommendation nightly batch job runtime100x profile count makes the nightly window too shortPartition the batch job by profile shard and run partitions in parallel; move from nightly to incremental/streaming updates

11. Trade-offs Recap

  • —AV1 for high-volume titles only vs. H.264 everywhere: AV1 saves ~30% bandwidth/storage at scale but costs ~5x encode compute — worth it only where the multiplied savings exceed the one-time cost.
  • —Eventual consistency for the catalog vs. strong consistency for watch-progress: a stale 'new episode' banner for a few seconds is invisible; losing 10 seconds of resume position is a visibly broken experience (Phase 13.3 vs 13.4, applied per-component, not globally).
  • —Pre-computed recommendation scores vs. real-time inference: sub-millisecond reads at 180M DAU vs. fresher-but-slower personalization — the batch approach wins because the read path cannot afford the latency.
  • —Circuit breaker fallback ('Popular on Netflix') vs. failing the whole home screen: a slightly-less-personalized row beats a blank screen, every time — Phase 11.8's whole argument in one UI decision.

12. The 60-Second Interview Pitch

Say this, in this order

"Two nearly-independent systems: a small, asynchronous ingest/transcode pipeline that turns a master file into a bitrate ladder, and a massive read-heavy serving path where the control plane (catalog, recommendations, progress) only ever hands out a signed manifest URL — all the actual bytes flow from CDN edge caches directly to the device. Progress is strongly consistent and sharded by profile; the catalog is cached and eventually consistent. Recommendations are pre-computed offline and served from a sorted set, with a circuit breaker and a static fallback so a slow recommendation model can never block the home screen."

Connect the dots

Nothing above was new — it's every pattern from the syllabus, assembled. If any deep dive felt shaky, that topic is your next study session, not this project.