Design a music streaming service
Open in the interactive app: practice, quiz, mock interview →In one minute
- Time to first sound is the product. A tap on a track should produce audio in well under a second, so the critical path is "resolve the file, fetch the first few seconds" in as few round trips as possible, helped by a large on-device cache and by prefetching the next track before the current one ends.
- Audio is small per file but the catalog is huge and skewed. A three-and-a-half-minute track at 160 kbit/s is about 4 MB; a hundred million tracks in several encodings is petabytes, yet a small head of popular tracks carries most plays. The head lives on CDN edges and in client caches, the tail is fetched from origin object storage.
- A play event is money. Royalties are paid on streams, and the industry convention, which Spotify states publicly, is that a stream counts once 30 seconds have been played (Spotify for Artists). The event pipeline is at-least-once, so counting must dedup by play ID and must cope with offline plays that arrive days later.
- Playlists are user-edited ordered lists, sometimes edited by several people at once and followed by tens of millions. Treat them as versioned documents with an operation log, not as rows you overwrite.
- Catalog metadata is read-mostly and can be cached aggressively; search and recommendations read from their own derived indexes, rebuilt from the catalog and listening history.
Requirements
Functional:
- Search the catalog (tracks, albums, artists, playlists) and play any track on demand, with seek, skip and gapless transitions.
- Create, edit, reorder and follow playlists, including collaborative ones; save tracks to a library.
- Download playlists for offline playback on mobile.
- Show stream counts on tracks and give artists and rights holders accurate per-track, per-day counts for royalties.
Non-functional:
- Playback start p95 under about 500 ms on a warm client, and stutter (rebuffering) in well under 1% of plays.
- Stream counts used for payment must be exact after daily reconciliation; counts shown in the app may lag by a day.
- Playlist edits from one user are immediately visible to that user (read-your-writes) and to collaborators within seconds.
- Playback keeps working through partial outages: a failed recommendation or search service must never stop a song that is already queued.
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 } ] }
-> 202Data 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:
- 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.
- 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.
- 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.
- 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
- Listeners notice silence after a tap far more than they notice bitrate, so the design spends bytes (prefetch, cache) to save round trips.
- 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.
- Popularity is skewed and files never change, so immutable, content-addressed files on CDNs and client caches serve most plays without touching origin.
- Money depends on counts and clients retry, so counting is derived from raw, deduplicable events with a closing period, not from live counters.
- Collaborative edits conflict, so playlists are versioned op logs addressed by item ID rather than overwritten arrays.
Failure modes
- 10x load from a major album release: millions of plays of the same 15 tracks in the first hour. Pre-position the files on every CDN edge before the release time (the release is scheduled, so this is known), cache metadata and the album snapshot, and make the playback resolver's response for those tracks cacheable for minutes.
- CDN vendor outage: the resolver returns several CDNs in preference order; clients fail over on errors or slow first bytes, and the resolver reorders the list globally within minutes based on client-reported error rates.
- Region outage: catalog and playlist stores are replicated across regions, and the gateway reroutes. Clients keep playing from cache and already-fetched audio. Events buffer on device until the event gateway accepts them, so no plays are lost, only delayed.
- Event pipeline backlog: counts and recommendations lag, playback is unaffected because nothing on the playback path depends on the pipeline. Alert on log consumer lag and on the fraction of events arriving more than a day late.
- Hot playlist: a viral playlist followed by 30 million people. Serve immutable
(playlist_id, revision)snapshots from cache; update the follower count with sharded counters.
In the wild
- Spotify, 2025: one filter reorder takes down the perimeter. On 16 April a change to the order of Spotify's custom Envoy filters was judged low risk and applied to all regions at once; it triggered a bug that crashed every Envoy instance at 12:18 UTC. Restarts plus client retries produced a load spike, and because Envoy's max heap was set above the Kubernetes memory limit, new instances were killed and restarted in a loop. Asia-Pacific, quiet at that hour, stayed up; the US was back at 15:10. Spotify fixed the bug and the limits and changed how perimeter config rolls out (Spotify).
- Spotify, 2022: managed service discovery fails everyone at once. A Google Cloud Traffic Director outage, combined with a gRPC client bug, broke discovery for services that used it, and users worldwide were logged out and could not log back in for about 2 hours 23 minutes. Services on Spotify's DNS-based discovery were unaffected, so Spotify moved the rest back to DNS and added discovery-health monitoring (Spotify).
- Spotify, 2016: rebuilding event delivery. The old pipeline tailed log files into Kafka 0.7 and wrote hourly buckets to HDFS; an hour was only complete when every expected file arrived, so one failed machine could stall a bucket until someone intervened, and the ETL step added about 30 minutes. Components were too tightly coupled to fix one at a time, so Spotify rewrote it around Cloud Pub/Sub (Spotify).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| CDN plus large client cache | Fast starts, low egress for repeats | Device storage, cache encryption and eviction logic | Repetitive listening, mobile clients |
| Peer-to-peer delivery | Low server bandwidth | Poor on mobile, complex, privacy concerns | Desktop-heavy, egress-expensive eras |
| One bitrate per track play | No audible switches | Slower reaction to network drops | Audio, where buffers are cheap |
| Per-chunk ABR | Reacts quickly | Audible quality changes | Video |
| Batch-reconciled stream counts | Exact, auditable, handles late data | Counts final only after days | Payments and royalties |
| Live counters on start events | Instant | Overcounts skips and retries | Trending and dashboards only |
| Op log with item IDs for playlists | Safe concurrent edits, cheap appends | Snapshot and rebase logic | Collaborative or large lists |
Leadership lens
- Build first: the playback path (resolver, file layout, client cache, CDN contracts) and the raw event schema with
play_id. Recommendations and search can start simple; an event schema without a stable play ID cannot be fixed retroactively for royalty years already paid. - Ownership: a Playback team owns client player, resolver and CDN relationships; a Content Platform team owns ingestion, encoding and storage; an Event Delivery team owns the pipeline as a product with an SLA; a Royalties team owns the counting rules, which are part legal contract.
- Cost drivers: CDN egress, then event storage and processing, then audio storage. Raising client cache hit rate by a few points is usually the cheapest egress win; negotiating multiple CDNs keeps prices honest.
- Rollout: change the counting rules only with a parallel run, computing old and new counts for weeks and explaining every difference to rights holders before switching.
- Review questions: what is our time-to-first-sound at p95 by market and network type; what fraction of events arrives after day close; can we recompute any day's counts from raw events alone.
Check yourself
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 maximumms_played(heartbeats are cumulative, duplicated and out of order) and counts once at 30,000 ms, then applies anti-abuse rules.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.
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.
Why are playlist edits operations addressed by
item_idrather 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_revisionthat reference stable item IDs mostly commute, so on a conflict the client rebases cheaply.
Sources and further study
Primary
- Spotify: Large Scale, Low Latency, P2P Music-on-Demand Streaming (Kreitz and Niemelä, IEEE P2P 2010): measured playback latency, cache, peer and server shares, the 15-second first request and prefetch timing.
- RFC 5681, section 4.1: Restarting Idle Connections: why a sender shrinks its congestion window after idle, the behaviour Spotify switched off.
Systems docs
- How your streams are counted (Spotify for Artists): the 30-second rule and how offline plays are logged on reconnect.
- Modernizing our royalty system (Spotify for Artists): the 1,000-stream threshold, artificial-streaming charges and noise-track minimum length.
- Audio quality (Spotify support): the current bitrate tiers, web-player AAC and lossless FLAC.
- Linux ip-sysctl documentation:
tcp_slow_start_after_idleand the other TCP knobs that set time to first byte.
Books
- The Site Reliability Workbook, Beyer et al. (Google), chapter 13 "Data Processing Pipelines": ends with a Spotify case study on event delivery SLOs.
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, chapter 11 "Stream Processing": event logs, deduplication and reasoning about time and late events.
- High Performance Browser Networking, Ilya Grigorik, chapter 2 "Building Blocks of TCP": slow start, slow-start restart and why warm connections matter (read free).
Other curricula
- Azure Architecture Center: Event Sourcing pattern: frames state as an append-only log of actions from which views are materialised, the same idea as the playlist op log and raw play events.
- donnemartin/system-design-primer: CDN: frames static-file delivery as push versus pull CDNs; immutable audio files suit pull with long TTLs.
Watch
- Niklas Gustavsson Discusses Spotify's Architecture (Spotify R&D): Spotify's chief architect on how the backend and its teams are organised at scale.
- Building Backend Services at Spotify (tretton37): how Spotify structures and operates its backend services.
- Design Spotify Like a Senior Engineer (Hayk Simonyan): an interview-style walkthrough to compare against your own answer.
Free resources
- Spotify's Event Delivery: The Road to the Cloud, Part I (Spotify Engineering): how Spotify moves billions of client events a day and why it rebuilt the pipeline.
- Smoother Streaming with BBR (Spotify Engineering): an experiment on congestion control and audio stutter.
- Personalization at Spotify using Cassandra (Spotify Engineering): how per-user data is modelled in a wide-column store.
- Ogg Encapsulation for the Opus Audio Codec (RFC 7845): a precise description of Ogg pages, granule positions and seeking, the same container used for Vorbis.
- Spotify (Wikipedia): background on the service's history, including its early peer-to-peer delivery.