The system design question
Design a feature for a music streaming service that shows every listener their 10 most-played songs over the last 4 weeks, updated as they listen.
Agree on the scope (5 minutes)
Say up front what you're covering.
| We'll design | We'll skip |
|---|---|
| Recording plays as they happen | Playing the music itself |
| Counting plays per listener over the last 4 weeks | Charts across all listeners |
| Keeping each listener's top 10 ready to show | Recommendations |
| Showing the list in the app | Royalties and payouts |
Then agree on what "good" means:
- Fresh: a song you just played shows up in your top 10 within a minute or so, not tomorrow.
- Fast to open: the list loads in well under 100 ms, because it's just a screen in the app.
- Close enough: counts can be off by a play now and then. Nobody audits their top 10.
- Cheap per listener: most people barely look at this screen, so we can't spend much on each of them.
Rough numbers (5 minutes)
These are assumptions, but reasonable ones: 500 million listeners a month, 200 million of them listening on a given day, about 30 plays each per day, and peaks at 3 times the average. Meta's design round is often 45 minutes, so say these fast and move on.
- ~6B
- plays a day
- ~70K
- plays a second on average
- ~200K
- plays a second at a peak
- ~250
- list views a second
| Question | Rough math | What it means |
|---|---|---|
| How many plays come in? | 200 million × 30 a day ÷ 86,400 seconds | About 70K a second, up to 200K at a peak. That's a lot of tiny writes |
| How often is the list read? | Say 20 million views a day ÷ 86,400 | A few hundred a second. Reads barely matter |
| How big are the counts? | 500 million listeners × about 28 days × 20 songs a day × 16 bytes | Around 4 TB of daily counts. It has to be split across machines |
| How big are the finished lists? | 500 million × 10 songs × about 20 bytes | About 100 GB. That fits in a cache cluster easily |
The overall design (10 minutes)
- When a song has played for 30 seconds, the player sends a play event with the listener, the song, the time and an event ID.
- A play service checks it and drops it onto an event stream, split up by listener ID so all of one person's plays go to the same place.
- Counting workers keep, for each listener, a count per song per day for the last 28 days, plus a running total per song across those days.
- After each small batch of plays, a worker works out that listener's new top 10 and saves it if it changed.
- The app asks for the listener's top 10 and gets back one small precomputed row, usually straight from a cache.
What the apps need to do
| Action | Who does it | What's sent |
|---|---|---|
| Report a play | The music player | Listener ID, song ID, when it started, how long it played and an event ID |
| Get my top 10 | The app | Just the listener, from their login |
| Get top 10 for a time range | The app, later on | Listener and a range like 'last 6 months', if we add that |
What we store and where
| Data | Where it goes | Why |
|---|---|---|
| Play events | Event stream, split by listener, kept a few days | So workers can replay if they crash |
| Daily counts per listener and song | Key-value store, split by listener | The raw material for the 4-week window |
| Running totals per listener | Same store, next to the daily counts | So we don't add up 28 days on every play |
| Top 10 per listener | Key-value store with a cache in front | What the app actually reads |
Step 1: Counting over a sliding 4 weeks (12 minutes)
A truly sliding window would drop each play exactly 4 weeks after it happened, down to the second. You'd have to remember every play's time. Nobody needs that. Ask the interviewer, and they'll almost always agree that 'last 28 days, by day' is fine.
So for each listener, keep one bucket per day. Each bucket maps song to plays that day. This is the bucketed version of a sliding window: the window moves forward a day at a time, and the oldest bucket falls off.
Adding up 28 buckets on every play would be wasteful. Instead keep a running total per song, like a sliding window over a hash map. A new play adds 1 to today's bucket and 1 to the total. When a day falls out of the window, subtract that day's bucket from the totals and delete it.
When does the window move?
Running a job at midnight over 500 million listeners is a big spike of work, mostly for people who didn't even listen that day. Do it lazily instead. Each listener's record remembers the last day it was rolled forward. When their next play arrives, or they open the screen, roll forward first: subtract any expired days, then carry on.
Pick one time zone per listener, probably theirs, so 'a day' lines up with what they'd expect.
Step 2: Keeping the top 10 fresh (10 minutes)
After a play, only one song's total went up, and it only went up. So that song is the only thing that can move into the top 10. Compare its new total with the 10th place. If it's bigger, slot it in. That's a constant amount of work per play.
When old days fall off, totals go down, and a song in the top 10 might drop below one that wasn't in it. Now you can't patch the list. Recompute it from the running totals. A listener plays maybe a few hundred different songs in 4 weeks, so picking the top 10 with a small heap is instant. It only happens once a day per active listener.
To cut down on writes, workers hold a listener's plays for a few seconds and apply them as a batch. One save per batch, not per play. The list is still updated well within the minute we promised.
- Ties: break them by whichever song was played most recently, so the list feels alive.
- Store the top 10 with its counts, so the app can show '23 plays' without another lookup.
Step 3: Scale and correctness (8 minutes)
Split the stream and the stores by listener ID, which is plain sharding. Each listener belongs to exactly one worker at a time, so there's no locking and no two workers fighting over the same totals. Unlike a global chart, there's no hot key here: one person can only play so many songs.
The player retries when the network drops, so the same play can arrive twice. Each play has an event ID, and the worker remembers recent IDs per listener for a day or so and skips repeats. That's idempotency at a small scale.
If a worker dies, a new one picks up its listeners, loads their saved state and replays the stream from the last saved position. Plays it had already applied are skipped by their IDs.
The app reads from a cache in front of the top 10 store. Workers write the new list to the store and update the cache in the same step, so a listener sees their own new play on the next refresh.
Choices you should be able to defend
| We chose | Instead of | Because | We'd reconsider if |
|---|---|---|---|
| Day buckets | An exact window down to the second | 28 buckets per listener are small and easy to expire | The product wanted 'last 24 hours' or something finer |
| Precomputing the top 10 on every batch | Working it out when the screen opens | Reads are a single lookup, and writes are already happening anyway | Very few listeners played much, so most precomputing went to waste |
| Rolling the window lazily | A nightly job over everyone | No huge spike, and no work for people who didn't listen | We needed the lists to be right for an email sent to everyone |
| Counting in our own workers | Querying a big analytics database | Fresh within a minute, and cheap per play | Freshness of a day was fine, so a daily batch job would be simpler |
What could go wrong (5 minutes)
| What happens | What we do about it |
|---|---|
| A counting worker crashes | Another takes over its listeners, reloads state and replays from the last saved stream position |
| Duplicate play events | Skipped by event ID |
| The stream falls behind at a peak | Lists are a bit stale for a while. Plays are kept, so nothing's lost. Add workers |
| Cache misses or a cache node dies | Read from the top 10 store, which is slower but still one lookup |
| Someone's phone was offline for a day and sends old plays | Put each play in the bucket for when it happened, if that day's still in the window. Drop it if not |
Follow-up questions (5 minutes)
What about a global top 10 across everyone?
That one has hot songs, so split counting across many workers, add up partial counts every few seconds, and merge. An approximate counter like count-min sketch helps if exact counts get too expensive.
Can listeners pick other ranges, like 6 months or all time?
Keep coarser buckets as data ages: days for 4 weeks, weeks for 6 months, months beyond that. Same running-total trick per range.
How would you rebuild everything if there's a counting bug?
Keep raw plays in cheap storage, run a batch job to rebuild the daily buckets, then switch workers over to the fixed state.
How the 60 minutes add up
| Minutes | What you're doing |
|---|---|
| 0–5 | Agree on what you're building |
| 5–10 | Rough numbers |
| 10–20 | Draw the overall design |
| 20–32 | Counting over a sliding 4 weeks |
| 32–42 | Keeping the top 10 fresh |
| 42–50 | Scale and correctness |
| 50–55 | What could go wrong |
| 55–60 | Follow-up questions |
Key concepts used here
- Stream processing and windowingCount things in data that never stops coming in.
- Heaps and priority queuesAlways have the smallest or largest item ready.
- Hash maps and sliding windowsSwap nested loops for a single pass.
- Caching strategiesMake reads faster, and figure out what to do when data changes.
- Sharding and consistent hashingSplit data across machines and keep it balanced.
- Idempotency and retriesMake sure a retried request doesn't happen twice.
- Message queues and event streamsKeep services from depending on each other, and handle traffic spikes.
Further reading
We wrote this sample answer ourselves.
Practice Loop is independent and isn't affiliated with or endorsed by Meta.