The Blueprint
Media & files intermediate

Design Spotify

A long-tail catalogue where a hundred million tracks each have a distribution curve — the opposite scaling story from Netflix.

asked at Spotify · Apple · YouTube Sometimes asked ~35 min walkthrough ✎ Practice on canvas →
01

Scope the requirements

This looks like Netflix until you count tracks. 100M+ items with a long-tail play distribution means CDN caching becomes a shard-selection problem, not a hit-rate problem.

Functional — what it must do

  • Search + play any track from a 100M-item catalogue.
  • Playlists (user-authored + system-generated daily mixes).
  • Gapless playback, offline downloads.
  • Track-play events feed recommendations + royalty accounting.
  • Explicitly cut: podcasts, video, social — offer them back.

Non-functional — the numbers that shape the design

  • Play start latency under 300 ms to first audio.
  • 500M MAU, ~200M DAU, ~50M concurrent listens at peak.
  • Long-tail catalogue: the top 1% of tracks drives 50% of plays; the bottom 50% drives 5%.
  • Track file size ~3-5 MB (Ogg Vorbis); daily upload ~40k new tracks.
  • 99.99% on play; browse can degrade.
Say this out loud

The tail is the design driver. I need a CDN that can cache the head cheaply and fall through to origin fast enough that the tail still plays under 300 ms.

02

Back-of-envelope estimates

Storage is huge but bandwidth is modest — audio is 10× smaller than video per hour. The interesting math is per-user play data.

QuantityEstimateHow you got there
Concurrent listens~50M peak200M DAU × 25% overlap during peak commute windows.
Bandwidth~15 Tbps50M × ~300 kbps average bitrate (Vorbis q5). Small next to video streaming.
Catalogue storage~5 PB100M tracks × 5 encodings × ~10 MB avg (raw + ladder). Growing 40k/day.
Play events/day~5B200M DAU × ~25 plays/day. Big stream, but small compared to feed events.
CDN hit rate on head~99%top 1% (1M tracks) fits in ~5 GB per edge box. Tail misses fall through to regional.
Daily-mix rebuild~1B rows/dayone per user; batch job runs nightly reading last N days of history.
Say this out loud

50M concurrent × 300 kbps is small next to Netflix. The scale story here is the catalogue depth, not bandwidth.

03

Sketch the API

Browse + play + event ingest. Playback returns a signed URL; the manifest points to a CDN edge nearest the listener.

GET/api/v1/search?q=→ hits over tracks/artists/albums. Elasticsearch.
GET/api/v1/tracks/{id}/playback→ { streamUrl, drmToken, gaplessNext? }. Signed CDN URL.
POST/api/v1/events/playbody: { trackId, ms, source, deviceId }. Batched from client every ~30 s.
GET/api/v1/mixes/daily→ [tracks]. Read from Redis; built overnight by recs job.
GEThttps://cdn/{track}/{bitrate}/audio.oggByte-range fetch from CDN. Never touches your services.
  • Gapless playback needs a hint in the manifest: the response includes gaplessNext with a preload URL so the client can start buffering the next track before the current one ends.
  • Play events are batched (not per-second) — the client accumulates and fires every 30 s or on track change.
04

Data model

Cold catalogue + hot play history + derived rollups. Postgres for the small stuff, Cassandra for the wide streams.

tracks
id PK · title · artist_id · album_id · duration · encodings[]
Postgres — small (~100M rows fit one cluster), read-mostly, replicated.
playlists
id PK · owner_id · tracks[] (ordered)
Postgres. Small per-row, hot per-user.
play_events
(user_id, ts) PK · track_id · ms · device
Cassandra, partition by user_id. 5B rows/day.
daily_mix
user_id → [track_id × ~50]
Redis, rebuilt nightly. TTL 24h. Fallback: recompute on-demand for absent user.
The database call

The play stream is the only large-write dataset — Cassandra keyed by user_id. Track metadata is small enough for Postgres; caching that whole table in Redis costs cents. The daily mix is a derived Redis-only view of yesterday's play history.

05

High-level design

Three planes: catalogue (small, read-heavy), media (large, one-way, CDN-first), and events (append-only stream feeding both royalty accounting and recommendations).

client edge service data async Player CDN edge API gateway Catalogue svc Playback svc signs urls Events ingest Postgres tracks, playlists Elasticsearch search Cassandra plays Recs pipeline Royalty ledger Daily-mix cache append audio meta
audio never touches the app tier. events feed two independent consumers: recs (approximate) and royalties (exact).
  1. Client plays track → GET /playback → catalogue check → sign a manifest URL pointing at the nearest CDN → return.
  2. Client fetches the audio direct from CDN. On miss, edge pulls from regional cache; last resort is origin (~1% of tail traffic).
  3. Every ~30 s the client batches play events → events ingest → Cassandra append + Kafka publish.
  4. Recs pipeline consumes the event stream, aggregates listening patterns, writes daily mixes to Redis before morning peak.
  5. Royalty pipeline runs monthly against the durable Cassandra + Kafka archive to compute artist payments — batch not stream, because rights disputes are common and re-runs must be deterministic.
06

Deep dives — where the interview is won

Phase 01

Long-tail caching: keep the head at the edge, let the tail fall through fast

Video streaming (Netflix) has a small catalogue that fits comfortably at every ISP edge. Music has 100M+ tracks; you cannot pre-place all of them everywhere. The pattern: cache the top ~1% at every edge (they serve half the traffic), the next 10% at regional, and let the deep tail fetch from origin. Because audio files are small (~5 MB) and the tail is spread across many artists, origin latency is tolerable — a ~50 ms extra round trip on a niche track is fine.

The routing decision (which cache tier) is made from CDN request logs: if a track is requested more than N times per region per hour, promote it to a hotter tier. Demotion happens naturally through LRU eviction. This is the classic 'popularity-driven caching' pattern, and the interviewer scores on you naming the tail as the design axis.

listener edge (top 1%) regional (10%) origin (rest) Listener Edge cache hot Regional warm Origin S3 cold hit 50% miss 45% miss 5%
three tiers by popularity band. only the deep tail reaches origin.
Trade-offThe tail is where new artists live. If origin latency is poor, they get punished. Pre-warming new releases (predicted mid-tail on release) mitigates this — a marketing input, not a technical one.
Phase 02

Gapless playback: the manifest hint that changes everything

Gapless means no audible pause between tracks (albums, mixes). The naive design fetches track N, ends, then requests track N+1 — the round trip is audible. The fix: on the /playback response for track N, include gaplessNext = playback URL for track N+1. The client starts prefetching N+1's first audio segment as N's playback progresses. When N ends, N+1's buffer is already primed.

The subtlety: gapless must survive a skip. If the user skips N halfway through, the prefetch of N+1 is wasted bandwidth. Cap prefetch at the first 10 seconds of N+1 — enough to start playback without noticeable gap, small enough to abandon cheaply.

server client Playback svc returns N + hint Player prefetches N+1 N + N+1 url GET N+1 prefetch
one round trip returns both current and next track. prefetch fills the gap.
Trade-offAdds one prefetch's worth of bandwidth per track transition. Trivial cost for the perceptible quality bump.
Phase 03

Daily mix: overnight recall, online serving

The daily mix (personalised playlist rebuilt each morning) is a perfect fit for offline-compute + online-serve. Nightly batch reads yesterday's play events per user, runs collaborative filtering + content signals, writes a ~50-track playlist to Redis keyed by user_id.

The read path is one Redis GET. If the mix expires or the user is new, fall through to a session-based online recommendation using recent history + region trending. The trick is that morning coffee/commute traffic hits Redis first, and the fallback path only serves ~1% of users on any given day.

nightly cache online Recs batch cf + content Redis mix User request Fallback ranker write nightly miss/new user GET
99% of daily mixes are pre-baked. new/edge users hit a lighter online path.
Trade-offThe mix is 24 hours stale. If a user has a mood shift mid-day, their mix doesn't adapt until tomorrow. Add a 'refresh mix' button that runs the fallback ranker on-demand for the impatient.
07

Follow-ups you should expect

How is this different from Netflix?
Netflix has a small catalogue watched by many; Spotify has a huge catalogue with a long tail. Netflix's whole design is edge pre-placement of ~15k titles; Spotify's is popularity-tier caching of ~1M active tracks with fast origin fallback. Same idea, different constants and different failure surfaces.
How do you handle offline downloads?
Downloaded tracks are AES-encrypted with a per-download key delivered by the license server. Revocation happens on subscription change: the key server refuses to hand out new decryption keys. The audio file sits encrypted on the device forever until re-authorised or expired.
Royalty accounting for 5B plays/day?
Batch, not stream. Monthly job reads the durable event archive, joins to the rights table (which artist owns which track when), and outputs a payment ledger row per (artist, month). Batch because rights change retroactively — a stream job would need constant reruns.
Search over 100M tracks with typo tolerance?
Elasticsearch with fuzzy matching + weighted fields (title × 3, artist × 2, album × 1). The interesting cost is keeping the index fresh — Postgres → CDC → Elasticsearch is the standard pipeline. Boosted fields for artists the caller already follows come from a per-user reranker.
What breaks if origin S3 goes down?
Tail plays fail (~5% of traffic). Head + regional traffic (~95%) keeps working from cache. Alerts fire because origin misses back up quickly. Users see 'this track is unavailable' on the affected tracks; the app itself keeps working.

Where candidates lose the room

  • Designing for a Netflix-shaped catalogue — pre-placing 100M tracks everywhere is impossible; you need popularity tiers.
  • Fetching next track only when current ends — gap is audible on any transport.
  • Streaming royalty accounting — rights are retroactive and disputed; batch with a monthly recompute is the norm.
  • Play events through your OLTP database — 5B/day is a stream shape, not a table shape.
  • One-shot recommendation on request — the daily mix pattern buys you cheap serving at the cost of one day of staleness.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.