System design sample solution

System design: show each listener their top 10 songs

A step-by-step system design answer you could actually give in 60 minutes.

System design interview · Sample 60-minute answer · Not Meta's actual design · Independent, not affiliated with Meta

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 designWe'll skip
Recording plays as they happenPlaying the music itself
Counting plays per listener over the last 4 weeksCharts across all listeners
Keeping each listener's top 10 ready to showRecommendations
Showing the list in the appRoyalties 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
QuestionRough mathWhat it means
How many plays come in?200 million × 30 a day ÷ 86,400 secondsAbout 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,400A few hundred a second. Reads barely matter
How big are the counts?500 million listeners × about 28 days × 20 songs a day × 16 bytesAround 4 TB of daily counts. It has to be split across machines
How big are the finished lists?500 million × 10 songs × about 20 bytesAbout 100 GB. That fits in a cache cluster easily

The overall design (10 minutes)

Music playera song counts as playedPlay eventssplit by listenerCounting workersdaily counts and top 10Top 10 storeone row per listenerAppshows the list
Plays stream into workers that keep running counts per listener and save each listener's top 10 for the app to read.
  1. 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.
  2. 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.
  3. 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.
  4. After each small batch of plays, a worker works out that listener's new top 10 and saves it if it changed.
  5. 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

ActionWho does itWhat's sent
Report a playThe music playerListener ID, song ID, when it started, how long it played and an event ID
Get my top 10The appJust the listener, from their login
Get top 10 for a time rangeThe app, later onListener and a range like 'last 6 months', if we add that

What we store and where

DataWhere it goesWhy
Play eventsEvent stream, split by listener, kept a few daysSo workers can replay if they crash
Daily counts per listener and songKey-value store, split by listenerThe raw material for the 4-week window
Running totals per listenerSame store, next to the daily countsSo we don't add up 28 days on every play
Top 10 per listenerKey-value store with a cache in frontWhat 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 choseInstead ofBecauseWe'd reconsider if
Day bucketsAn exact window down to the second28 buckets per listener are small and easy to expireThe product wanted 'last 24 hours' or something finer
Precomputing the top 10 on every batchWorking it out when the screen opensReads are a single lookup, and writes are already happening anywayVery few listeners played much, so most precomputing went to waste
Rolling the window lazilyA nightly job over everyoneNo huge spike, and no work for people who didn't listenWe needed the lists to be right for an email sent to everyone
Counting in our own workersQuerying a big analytics databaseFresh within a minute, and cheap per playFreshness of a day was fine, so a daily batch job would be simpler

What could go wrong (5 minutes)

What happensWhat we do about it
A counting worker crashesAnother takes over its listeners, reloads state and replays from the last saved stream position
Duplicate play eventsSkipped by event ID
The stream falls behind at a peakLists are a bit stale for a while. Plays are kept, so nothing's lost. Add workers
Cache misses or a cache node diesRead from the top 10 store, which is slower but still one lookup
Someone's phone was offline for a day and sends old playsPut 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

MinutesWhat you're doing
0–5Agree on what you're building
5–10Rough numbers
10–20Draw the overall design
20–32Counting over a sliding 4 weeks
32–42Keeping the top 10 fresh
42–50Scale and correctness
50–55What could go wrong
55–60Follow-up questions

Key concepts used here

Further reading

We wrote this sample answer ourselves.

Practice Loop is independent and isn't affiliated with or endorsed by Meta.