Engineering Atlas › System Design Interviews

Design a music streaming service

21 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: podcasts and video, the recommendation models themselves (Discover Weekly style batch jobs are mentioned only as consumers of events), ads, payments and subscriptions, lyrics, and device handoff between phone and speaker.

Back-of-envelope

Planning numbers in the right order of magnitude for a service with several hundred million monthly users. For scale, Spotify reported 777 million monthly active users and 300 million Premium subscribers on 30 June 2026 (Spotify 6-K).

Users: 250 million daily actives listening about 100 minutes a day. With an average track of 3.5 minutes that is about 30 plays per user per day, so 7.5 billion plays a day, about 87,000 play starts per second on average and around 220,000 at peak.

Concurrent streams: 250 million times 100 minutes divided by 1,440 minutes a day is about 17 million listeners at any moment on average, perhaps 35 million at peak.

Bandwidth: at 160 kbit/s, 17 million listeners need about 2.8 Tbit/s on average and about 5.6 Tbit/s at peak. If client caches serve a third of plays (repeat listening is common), the CDN delivers roughly 2 to 4 Tbit/s. The origin sees only CDN misses, a small percentage, mostly long-tail tracks.

Catalog storage: 100 million tracks. One encoding at 160 kbit/s is about 4.2 MB per track. Spotify's app offers about 24, 96, 160 and 320 kbit/s, the web player uses AAC at 128 or 256 kbit/s, and lossless is FLAC at up to 24-bit/44.1 kHz (Spotify support). Those six lossy files total about 26 MB and a 24-bit FLAC copy about 35 MB, so about 60 MB per track and about 6 PB in all. New releases of around 100,000 tracks a day add about 6 TB a day, about 11 PB over five years.

Events: perhaps 10 client events per play (start, heartbeats, pause, seek, skip, end) at about 300 bytes is 75 billion events and about 22 TB a day, about 40 PB over five years before compression. That pipeline, not audio storage, is the biggest data-engineering cost.

Metadata API: play resolves plus search, home and playlist reads come to perhaps 300,000 to 500,000 requests per second at peak, mostly cache hits.

API

GET  /v1/tracks/{id}/playback?formats=ogg160,ogg320
  -> { file_id, format, bitrate_kbps, size_bytes,
       urls: [ "https://cdn-a/...", "https://cdn-b/..." ],   # ordered by preference
       key_ticket, expires_at, duration_ms }

GET  /v1/search?q=hello&types=track,artist&limit=20&cursor=...
  -> { tracks: [..], artists: [..], next_cursor }

GET  /v1/playlists/{id}?fields=header,items&from=0&limit=100
  -> { playlist_id, revision, name, items: [ { item_id, track_id, added_by } ] }

POST /v1/playlists/{id}/ops
  body { base_revision: 41, ops: [ { op: "insert", after_item: "i7", track_id },
                                   { op: "move", item: "i3", after_item: "i9" },
                                   { op: "remove", item: "i5" } ] }
  -> 200 { revision: 42 }  |  409 { current_revision: 44, ops_since: [..] }

POST /v1/events          (batched, gzip, sent every few seconds or on app close)
  body { events: [ { play_id, track_id, type: "progress", ms_played: 31250,
                     client_seq, client_ts } ] }
  -> 202

Data model

tracks (catalog store, key track_id): title, ISRC, album and artist IDs, duration, market availability, and a list of (file_id, format, bitrate, size). Read-mostly and small (a few KB per track), replicated to every region and fronted by caches. Partitioning by track_id hash is fine because nothing joins across tracks on the hot path.

audio files (object storage, key file_id, which is a content hash): immutable, encrypted blobs. Content addressing means re-ingesting the same master produces the same key, and CDN entries never go stale.

playlists (wide-column store, partition key playlist_id): a header row (owner, name, revision, follower count) and an ops log clustered by revision. The current item list is a materialized snapshot rebuilt every N revisions. Partitioning by playlist keeps every edit and read of one playlist on one replica set.

library (wide-column, partition key user_id, clustering added_at desc): saved tracks and followed playlists. One partition read renders "Your Library".

play events (log, then object storage partitioned by event_date/hour): raw, append-only and never mutated. The log is partitioned by user_id hash so one user's events stay ordered.

stream_counts (warehouse table (track_id, day, country) -> streams, plus a key-value projection keyed by track_id for the app): written by the daily reconciliation job; the projection serves the displayed count.

High-level design

flowchart LR
  C["Client app + local cache"] --> GW["API gateway"]
  GW --> MD["Metadata service"]
  MD --> CAT[("Catalog store")]
  GW --> PB["Playback resolver"]
  PB --> CAT
  PB --> KS["Key service"]
  C -->|"range GET"| CDN["CDNs"]
  CDN --> OS[("Audio object storage")]
  GW --> PL["Playlist service"]
  PL --> PDB[("Playlists and libraries")]
  GW --> SR["Search"]
  C -->|"batched events"| EV["Event gateway"]
  EV --> LOG["Event log"]
  LOG --> RAW[("Raw events by hour")]
  LOG --> RT["Streaming counter"]
  RAW --> REC["Daily reconciliation"]
  REC --> SC[("Stream counts")]
  RAW --> ML["Recommendation jobs"]

Read path (playing a track): the client checks its local cache; on a hit, playback starts immediately from flash. On a miss, it calls the playback resolver, which returns the best file for the device and network, a short-lived ticket for the decryption key, and CDN URLs in preference order. The client issues an HTTP range request for the first chunk to the first CDN, starts decoding as soon as a few seconds are buffered, then fetches the rest in larger ranges. When the current track nears its end, the client resolves and prefetches the beginning of the next queued track, so the transition is gapless and needs no network on the critical path.

Write path (counting): the client records play progress locally and sends batched events. The event gateway authenticates, stamps server receive time, and appends to the log. A streaming job keeps approximate near-real-time counts for dashboards and trending. A daily job reads the raw events for the day (plus late arrivals for earlier days), groups by play_id, applies the 30-second rule and the fraud filters, and writes the authoritative counts that feed royalty reports.

Deep dives

Fast start and gapless playback

The options are a smaller first fetch, fewer round trips, or no network at all. Spotify's early desktop client also fetched from peers to cut latency and bandwidth cost; it moved away from peer-to-peer in 2014, as mobile clients, which make poor peers, came to dominate. Its 2010 measurements are still the best public baseline: 55.4% of played data came from the local cache, 35.8% from peers and only 8.8% from servers, and median playback latency was 265 ms. On a tap with nothing cached, the client asked the server for roughly the first 15 seconds of audio over an already-open TCP connection (Kreitz and Niemelä, IEEE P2P 2010). Today the levers are:

  1. Client cache. A few GB of encrypted audio on device, evicted by least-recently-played. Because listening is repetitive, this alone removes a large share of network fetches.
  2. Prefetch. Fetch the first chunk of the next queued track before the current one ends, and the first chunk of likely taps (the top search result, the first track of an opened album) speculatively. The cost is wasted bytes on wrong guesses, so cap speculative prefetch on cellular. The 2010 client prefetched the next track from the server when 10 seconds remained; when a bug disabled prefetch for a few weeks, median latency rose from 265 to 390 ms and stutter from 1.0% to 1.8% of plays.
  3. A small first range. At 160 kbit/s, 5 seconds of audio is 100 KB. Request that first so decoding starts on one round trip, then request large ranges (1 MB or the whole file) so the radio can go idle.
  4. Bitrate choice per play, not per chunk. Music players usually pick one encoding at start from the network type and recent throughput, and step down only on sustained stalls. Mid-song quality switches are audible in a way video resolution switches are not. The ABR mechanics are in the Media Streaming lesson.

I would choose all four, with the cache and next-track prefetch as the biggest wins because they remove the network from most transitions.

Counting streams that pay royalties

Options: count on the client and trust it, increment a counter on the server per "start" event, or derive counts from raw events in a reconciled batch. Trusting the client invites fraud. Incrementing on "start" overcounts skips and double counts retries. So raw events are the source of truth, and counting is a pure function over them.

The rules that function applies: group by play_id, which the client generates once per playback; take the maximum ms_played across that play's events, because heartbeats are cumulative, may be duplicated by retries, and arrive out of order; count the play once if that maximum reaches 30,000 ms. Use listened time, not position, so seeking to 0:31 does not count. Then apply anti-abuse rules, such as a cap on counted plays per user, track and day, and filters for known bot accounts. Payment rules sit on top of counting: since April 2024 a track needs at least 1,000 streams in the previous 12 months to earn recorded royalties, and Spotify charges labels and distributors per track when it detects flagrant artificial streaming (Spotify for Artists). So the counter must also keep a rolling 12-month total per track.

Late data is the norm: offline listening uploads its events when the phone reconnects, possibly days later. Spotify counts offline plays when the listener goes online and requires a reconnect at least once every 30 days, which bounds how late an event can be (Spotify for Artists). The daily job recomputes a sliding window of recent days and emits corrections, and royalty statements close only after a grace period. The real-time counter serves dashboards and trending and may be slightly wrong. It is the same split as in the Ad Click Aggregator lesson: a fast approximate path, a slow exact path, and the exact path wins. Spotify's real pipeline makes the delivery promises explicit: each event type gets its own Pub/Sub topic and ETL, a batch step deduplicates into hourly buckets, and the SLOs cover timeliness, completeness (audited daily) and skewness, the share of finance-bearing events filed in the wrong hour (Google SRE Workbook, ch. 13).

Playlists as versioned documents

A naive model stores a playlist as one row with an array of track IDs, rewritten on every edit. Two collaborators editing at once lose each other's changes, and a 10,000-track playlist rewrites hundreds of KB to add one song.

Instead, every item gets a stable item_id, edits are operations (insert after item, move after item, remove item) addressed by item ID rather than index, and each accepted batch bumps revision. A client sends ops with its base_revision. If the server is at that revision, it applies them. If not, it returns the ops since, and the client rebases: removals of already-removed items become no-ops, inserts after a removed item fall back to that item's predecessor. Because ops reference IDs, not indexes, most concurrent edits commute and rebasing is trivial. This is a lightweight cousin of the operational transformation covered in Design Google Docs, and it is enough because playlists do not need character-level merging.

Popular editorial playlists are a hot-read problem, not a write problem: tens of millions of followers read them, few people edit them. Serve their snapshots from a cache keyed by (playlist_id, revision), which is immutable and so can be cached at the CDN like audio.

Down the stack

Layer 1The contract

GET /playback returns URLs and a key ticket valid for a bounded time (minutes); any listed CDN URL serves byte-identical content for the same file_id, so the client may switch mid-file using range requests. Range responses are 206 Partial Content and safe to retry. Event submission is at-least-once: the client keeps events on disk until it gets a 202, so duplicates are expected and the counter must be idempotent per play_id. The authoritative stream count for a day is final after the grace period; before that it may change. Playlist ops are applied atomically per batch and produce a strictly increasing revision.

Layer 2The mechanism

Lossy tracks are Ogg Vorbis (and AAC on some web clients). An Ogg file is a sequence of pages, each with a 27-byte header carrying the capture pattern OggS, a granule position (the sample count at the end of the page) and a CRC. After the three Vorbis header packets at the start of the file, the decoder can resume at any page boundary, so seeking is a bisection over byte offsets using granule positions (RFC 7845), or a lookup in a small seek table fetched with the first chunk. Spotify chose the second: because Ogg seeks poorly over a network, it adds a custom header to every file to support seeking. Files are encrypted with a per-file key, so the CDN only ever stores ciphertext; the key service releases the key to an authenticated client for a ticket the resolver issued. On the event side, the log is partitioned by user, and each event carries (play_id, client_seq), so dedup is "keep the highest client_seq and the maximum ms_played per play".

Layer 3The machine

At 160 kbit/s, audio needs 20 KB per second. A 100 KB first chunk is about 70 TCP segments of 1,460 bytes. On a warm, kept-alive HTTP/2 connection whose congestion window has already grown, it arrives in one round trip, about 30 to 80 ms on mobile. On a cold connection, DNS, TCP and TLS 1.3 handshakes add two or three round trips, and slow start from an initial window of 10 segments needs about three more. That difference, roughly 100 ms versus 400 ms, is why the client keeps CDN connections open. A warm connection is not enough on its own: RFC 5681 says a sender should shrink its congestion window after an idle period longer than the retransmission timeout, and Linux does this by default (tcp_slow_start_after_idle=1, kernel docs). Spotify turned it off on its servers and average playback latency fell by about 50 ms. Spotify's engineering blog also reports an A/B test on one CDN where switching server congestion control from CUBIC to BBR cut stutter by about 17% in Asia-Pacific, 12% in Latin America and 3 to 5% in Europe and North America, because loss-based control backs off too far on lossy links (Spotify). The radio adds its own cost: an idle cellular modem takes tens to hundreds of milliseconds to wake, so downloading the whole track in one burst and letting the radio sleep beats trickling 20 KB per second. Decoding takes a few percent of one mobile core, and cache reads from flash take well under a millisecond.

Why it's built this way

  1. Listeners notice silence after a tap far more than they notice bitrate, so the design spends bytes (prefetch, cache) to save round trips.
  2. Round trips are bounded by the speed of light and mobile radio wake-up, so warm connections and nearby CDN edges matter more than server speed.
  3. Popularity is skewed and files never change, so immutable, content-addressed files on CDNs and client caches serve most plays without touching origin.
  4. Money depends on counts and clients retry, so counting is derived from raw, deduplicable events with a closing period, not from live counters.
  5. Collaborative edits conflict, so playlists are versioned op logs addressed by item ID rather than overwritten arrays.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
CDN plus large client cacheFast starts, low egress for repeatsDevice storage, cache encryption and eviction logicRepetitive listening, mobile clients
Peer-to-peer deliveryLow server bandwidthPoor on mobile, complex, privacy concernsDesktop-heavy, egress-expensive eras
One bitrate per track playNo audible switchesSlower reaction to network dropsAudio, where buffers are cheap
Per-chunk ABRReacts quicklyAudible quality changesVideo
Batch-reconciled stream countsExact, auditable, handles late dataCounts final only after daysPayments and royalties
Live counters on start eventsInstantOvercounts skips and retriesTrending and dashboards only
Op log with item IDs for playlistsSafe concurrent edits, cheap appendsSnapshot and rebase logicCollaborative or large lists

Leadership lens

Check yourself

  1. Why are royalty counts derived from raw events in a daily batch instead of incrementing a counter on each "start" event?

    Show answer

    Counting on "start" overcounts skips and double counts retries, and trusting the client invites fraud. The batch groups by play_id, takes the maximum ms_played (heartbeats are cumulative, duplicated and out of order) and counts once at 30,000 ms, then applies anti-abuse rules.

  2. Offline plays upload days later. How does the counting design cope?

    Show answer

    The daily job recomputes a sliding window of recent days and emits corrections, and royalty statements close only after a grace period. The real-time counter serves only dashboards and trending and may be slightly wrong; the exact path wins.

  3. Why does the client request a small first range of about 100 KB and then large ranges?

    Show answer

    At 160 kbit/s, 5 seconds of audio is about 100 KB, which arrives in one round trip on a warm connection so decoding starts quickly. Larger follow-up ranges let the cellular radio go idle instead of trickling 20 KB per second.

  4. Why are playlist edits operations addressed by item_id rather than rewriting an array of track IDs?

    Show answer

    Rewriting the array loses concurrent collaborators' changes and rewrites hundreds of KB for a 10,000-track playlist to add one song. Ops against a base_revision that reference stable item IDs mostly commute, so on a conflict the client rebases cheaply.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →