- Audio is encoded once per quality tier at ingestion, stored in object storage and served from CDNs with HTTP range requests. Device caching and prefetching the next track make playback feel instant.
- The catalogue is small (petabytes); bandwidth is the real cost (terabits per second at peak), so cache hit rate is the key metric.
- Playlists: metadata row + ordered entries with fractional positions; version numbers for sync and collaborative edits.
- Offline downloads: encrypted files on device, keys bound to account/device, periodic online licence checks.
- Royalties are an event pipeline problem: play events → dedup → 30-second rule → fraud filtering → per-track counts → pro-rata payouts, reconciled in batch.
Music streaming looks like a smaller version of video streaming, and many candidates design it that way: upload, transcode, CDN, done. Interviewers at Spotify, JioSaavn, Gaana, Amazon Music or YouTube Music then push on the parts that are unique to music: instant playback on flaky mobile networks, offline listening on a train with no signal, playlists edited by five friends at once, recommendations that drive most listening, and paying the right rights holders for billions of plays.
This guide designs a Spotify-like service with those parts given real depth. Where Spotify has published details (bitrates, the 30-day offline rule, stream thresholds) they are mentioned with the caveat that such details change; everything else is a sound reference design.
Step 1: Requirements
Functional
- Play any track in the catalogue on mobile, desktop, web and connected devices, with seek, skip and gapless playback.
- Browse artists and albums; search tracks, artists, albums, playlists and podcasts.
- Create, edit, follow and share playlists, including collaborative ones.
- Offline downloads for premium users.
- Recommendations: home feed, radio, personalised playlists, autoplay.
- Count plays for royalties and artist analytics.
- Free tier with ads and quality limits; premium tier.
Non-functional
- Low start latency: first audio within a few hundred milliseconds on good networks; no rebuffering mid-track.
- Works on 3G/4G in tier-2 Indian cities and on metro rides with intermittent signal.
- High availability for playback; the catalogue and playlists can be slightly stale but playback must not stop.
- Accurate and auditable stream counts, because they drive payments.
- Licensing: availability differs by country and changes over time.
Step 2: Capacity Estimates
Assumptions for the interview (Spotify reports several hundred million monthly users; these are round numbers for math, not its real figures):
| Quantity | Assumption / math | Result |
|---|---|---|
| Daily active users | Assumption | 200 million |
| Listening time | 2 hours per DAU per day | 400M listening hours/day |
| Average concurrent listeners | 400M h / 24 h | ~16.7 million |
| Peak concurrent listeners | 2x average (evening) | ~33 million |
| Peak egress bandwidth | 33M × 160 kbps (minus cache hits on devices) | ~5.3 Tbps upper bound |
| Track starts | ~3.5-minute tracks: 400M h × 60 / 3.5 | ~6.9 billion/day ≈ 80,000/s |
| Catalogue | Assume 100 million tracks | — |
| Bytes per track, one bitrate | 160 kbps = 20 KB/s × 210 s | ~4.2 MB |
| All encodings per track | Several lossy tiers + lossless | ~40 MB |
| Total audio storage | 100M × 40 MB | ~4 PB (×3 for replication / regions) |
| Play events | Start, progress, end per track | Tens of billions of events/day |
The conclusions are clear. Storage of a few petabytes is manageable. Egress of several terabits per second is the dominant cost, so everything about delivery is designed to maximise CDN and device cache hits. Track starts at ~80k/s drive the metadata and licensing checks. And tens of billions of play events per day make the royalty pipeline a serious streaming system. For the method behind these numbers, see back-of-the-envelope estimation.
Step 3: API Design
GET /v1/tracks/{track_id}/playback?quality=high&format=ogg
-> { "cdn_urls": ["https://audio-cdn-1.example/ab/cd/<file_id>?sig=...&exp=..."],
"file_id": "...", "codec": "vorbis", "bitrate": 160,
"key_ref": "...", "duration_ms": 214000, "normalization_gain_db": -6.2 }
GET /v1/me/playlists -> playlists with versions
GET /v1/playlists/{id}?since_version=41 -> changes since version 41, or full list
POST /v1/playlists/{id}/tracks
{ "track_ids": [...], "after_item_id": "it_88", "base_version": 41 }
-> { "version": 42 } or 409 Conflict (client rebases and retries)
GET /v1/search?q=tum+hi+ho&types=track,artist&market=IN&limit=20
GET /v1/me/recommendations/home
POST /v1/events:batch [ {type: "play_progress", track_id, ms_played, ...}, ... ]
The playback endpoint is a metadata call, not an audio stream: it checks entitlement and licensing for the user's country, then returns signed, short-lived CDN URLs. Audio bytes flow directly from the CDN. Separating the two keeps app servers out of the multi-terabit data path.
Step 4: High-Level Architecture
Deep Dive 1: Audio Ingestion and Encoding
Labels and distributors deliver lossless masters (typically WAV or FLAC) plus metadata (ISRC codes, credits, release dates, territories, rights holders). The pipeline:
- Validate files and metadata; reject duplicates by ISRC and audio fingerprint.
- Encode into several tiers. Spotify has historically used Ogg Vorbis at roughly 96, 160 and 320 kbps, AAC for some platforms, and added lossless FLAC for premium users in 2025. Encoding is embarrassingly parallel and runs on a batch farm.
- Loudness analysis: compute normalisation gain so tracks play at consistent volume.
- Feature extraction: audio embeddings, tempo, key and energy for recommendations.
- Store each encoded file in object storage under an opaque
file_id; the catalogue mapstrack_idto the list of files per codec and bitrate. - Publish to the catalogue and search index only when the release date and territory rules allow.
Encoding once at ingestion, rather than on the fly, is the right call: the catalogue changes slowly, files are small, and on-the-fly transcoding at 80,000 starts per second would be absurd. The same reasoning applies to video in YouTube / Netflix system design, where files are far larger and adaptive bitrate per segment matters more.
Deep Dive 2: Playback, CDN and Prefetching
The playback sequence
- Check local cache. If the track (or its first chunk) is cached on the device, playback starts immediately with zero network.
- Get playback info. One API call returns signed CDN URLs and decryption key references, after entitlement and territory checks.
- Range request the start. The player fetches the first few hundred KB with an HTTP
Rangeheader and starts playing once a small buffer is filled. - Stream the rest in further range requests, keeping a buffer of tens of seconds ahead.
- Prefetch the next track when the current one nears its end (next in queue, playlist or autoplay), so the transition is gapless.
Why music caches so well
Listening follows a steep popularity curve: a small fraction of tracks accounts for a large share of plays, and new releases from top artists spike on Friday. A 4 MB file served to millions of listeners is ideal CDN content. Strategies:
- CDN edges inside ISPs (Jio, Airtel and others in India) keep traffic local and cheaper.
- Pre-warm edges with big Friday releases before midnight drops.
- Device cache: the app keeps a cache of recently played tracks (hundreds of MB to a few GB, user-configurable), so repeat listens, which are extremely common in music, cost no bandwidth.
- Adaptive quality: on poor networks pick a lower bitrate for the next track; switching mid-track is rarely worth it for audio.
Deep Dive 3: Playlists
Playlists are the core user-generated data: libraries can hold thousands of playlists, some playlists have tens of thousands of tracks, and popular editorial playlists have millions of followers.
Data model
| Table | Key | Columns |
|---|---|---|
| playlists | playlist_id | owner_id, name, description, image, collaborative flag, public flag, version, updated_at |
| playlist_items | (playlist_id, position_key) | item_id, track_id, added_by, added_at |
| user_library | (user_id, sort_key) | playlist_id or album_id, folder, pinned |
| playlist_followers | (playlist_id, user_id) | followed_at (counts maintained asynchronously) |
Ordering is the interesting part. Storing integer positions (1, 2, 3…) means inserting at position 2 renumbers every later row. Use a fractional index (a sortable string key): inserting between keys "a" and "b" generates "aV", which sorts in between. Moving a track updates one row, and appending to a 10,000-track playlist never touches the other 9,999.
Sync and collaborative edits
- Each change bumps the playlist
versionand appends to a change log. Clients sync with "give me changes since version 41". - Writes include
base_version. If the server is at a newer version, it returns 409; the client fetches the delta, re-applies its intent (for example "add track X after item it_88") and retries. Because edits are expressed relative to item IDs, not numeric positions, most concurrent edits merge cleanly. - For richer real-time collaboration, the same problem is solved with OT or CRDTs, discussed in Google Docs: OT vs CRDT. For playlists, version-based rebase is usually enough.
Storage choice
A wide-column store (Cassandra-like) fits: partition by playlist_id, cluster by position_key, so reading a playlist is one sequential partition scan. Very large playlists can be split into pages. Popular editorial playlists are read-heavy and change rarely, so serve them from a cache keyed by (playlist_id, version).
Deep Dive 4: Offline Downloads and DRM
Offline is a premium feature with licensing obligations: labels allow downloads only for paying subscribers and only inside the app.
- Download. The client fetches the encrypted file for the chosen quality, the same file the CDN serves for streaming. Files are encrypted once at ingestion with per-file content keys.
- License. The client requests the content keys from a licence service. Keys are wrapped with a device-specific key stored in the platform's secure storage (Android Keystore, iOS Keychain), so copying files to another device is useless.
- Limits. Spotify documents limits such as up to 10,000 tracks on each of up to 5 devices, and requires the app to go online at least once every 30 days. The licence carries an expiry; when it lapses, offline playback stops until the app re-validates the subscription.
- Revocation. If a track is removed from the catalogue or a subscription ends, the next online check deletes the keys or files.
- Offline play events are stored locally and uploaded later with their original timestamps, so royalty counting still sees them (this is one of the "late events" sources for the royalty pipeline).
Download management on the client matters too: resumeable downloads via range requests, Wi-Fi-only by default, storage-aware eviction, and re-download on quality change. Indian users often have low-storage phones, so showing download size and allowing SD-card storage is a real product consideration.
Deep Dive 5: Music Search
Music search is short-query, typo-heavy and popularity-driven. The query "arjit" should find Arijit Singh, "tum hi ho" should find the song whether its title is stored in Latin script or Devanagari, and "lofi" should surface popular playlists, not a 2009 obscure track that happens to contain the word.
- Index: tracks, artists, albums, playlists, podcasts in a search cluster (Elasticsearch/OpenSearch or a custom engine). The inverted index basics are in Elasticsearch explained.
- Typo tolerance: edit-distance fuzzy matching and phonetic analysers, essential for artist names.
- Transliteration: index Indian-language titles in both native script and common romanisations so Hinglish queries match.
- Prefix search for autocomplete: edge n-grams or a dedicated suggestion structure updated from popular queries; the dedicated design is search autocomplete system design.
- Ranking: text relevance blended with popularity in the user's market, freshness and personal affinity (artists the user already plays rank higher).
- Territory filtering: hide tracks not licensed in the user's country.
Deep Dive 6: Recommendations
Recommendations drive a large share of listening on modern streaming platforms through home feed shelves, radio, autoplay and personalised playlists such as Spotify's Discover Weekly. The architecture is the standard two-stage funnel:
| Stage | Purpose | Techniques |
|---|---|---|
| Candidate generation | From 100M tracks to a few thousand | Collaborative filtering (matrix factorisation or embeddings from co-listening and co-playlisting), content similarity from audio embeddings, artist graph, trending in your city |
| Ranking | Order candidates for this user now | Model predicting listen-through, save or skip; features: user history, time of day, device, recent session |
| Re-ranking / policy | Make the list good as a whole | Diversity, no repeated artist back to back, freshness, new-artist exposure, explicit-content settings |
Two kinds of pipelines run side by side. Batch jobs (daily or weekly) compute embeddings and personalised playlists for every user, writing results to a key-value store for fast reads. Real-time session models adjust radio and autoplay based on the last few skips and plays, using features streamed from the event pipeline. Cold start is handled with content features for new tracks and onboarding choices (pick some artists) for new users. The general recommender architecture is covered in depth in recommendation system design.
Interestingly, playlists are a goldmine for collaborative filtering: millions of human-curated playlists say "these songs belong together", which is often a stronger signal than raw listening.
Deep Dive 7: Counting Streams for Royalties
This is the part most candidates skip and the part that matters most to the business. Payments to labels, publishers and artists are computed from stream counts, so the pipeline must be complete, deduplicated, fraud-resistant and auditable.
Pipeline
- Emit events. The player sends
play_start, periodicplay_progressandplay_endevents with a uniqueevent_id,ms_played, track, user, device, country and the reason the track ended (skip, completed). Offline events are buffered and uploaded later. - Ingest durably. An events gateway writes them to Kafka; raw events are archived to a data lake as the source of truth.
- Qualify. A play counts as a stream once it passes 30 seconds, the commonly used industry threshold.
- Deduplicate by
event_id(retries from flaky mobile networks are common). - Filter artificial streams. Bot farms and stream-manipulation services exist; signals include accounts playing the same tracks on loop 24 hours a day, many accounts from one device or IP range, and plays just past 30 seconds. Flagged plays are excluded and can be removed retroactively.
- Aggregate per track, market, product tier and period in a batch job (stream processing powers real-time artist dashboards, but payment numbers come from the reconciled batch).
- Compute payouts and produce statements per rights holder.
The pro-rata model
Spotify describes its model as streamshare: revenue for a market and period goes into a pool, and each rights holder receives the share of the pool equal to its share of qualifying streams. Since 2024, Spotify has also required a track to reach a minimum number of streams (1,000 in the previous 12 months) before it generates recorded royalties. Royalty rules differ across services and change, so confirm current details if you build on them.
from collections import defaultdict
MIN_MS = 30_000 # a play counts as a stream after 30 seconds
MIN_TRACK_STREAMS = 1_000 # eligibility threshold (simplified to one period)
def count_streams(events, fraud_users):
"""events: (event_id, user_id, track_id, ms_played). Returns qualified streams per track."""
seen, per_track = set(), defaultdict(int)
for event_id, user, track, ms in events:
if event_id in seen or user in fraud_users or ms < MIN_MS:
continue
seen.add(event_id)
per_track[track] += 1
return dict(per_track)
def pro_rata(per_track, track_owner, pool):
eligible = {t: n for t, n in per_track.items() if n >= MIN_TRACK_STREAMS}
total = sum(eligible.values())
payout = defaultdict(float)
for t, n in eligible.items():
payout[track_owner[t]] += pool * n / total
return dict(payout)
events = [("e1", "u1", "t1", 45_000), ("e1", "u1", "t1", 45_000), # duplicate delivery
("e2", "u2", "t1", 12_000), # skipped before 30 s
("e3", "bot9", "t2", 31_000)] # flagged account
print(count_streams(events, fraud_users={"bot9"})) # {'t1': 1}
per_track = {"t1": 300_000, "t2": 900_000, "t3": 800_000, "t4": 600}
owners = {"t1": "label_A", "t2": "label_B", "t3": "label_A", "t4": "indie_C"}
print(pro_rata(per_track, owners, pool=1_000_000)) # {'label_A': 550000.0, 'label_B': 450000.0}
import java.util.*;
public class Royalties {
record Play(String eventId, String userId, String trackId, long msPlayed) {}
static final long MIN_MS = 30_000;
static final long MIN_TRACK_STREAMS = 1_000;
static Map<String, Long> countStreams(List<Play> plays, Set<String> fraudUsers) {
Set<String> seen = new HashSet<>();
Map<String, Long> perTrack = new HashMap<>();
for (Play p : plays) {
if (fraudUsers.contains(p.userId()) || p.msPlayed() < MIN_MS) continue;
if (!seen.add(p.eventId())) continue; // duplicate event
perTrack.merge(p.trackId(), 1L, Long::sum);
}
return perTrack;
}
static Map<String, Double> proRata(Map<String, Long> perTrack,
Map<String, String> owner, double pool) {
long total = perTrack.values().stream().filter(n -> n >= MIN_TRACK_STREAMS)
.mapToLong(Long::longValue).sum();
Map<String, Double> payout = new TreeMap<>();
perTrack.forEach((track, n) -> {
if (n >= MIN_TRACK_STREAMS)
payout.merge(owner.get(track), pool * n / total, Double::sum);
});
return payout;
}
public static void main(String[] args) {
Map<String, Long> perTrack = Map.of("t1", 300_000L, "t2", 900_000L, "t3", 800_000L, "t4", 600L);
Map<String, String> owner = Map.of("t1", "label_A", "t2", "label_B", "t3", "label_A", "t4", "indie_C");
System.out.println(proRata(perTrack, owner, 1_000_000)); // {label_A=550000.0, label_B=450000.0}
}
}
Dry run
- Event e1 arrives twice (a mobile retry): counted once. e2 played 12 seconds: not a stream. e3 is from a flagged bot account: excluded. Result: t1 = 1 stream.
- For the payout: t4 has 600 streams, below the 1,000 threshold, so it is excluded from both numerator and denominator. Eligible total = 300k + 900k + 800k = 2,000,000.
- label_A owns t1 and t3: (300k + 800k) / 2M = 55% of a pool of 10 lakh = ₹5.5 lakh. label_B: 900k / 2M = 45% = ₹4.5 lakh.
In production this logic runs in a batch engine over billions of rows, with money in integer minor units (paise or micro-units), not floating point, and with rounding rules defined contractually. The streaming side (late offline events, dedup, exactly-once aggregation) is the same problem as counting ad clicks, covered in ad click aggregation system design.
Bottlenecks and Trade-offs
| Decision | Trade-off |
|---|---|
| Pre-encode all tiers vs transcode on demand | More storage (cheap) vs huge CPU at play time (expensive). Pre-encode wins. |
| Bigger device cache | Less bandwidth and instant replays vs phone storage complaints |
| Aggressive prefetch | Gapless playback vs wasted data if the user skips (respect data-saver mode) |
| Real-time vs batch royalty counts | Fresh artist dashboards vs auditable payments; use both with batch as truth |
| Strong consistency for playlists | Fewer surprises vs higher latency; version-based optimistic concurrency is enough |
| Long offline licence window | Better travel experience vs weaker subscription enforcement |
Hot spots to call out: a superstar album drop at midnight (pre-warm CDN, cache catalogue and artist pages, protect the playback metadata service with caching and rate limits), and year-end "Wrapped" style campaigns, which are precomputed in batch over weeks and served from a key-value store, never computed live.
What Interviewers Look For
Bandwidth-first thinking
You calculate Tbps egress and design for CDN and device cache hits, not just storage.
Music-specific UX
Instant start, prefetching, gapless playback, offline with DRM and licence expiry.
Clean data models
Playlist ordering that does not renumber rows; versioned sync for collaborative edits.
Money pipeline
30-second rule, dedup, fraud filtering, late offline events, batch reconciliation for payouts.
Common mistakes
- Streaming audio through application servers instead of signed CDN URLs.
- Transcoding on every play.
- Ignoring licensing and territory checks in the playback path.
- Integer playlist positions that require renumbering on every insert.
- Counting every
play_startas a stream, with no dedup or fraud filtering. - Forgetting that offline plays arrive days later and must still be counted.
Follow-ups to prepare
- "How would you add podcasts?" Longer files, resume position sync, episode feeds via RSS ingestion, different royalty model (ads and subscriptions).
- "How would you build lyrics in sync?" Time-coded lyrics files fetched with the track metadata and cached like audio.
- "How do you support Spotify Connect (play on a speaker from your phone)?" A device registry and a per-user playback state service that devices subscribe to over a persistent connection; the phone is only a remote control.
Frequently Asked Questions
Master System Design Interviews
Prepflix's system design course covers media systems like Spotify, YouTube and Hotstar end to end, with mock interviews that probe CDN, storage and analytics trade-offs.
Explore the Prepflix Course →