Design the Spotify web player
A music web app where one player keeps playing while you browse, search, and edit playlists, with a queue you can reorder, lock screen controls, and a remote control for your other devices. The hard parts are a player that survives every page change, track changes with no silence between songs, a queue with honest shuffle and repeat, and one playback state shared by several devices.
The question
Design the web client for Spotify. A listener opens open.spotify.com, starts an album, and keeps browsing: artist pages, search, their library. The music must never stop or stutter while they click around, and when one song ends the next one should start right away.
They also manage playlists with thousands of songs, control playback from the lock screen or the keyboard's media keys, and can move playback from the browser to their phone or a speaker. Walk me through how you'd build the client, and be ready to talk about offline listening and accessibility at the end.
Real-life examples: Spotify web player (open.spotify.com), Apple Music on the web, YouTube Music, Deezer web, SoundCloud
Requirements: what to ask before drawing anything
"Design Spotify" sounds like one feature: play music. In practice the web player is a whole app. It has a home page, artist and album pages, search, a library, playlists, a queue, a player bar, a lyrics view, and a device picker. The first five minutes go to agreeing which of these matter, and the way to get there is to ask questions whose answers change the design.
Ask about the surface first (which screens), then the users (devices and networks), then the content (track length, playlist size), then the rules (who can play what, and where). The table lists the answers this guide assumes and what would move if the interviewer answered differently.
| Question | Answer we assume | What it changes |
|---|---|---|
| Which screens? | Home, search, artist and album pages, library, playlist page, queue panel, and the player bar. Podcasts and lyrics are follow-ups. | Which chapters get depth. The player bar is on every screen, so it gets the most. |
| Must music keep playing while browsing? | Yes, always. Clicking an artist page must not cause even a short gap. | Who owns the audio element: the page or the app shell. This one answer shapes the whole architecture. |
| Free and paid users? | Both. Paid users get higher quality and no ads. Free users hear ads between tracks. | Two quality levels, and an ad break as one more item type in the queue. |
| Which devices? | Desktop browsers first: Chrome, Edge, Safari, Firefox. Mobile web is out: phones get the native app. | We can assume a keyboard, a large screen, and media keys, but must handle Safari's media rules. |
| Other devices at once? | Yes. The user may have the phone app and a speaker online. Only one plays at a time, and any of them can act as a remote. | A live connection to the server and a shared playback state. |
| How big do playlists get? | Most hold under 200 tracks. Some users have Liked Songs with 10,000 or more. | Virtualized lists and paged loading, not one big render. |
| Offline? | Not on the web. Downloads are a feature of the desktop and mobile apps. | We explain why and design a short-lived cache instead. |
| Search engines and link previews? | Yes for public pages: artist, album, playlist, and track links are shared constantly. | Public pages need server-rendered HTML with real titles and artwork. |
Functional requirements
- Play, pause, skip, go back, seek, and change volume from a player bar that is visible on every screen.
- Keep playing without any gap while the user moves between pages, uses the browser's back button, or opens search.
- Start the next track the moment the current one ends, with optional crossfade between tracks.
- Manage a queue: play next, add to queue, reorder, remove, shuffle, and repeat (off, the whole context, or one track).
- Show what's playing on the lock screen and the operating system's media panel, and respond to hardware media keys.
- Show other devices on the account, move playback to one of them, and control them from the browser.
- Search with results that appear as the user types: tracks, artists, albums, playlists.
- Browse the library and open playlists of any size, reorder tracks by dragging, add and remove tracks.
Non-functional requirements
- Fast first sound: under 1 second from pressing play to hearing audio on a normal connection, under 300 ms for the next track in the queue.
- No stalls: fewer than 1 in 100 plays should pause to buffer. A track at 256 kbps is about 32 KB per second, so a 3.5 minute song is roughly 7 MB.
- Gapless: when an album is meant to flow (live records, classical, DJ mixes), there is no audible silence between tracks.
- Responsive pages: Interaction to Next Paint under 200 ms even on a playlist page with 10,000 rows.
- Content protection: paid audio is encrypted, and the client never holds a plain copy of a whole file it could save.
- Accessible: every control works with the keyboard and a screen reader, and track changes are announced.
Out of scope, and why
Recommendations and ranking are server problems: the client renders whatever shelves the server returns. Audio encoding, the catalog, and licensing deals are backend and business work. Podcasts with video, audiobooks, and live audio rooms each bring their own design. Say these out loud, so the interviewer knows you saw them and chose to leave them out.
Check your understanding · Exercise 1
Which answers from the interviewer would change the client architecture, not just a detail? Select all that apply.
What makes a music player hard
A music player looks simpler than a video site: no frames, smaller files, a short list of buttons. The difficulty is somewhere else. Music is listened to for hours with the tab in the background, it must never break, and the player is shared with the operating system and with the user's other devices. Five traits drive every decision in this guide.
- It lives for hours. A video page is often closed after one video. A music tab stays open all day while hundreds of pages are visited inside it. The player must outlive every page, and anything that leaks memory per track or per page will crash the tab by evening.
- Silence is a bug. The eye forgives a spinner. The ear does not forgive a gap. On a live album or a DJ mix even a pause of a few tens of milliseconds between tracks is clearly audible, and a stall in the middle of a chorus is worse than any slow page.
- It runs in the background. Most of the time the tab is hidden while the user works in another window. Browsers slow down timers in hidden tabs, and while Chrome spares pages that made sound in the last 30 seconds, a paused or stalled player loses that pass. Timers also drift from the audio clock, so the player cannot rely on
setTimeoutto start the next track. It must use media events and let the browser's audio pipeline do the timing. - The operating system is a second user interface. The lock screen, the media panel in the taskbar, Bluetooth headphones, and keyboard media keys all control the player. They must show the right song and artwork and send the right commands.
- One account, many players. The phone, the desktop app, a smart speaker, and the browser can all be online at once. Only one of them makes sound, the others are remotes, and the queue and position must stay in sync between them.
Two more traits come from the business and the browser. Audio is licensed, so paid quality streams are encrypted and the client must not end up with plain files it could save.
And browsers block sound the user did not ask for: under the autoplay policy, a call to audio.play() before the user has clicked or pressed a key on the page usually returns a promise that rejects with a NotAllowedError. Each browser has its own exceptions (Chrome, for example, also weighs how often the user plays media on the site), so a web player that tries to resume music on page load must handle the rejection from day one.
| Trait | What goes wrong if you ignore it | What it forces |
|---|---|---|
| Lives for hours | Each page mounts its own player; navigation cuts the sound. Listeners and blobs pile up and memory grows by megabytes per track. | One player in the app shell, created once; strict cleanup of per-track resources. |
| Silence is a bug | The next track starts loading only when the current one ends: half a second of silence at every change. | Preload the next track before the end; a playback engine that can hand over on time. |
| Runs in the background | A setTimeout meant to fire at the end of a track fires late in a hidden tab. | Timing driven by media events (ended, timeupdate) and audio clocks, not page timers. |
| The OS is a second UI | Media keys do nothing, or the lock screen shows the previous song. | The Media Session API kept in sync with the engine on every change. |
| Many players per account | Two devices play at once, or a skip on the phone is lost. | A server-owned playback state, a WebSocket, and one active device. |
| Licensed audio | Plain files in a cache can be copied out of the browser. | Encrypted media through Encrypted Media Extensions (EME) for paid streams; no offline downloads on the web. |
Think first
A teammate says: "Music is just a progress bar and a play button, we can build it like any other page component." Which single user action would you demo to show them why that's wrong?
Start a song, then click an artist link. If the player is a component inside the page, React unmounts it on navigation, the audio element is removed from the document, and the music stops.
That one click shows that the player has a different lifetime from pages. It must sit above the router and be created once per session, not once per page.
A player that survives every page change
Start with the decision everything else hangs on: who owns the audio element. The sound comes from a media element (or an audio graph, see the next chapter) that lives in the document. If that element is removed from the document or the document is thrown away, the sound stops. So the question "where does the player live?" is really "what survives a navigation?"
The naive build is a classic multi-page site: each URL is a server-rendered page, each page includes the player bar, and links are plain links. Every click unloads the whole document. The audio element is destroyed with it, and on the new page a fresh one has to fetch the track again, seek to the old position, and call play().
That costs roughly 300 ms to 2 seconds of silence on every click (our estimate), and some browsers' autoplay rules may block the restart outright, because nobody has clicked the new document yet. Twenty clicks in an evening means twenty gaps. Nobody would keep using it.
Decision Where should the player live so that music keeps playing during navigation?
What we pick: Use a single-page app with the player in the app shell. It is the only option where navigation causes no gap at all, which is the requirement that matters most. Public pages are still server-rendered on the first load for search engines and link previews, then hydrate into the app, so we keep that benefit of the first option. The frame shell solves the same problem with worse costs to the URL, search, and accessibility. See the spa-vs-mpa and routing lessons for the general trade-off.
Keep the engine outside the component tree
Putting the player bar in the root layout is not enough by itself. Components can still re-mount: a layout key changes, an error boundary resets, a provider moves during a refactor. If the audio element or the queue lives in component state, any of these restarts the music.
The safer pattern is a plain TypeScript module, the playback engine, created once per tab. React components subscribe to it and send it commands, but they never own it.
// player/engine.ts: created once, outlives every page
type Snapshot = { trackId: string | null; status: PlayerStatus; positionMs: number };
class PlaybackEngine {
private snapshot: Snapshot = { trackId: null, status: "idle", positionMs: 0 };
private listeners = new Set<() => void>();
readonly audio = new Audio(); // not in any component
getSnapshot = () => this.snapshot;
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
protected set(next: Partial<Snapshot>) {
this.snapshot = { ...this.snapshot, ...next }; // new object, so React re-renders
this.listeners.forEach((fn) => fn());
}
}
export const engine = new PlaybackEngine();
// In any component:
// const { status } = useSyncExternalStore(engine.subscribe, engine.getSnapshot);
Position is the one value that changes often: the timeupdate event fires every 15 to 250 ms depending on the browser (about 4 times a second in Chrome), and the progress bar wants 60 updates a second to move smoothly. Don't push either into React state for the whole app. Keep the coarse position in the snapshot, and let the progress bar read audio.currentTime itself inside a requestAnimationFrame loop that runs only while it is visible. The rest of the app never re-renders because of time passing.
The full page loads you didn't plan for
A single-page app only keeps the music if every navigation is a client navigation. Full loads sneak in through ordinary code, and each one stops the music. List them and close them one by one:
- A plain
atag written by hand instead of the router's link component. Add a lint rule, and make the shared link component the only way to link inside the app. - Code that sets
window.location.hrefafter an action, for example after creating a playlist. Use the router'snavigateinstead. - An expired session that makes the server answer a data request with a redirect to the login page. Handle 401 responses in the API client and refresh the token in the background; never let a data call navigate the document.
- A new deploy. Old chunk files are deleted, the next lazy route fails to load, and a common fix is
location.reload(). In a music app, wait: retry the import with the new file names from the server, or reload only when playback is paused or between tracks. - The user presses F5 or opens the app in a new tab. This can't be prevented, so it must be recovered from: the server keeps the playback state (chapter 7), and the reloaded page shows the same track paused at the same second with a big play button. It does not try to autoplay.
Check your understanding · Exercise 2
The player bar is in the root layout, yet music stops whenever the user opens the Settings page. Which causes are likely? Select all that apply.
The playback engine: how bytes become sound
The engine's job: given a track id, get its audio from the network, decrypt it if needed, decode it, and send it to the speakers, while reporting status to the UI. The browser offers three ways to do this, and they differ in who controls the bytes. The choice decides whether we can stream in small pieces, play protected content, and join tracks without gaps.
The naive engine sets audio.src to a file URL and calls play(). The browser downloads the file with HTTP range requests, decodes it, and plays. For a prototype this is perfect, but it breaks on three requirements.
First, the paid streams are encrypted. Encrypted Media Extensions (EME, covered below) can in principle work with a plain src, but browser support for that path is uneven, and protected streaming on the web is in practice built on Media Source Extensions (MSE).
Second, the app has no say in buffering: some browsers download far ahead, which wastes data when the user skips after 10 seconds, and skipping is extremely common in music. Third, the next track can only start after the current element fires ended and a new file starts loading, so there is a gap.
Decision Which browser API should play the audio?
What we pick: Use Media Source Extensions on a single media element, with EME for paid streams. It is the only option that meets all three needs: encrypted content, control over buffering, and a gapless join. Keep a plain src path for unencrypted previews and ads, where simplicity wins. Web Audio is not the engine; it is an optional layer for effects on unprotected audio.
src would leave to the browser.Streaming in chunks
With MSE the engine is a small download manager. A track is stored as fragmented MP4: an init segment (codec details, a few hundred bytes) followed by media segments of a few seconds each. The engine asks the API for the track's file list, fetches the init segment and the first media segment, appends them, and calls play() as soon as about 2 seconds are buffered.
Then it keeps a buffer goal: stay roughly 20 to 30 seconds ahead of the playhead, fetch the next segment when the buffer falls below that, and stop fetching when it is above.
- Small first fetch, larger later ones. At 256 kbps a second of audio is 32 KB. A first request of 64 KB gives 2 seconds of sound quickly. Later requests of 256 KB cut the per-request overhead.
- Start low, then step up. If the network looks slow, start the first segments at 128 kbps and switch to 256 kbps once the buffer is healthy. Audio files are small, so this matters far less than for video, but it decides first sound on a weak connection.
- Abort what's no longer needed. Each fetch gets an
AbortController. When the user skips, abort in-flight requests for the old track before starting the new one, so the old bytes don't compete for bandwidth. This is the request-races lesson applied to audio. - Respect the quota.
appendBuffercan throw aQuotaExceededErrorwhen the buffer is full. Remove played ranges withsourceBuffer.remove(start, end), keeping maybe 10 seconds behind the playhead for quick rewinds. - Try another server. If a segment fails twice from one content delivery network (CDN) host, the file list can include a second host. Switch to it before the buffer runs dry, not after.
Encrypted streams with EME, at a high level
Encrypted Media Extensions let a page play content it can't read. The decryption happens in a Content Decryption Module (CDM) that ships with the browser: Widevine in Chrome and Firefox, Widevine or PlayReady in Edge, and FairPlay in Safari. The page's part is to pass messages between the CDM and a license server.
The flow is short. navigator.requestMediaKeySystemAccess checks which key system and codecs are supported, then createMediaKeys and audio.setMediaKeys attach the CDM. The element fires an encrypted event when it sees encrypted data, the page calls session.generateRequest, and the session fires a message holding a license request. The page posts it to the license server and passes the reply to session.update; from then on the CDM decrypts each chunk as it plays.
For the design, three consequences matter more than the steps. The license request is one more network round trip before first sound, so fetch the license for the next track during preload, not when it starts. A license can fail for business reasons (the track isn't available in this country, the subscription lapsed), so treat that as its own error with its own message, not a generic "playback error".
And users can turn DRM off: Firefox has a "Play DRM-controlled content" setting, and Chrome can block protected content per site. The app must detect that requestMediaKeySystemAccess rejected and explain how to enable it, instead of showing a spinner forever. Spotify's own help pages give exactly this advice to web player users.
Check your understanding · Exercise 3
Why is decoding whole tracks with the Web Audio API a poor main engine for this product? Select all that apply.
Gapless playback, crossfade, and the player's states
Most track changes happen without anyone pressing anything: a song ends and the next begins. That moment is where a web player sounds cheap or polished. The naive engine waits for ended, then asks the API for the next track's files, fetches a license, fetches the first chunk, and plays.
Add those up on a decent connection, with assumed numbers: 100 ms for the file list, 150 ms for the license, 100 ms for the first chunk, plus decoder start. That's around 400 ms of silence at every change, and several seconds on a slow train connection.
There is a second, quieter source of gaps. Compressed formats like AAC and MP3 add a little silence at the start of every file (encoder delay: from several hundred to about 2,000 samples depending on the encoder, roughly 10 to 50 ms) and pad the end to fill the last frame.
Play two files back to back and those silences add up to an audible click or hole, even with zero network delay. The files' metadata says how many samples to trim, and only an engine that controls the timeline can trim them.
Decision How should the engine move from one track to the next?
What we pick: Always preload, and choose the handoff by context. For albums marked as gapless, append to the same buffer, because it is the only way to get zero silence. For ordinary playlists, the two-element handoff is good enough and also makes crossfade possible when the user turns it on. Never load on end. Whatever the real Spotify web player does internally is not public, so present this as your design, not as theirs.
When to start preloading
Preloading too early wastes data on tracks the user will skip; too late and the network can't finish in time. A good rule is "start when the remaining time drops below the time the next track needs to get ready, plus a margin". The engine knows both numbers: remaining time from duration - currentTime, and readiness cost from the last few requests (file list, license, first chunk).
On a good connection the cost is under a second, so starting 15 seconds before the end gives a wide margin. On a slow one it can be 4 seconds, and starting at 30 seconds is safer. Also restart the preload whenever the next track changes: the user edited the queue, toggled shuffle, or the remote device changed it.
fast Wi-Fi, preload 15 s before the end, same buffer. The next track needs 0.35 s to get ready, so it is ready at 3:15.3. Gap: 0 ms, no gap at all.
Crossfade needs both tracks playing at once for 1 to 12 seconds, with one volume falling while the other rises. For unprotected audio the smooth way is Web Audio: route each element through a GainNode and call gain.linearRampToValueAtTime, which runs on the audio thread rather than on page timers.
Protected audio is different. The EME spec lets browsers keep decrypted media away from a MediaElementAudioSourceNode, and in practice such a node then outputs silence, so routing a paid stream through an audio graph can mute it. For encrypted tracks, step each element's volume in small increments instead, or turn crossfade off. In an interview, say what you would test on each browser, not what you assume.
The player as a state machine
A player with booleans (isPlaying, isLoading, hasError, isBuffering) soon reaches combinations that make no sense: loading and playing, or error and playing. Bugs follow, like a play button that shows pause while the audio is silent. Model the player as one status with explicit transitions instead, as the state-machines lesson describes. Every media event and every user command becomes an event into the machine, and the machine decides what happens.
| Status | Entered when | What the UI shows |
|---|---|---|
idle | Nothing loaded yet, or the queue ended with repeat off. | Player bar with the last track, play button enabled. |
loading | A track was chosen; file list, license, or first chunk not ready. | Spinner on the play button after 300 ms, not before, so fast loads don't flash. |
playing | The element fired playing. | Pause button, moving progress bar. |
buffering | The element fired waiting during playback. | Progress bar keeps its place; a small spinner after 500 ms. |
paused | The user paused, or play() was blocked by the autoplay rules. | Play button. If blocked, a hint: press play to start. |
error | License refused, file missing, or repeated network failure. | A message that names the cause, then auto-skip to the next playable track. |
Check your understanding · Exercise 4
A live album has an audible click between every track, even on fast Wi-Fi with preloading on. What could explain it? Select all that apply.
The queue: play next, shuffle, repeat, and history
The queue answers one question many times a day: what plays after this? It sounds like an array with an index. Then the requirements arrive.
The user starts an album (the context), adds two songs with "Add to queue", inserts one with "Play next", turns on shuffle, and later turns it off. They expect the added songs to play before the album continues, shuffle to never repeat a song until all have played, turning shuffle off to return to album order from the current song, and the back button to go to what actually played before.
A single array fails these in specific ways. Shuffling the array in place loses the original order, so turning shuffle off can't restore it.
Inserting the user's songs into the array mixes them with the album, so after a reshuffle they move. And "previous" as index minus one goes to whatever sits before in the current order, which after a shuffle is not the song that was heard. Each is a small bug report; together they make the queue feel random in the bad sense.
Decision How should the queue be modelled?
What we pick: Use the three-part model, written as a pure reducer that both the client and the server run. The client applies a command immediately for an instant response and sends it to the server, which applies the same reducer and broadcasts the result to the user's other devices (chapter 7). Sharing one pure function is what keeps devices from drifting apart.
The rules, one by one
- Next takes the first song of
userQueueif there is one. Otherwise it moves the context cursor forward inorder. The finished song is pushed ontohistory. - Add to queue appends to the end of
userQueue. Play next inserts at the front. Neither touches the context. - Previous: if more than about 3 seconds have played, restart the current song (a common convention). Otherwise pop
history. - Shuffle on: build a random permutation of the context's indexes with a Fisher-Yates shuffle, then move the current song to the front so it keeps playing. Shuffle off: switch back to the identity order and set the cursor to the current song's original index, so the album continues from there.
- Repeat context: when the cursor passes the end, wrap to the start. With shuffle on, build a new permutation for the next round and make sure its first song is not the one that just played.
- Repeat one: at a natural end, replay the same song. When the user presses next, move on; the mode applies to the end of a song, not to the button.
- Unplayable songs (not available in this country, removed) are skipped by the reducer, not by the UI, so every device agrees on what plays.
type Repeat = "off" | "context" | "one";
type QueueState = {
history: string[]; // track ids that really played
current: string | null;
userQueue: string[]; // added by the user, plays first
context: { uri: string; trackIds: string[]; order: number[]; cursor: number };
shuffle: boolean;
repeat: Repeat;
seed: number; // so a shuffle can be replayed on every device
};
type QueueEvent =
| { type: "next"; reason: "ended" | "skip" }
| { type: "previous"; positionMs: number }
| { type: "addToQueue"; trackId: string }
| { type: "playNext"; trackId: string }
| { type: "setShuffle"; on: boolean; seed: number }
| { type: "setRepeat"; repeat: Repeat };
export function reduceQueue(state: QueueState, event: QueueEvent): QueueState {
// switch on event.type; never mutate, always return a new state
return state;
}
Note the seed. If the server and the client each call Math.random(), they produce different shuffles, and the phone shows a different "next up" from the browser. A seeded random generator, with the seed chosen once and sent along with the shuffle command, makes the order the same everywhere. It also makes bug reports reproducible: the seed is enough to rebuild the exact order.
An album of 8 tracks, t1 playing. Try Add to queue, then shuffle, then Next.
Check your understanding · Exercise 5
Shuffle is on, the user has two songs in Next in queue, and they turn shuffle off. What should happen? Select all that apply.
Lock screen, media keys, and the Media Session API
A music tab spends most of its life hidden. The user controls it from outside the page: the play key on the keyboard, the buttons on Bluetooth headphones, the media panel in the Windows taskbar or the macOS menu bar, the lock screen on a laptop. Without any work the browser does something, but not enough: it may show the page title and the site icon, and only play and pause work. Next and previous do nothing, and the panel says "Spotify - Web Player" instead of the song.
The Media Session API is the bridge. Through navigator.mediaSession the page tells the operating system what is playing (title, artist, album, artwork), which actions it supports, and where playback is, and the operating system sends actions back. It is supported in current Chrome, Edge, Firefox, and Safari, and desktop browsers generally deliver hardware media keys to the page as these same actions.
export function connectMediaSession(engine: PlaybackEngine) {
if (!("mediaSession" in navigator)) return;
const ms = navigator.mediaSession;
const on = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
try { ms.setActionHandler(action, fn); } catch { /* action not supported here */ }
};
on("play", () => engine.play());
on("pause", () => engine.pause());
on("nexttrack", () => engine.next("skip"));
on("previoustrack", () => engine.previous());
on("seekto", (d) => engine.seek((d.seekTime ?? 0) * 1000));
engine.on("trackstarted", (t) => {
ms.metadata = new MediaMetadata({
title: t.name, artist: t.artists.join(", "), album: t.album,
artwork: t.images.map((i) => ({ src: i.url, sizes: `${i.size}x${i.size}`, type: "image/jpeg" })),
});
});
engine.on("statechange", (s) => {
ms.playbackState = s.status === "playing" ? "playing" : "paused";
if (s.durationMs) ms.setPositionState({ duration: s.durationMs / 1000, position: Math.min(s.positionMs, s.durationMs) / 1000, playbackRate: 1 });
});
}
trackstarted event, not when the next track was chosen or preloaded, so the lock screen always matches what the user hears.The rules that keep the lock screen honest
- Update metadata when a track starts, not when it is preloaded. The preload happens 15 to 30 seconds early. If the adapter listens to "next track chosen", the lock screen shows the next song while the current one is still playing.
- Give several artwork sizes. The system picks the size it needs: small for a taskbar thumbnail, large for a lock screen. Sizes like 64, 300, and 640 pixels cover most cases, and they are the same images the app already shows, so they are likely cached.
- Send position state on changes, not on every tick. The system draws its own progress bar by extrapolating from the last
positionandplaybackRate. CallsetPositionStateon play, pause, seek, track start, and rate change. Calling it 4 times a second wastes work. Andpositionmust never exceedduration, or the call throws. - Position is per track, not per element. With gapless appending, the media element's timeline holds several tracks back to back, so
audio.currentTimemight be 412 seconds into a 200 second song. The engine converts element time to track time before reporting it anywhere. - One path for commands. The media key handler calls the same
engine.next()as the on-screen button and as the remote command from the phone. Three entry points, one implementation, so behavior can't drift. - Register handlers once, at startup. Registering in a component's effect re-registers on every mount, and an unmount can remove the handler while music still plays, leaving the media keys dead.
One more subtlety: the operating system only routes media keys to a page that is actually playing, or played recently. After a reload the new page has played nothing, so the keys may go to another app until the user presses play once in the page. That is one more reason the reload path shows a clear play button instead of trying to resume silently.
Check your understanding · Exercise 6
Users report that the lock screen shows the next song about 20 seconds before it actually starts. What's the most likely cause, and what else might be wrong? Select all that apply.
One account, many devices: remote control and handoff
The listener is at their desk with music playing in the browser. Their phone shows the same song with a moving progress bar. They tap pause on the phone and the browser goes quiet.
Later they press "play on Kitchen speaker" in the browser, and the song continues on the speaker from the same second. This is what Spotify calls Connect, and it changes what the web client is: no longer the only owner of playback, but one of several devices that share one playback state.
The naive version lets every device keep its own state and poll the server every few seconds to see what the others are doing. It fails in three ways.
Commands feel slow: a pause from the phone waits up to one polling interval, so 2 to 5 seconds of music after pressing pause. Two devices can both believe they are playing, because nobody decides who is active. And a busy account of five devices polling every 2 seconds adds 150 requests a minute per user for nothing, almost all of them returning "no change".
Decision How should devices on one account share playback?
What we pick: Use server-owned state pushed over a WebSocket. A browser can't do local discovery anyway, and only a central authority can enforce one active device. The WebSocket also carries library changes and other live updates, so it is not a cost paid only for this feature. See the websockets lesson for heartbeats and reconnects. Spotify's internal Connect protocol isn't public, but its public Web API exposes the same ideas: a list of devices, a playback state, and a transfer playback call. Its Web Playback SDK even turns a browser tab into one more Connect device.
What the shared state holds
type PlaybackState = {
version: number; // bumped by the server on every change
activeDeviceId: string | null;
trackId: string | null;
isPlaying: boolean;
positionMs: number; // position at the moment below
positionAt: number; // server time, ms since epoch
queue: QueueState; // the reducer state from the queue chapter
volume: number; // 0 to 1, of the active device
};
// On a remote: where is the song right now?
function livePosition(s: PlaybackState, serverNow: number, durationMs: number) {
if (!s.isPlaying) return s.positionMs;
return Math.min(durationMs, s.positionMs + (serverNow - s.positionAt));
}
serverNow matters. A laptop's clock can be seconds off from the server's. When the WebSocket connects, the server sends its time; the client stores the difference and adds it to Date.now(). Without that, a remote's progress bar can be several seconds ahead of the sound.
Commands and handoff
- A remote command (pause on the phone while the browser plays) is sent to the server with the version the remote last saw. The server forwards it to the active device. The browser's engine pauses and reports its new state; the server bumps the version and broadcasts it. The phone updates its button right away (optimistically) and corrects itself if the broadcast says otherwise.
- A transfer (play on the Kitchen speaker) asks the server to make another device active. The server tells the new device to start at the current position and waits for it to confirm that it is playing. Only then does it tell the old device to stop, so a failed transfer never leaves the user in silence.
- A failed transfer: the speaker is offline or doesn't confirm within about 5 seconds. The server keeps or restores the old active device and the browser shows "Couldn't connect to Kitchen speaker". Music should not end up stopped everywhere.
- Playing locally while another device is active is just a transfer to this browser. Treat the press of play as the user's intent: the other device pauses.
- Reconnecting: after a dropped WebSocket, the client reconnects with backoff and jitter, fetches the full state once, and then applies broadcasts again. Any broadcast with a version lower than what the client has is ignored.
Check your understanding · Exercise 7
The phone shows the progress bar 4 seconds ahead of the music playing in the browser. Which fixes address it? Select all that apply.
Search with instant results
Search in a music app is used differently from a web search box. People type a few letters of an artist or a half-remembered lyric, glance at the results, and play something within seconds. The results page is grouped: a top result, songs, artists, albums, playlists. Each group comes from the same request, and the user can press play on a song right from the results without leaving the page, while the current track keeps going until they do.
The mechanics of the search box are the ones the autocomplete guide builds in depth, and the same numbers apply here: debounce keystrokes by about 200 ms with a maximum wait of about 1 second, cancel the previous request with an AbortController when a new one starts, ignore any answer that isn't for the latest query, and keep a small in-memory cache keyed by the normalized query so that deleting a letter shows the earlier results instantly.
The debounce-throttle and request-races lessons explain why each piece is there. This chapter covers only what a music app adds on top.
- The query lives in the URL.
/search/daft%20punkcan be shared, bookmarked, and restored by the back button. Update it withhistory.replaceStatewhile typing, so each keystroke doesn't add a history entry, and push one entry when the user opens a result. - One request, grouped results, small limits. Ask for maybe 5 songs and 6 of each other type, enough to fill the first screen. A "See all songs" link opens a paged list that uses the same virtualized list as playlists.
- Prefetch on intent. When the pointer rests on a result for about 100 ms, prefetch that album or artist page's data. When it lands on a song, ask the engine to warm up that track (file list and license), so a click starts sound fast.
- Empty box, useful screen. Before typing, show recent searches (kept in
localStorage, capped at about 10, removable one by one) and genre tiles. These need no request while typing starts. - Play from results is a new context. Pressing play on a song in results starts a context of that song (and possibly its album). It must not silently wipe the user's Next in queue list.
| Moment | What the client does | Cost |
|---|---|---|
User types d, a, f | Debounce holds the request; no network. | 0 requests. |
Pause of 200 ms after daf | Fetch /search?q=daf; cache the answer under daf. | 1 request, about 5 to 15 KB of JSON. |
Types t within 100 ms of the reply | Abort nothing (done); debounce again. | 0 requests yet. |
Pause after daft | Fetch daft, abort if another key comes first. | 1 request. |
Deletes the t | Show cached daf results instantly, refresh in the background if older than 60 s. | 0 or 1 request. |
| Hovers a song for 100 ms | Engine warms the track: file list and license. | 1 to 2 small requests. |
Check your understanding · Exercise 8
Which choices fit search in this music app? Select all that apply.
Library, playlists, and 10,000-track lists
Most playlists are short, but the lists users care most about are long. Liked Songs grows by a few tracks a week for years, and heavy listeners can pass 10,000. Those lists must open fast, scroll smoothly, accept drag to reorder, and reflect edits immediately.
The naive page fetches the whole list and renders a row for each track. A row with artwork, title, artist, album, date added, a duration, and a heart is about 20 DOM nodes. Ten thousand rows is 200,000 nodes: it can take seconds to render on a mid-range laptop, use hundreds of megabytes of memory, and slow every later interaction with style and layout work over a huge tree. The page is unusable before the user sees anything.
Decision How should a 10,000-track playlist be loaded and shown?
What we pick: Use a virtualized list with ranges loaded on demand, as the virtualization lesson describes. With fixed 56 pixel rows and a 900 pixel viewport, about 16 rows are visible; with 10 rows of margin on each side, the DOM holds about 36 rows, which renders in a few milliseconds. Fixed row heights make the math trivial and are a natural fit for a track list.
Loading ranges
Spotify's public Web API pages playlist items with offset and limit; we use pages of 100, a size that keeps requests small and the page map short. Keep a map from page number to tracks. When the visible range changes, compute which pages it touches, and fetch the missing ones (at most 2 or 3 in flight).
Jumping to the bottom of a 10,000-track list with the scrollbar touches page 99 only. Rows for pages not yet loaded render as gray placeholders of the same height, so nothing jumps when the data arrives. Pages that are far from view can be dropped from memory for very long lists, but at about 1 KB per track, 10,000 tracks is about 10 MB, so most apps keep them.
Accessibility needs explicit help, because most rows aren't in the DOM. Render the list as a grid (or table) with aria-rowcount set to the full count (plus one for the header row), and give each rendered row its aria-rowindex. A screen reader can then announce a row's place in the full list ("row 5,013 of 10,001", counting the header) even though only 36 rows exist.
Keep keyboard focus on a row that stays rendered: when the user arrows past the edge, scroll first, then move focus. The aria-patterns lesson covers grid keyboard rules.
Drag to reorder, optimistically
Reordering has three traps in a long list. The drop target may be 3,000 rows away, so the list must scroll automatically when the pointer nears the top or bottom edge. The dragged row may scroll out of the rendered window, so draw the dragged item in an overlay that doesn't depend on its row staying mounted. And the same song can appear twice in a playlist, so rows need their own item id: "move the song with id X" is ambiguous, "move row item 7f3c" is not.
The edit itself is optimistic (see the optimistic-updates lesson): move the row in the local list at once, then send the change. Spotify's Web API describes a reorder as a range start, a range length, and an insert-before position, plus a snapshot_id: a version string of the playlist that the change is made against. In our design the server uses it to detect that someone else changed a collaborative playlist in the meantime, and answers 409 if so.
On success, store the new snapshot. On failure, put the row back, show a short message, and refetch the affected pages. Offer a keyboard path too: a row menu with "Move up" and "Move down", and announce the new position through a live region (an element whose changes screen readers read out).
Hearts, adds, and removes
- The heart (save to Liked Songs) flips at once. If the user taps it three times quickly, send only the final state after a short pause, so the server sees one change, not three that may arrive out of order.
- Add to playlist from a menu appends locally and shows a toast with Undo. If the song is already there, ask before adding a duplicate.
- Remove hides the row at once and keeps the item in memory for the Undo window (about 5 seconds), so undo doesn't need a round trip.
- Changes from other devices arrive over the same WebSocket as playback changes, as small events (playlist X has a new snapshot). The client refetches the affected pages only if that playlist is open or cached.
Check your understanding · Exercise 9
A user drags a song in a long, collaborative playlist, and the request fails because a friend edited the playlist a moment ago. What should the client do? Select all that apply.
Architecture: the pieces and their boundaries
With the hard decisions made, the architecture almost draws itself. The key idea is two lifetimes.
Some pieces live as long as the tab: the shell, the playback engine, the queue, the device connection, the caches. Others live as long as a page: the artist page, the playlist page, search results. Long-lived pieces must never depend on short-lived ones, and short-lived pieces talk to long-lived ones through small, explicit interfaces.
| Piece | Lives for | Responsibility | Why the boundary is here |
|---|---|---|---|
| App shell | The tab | Layout: sidebar, header, player bar, route outlet, error boundary around the outlet. | Navigation swaps only the outlet, so the player is never unmounted. |
| Routes (pages) | One page | Home, search, artist, album, playlist, library. Loaded with code splitting. | Each page can fail, load, or be replaced without touching playback. |
| Playback engine | The tab | MSE and EME, buffering, preload, the player state machine, track-time conversion. | Plain TypeScript, outside React, so no re-render or re-mount can stop the audio. |
| Queue reducer | The tab | History, user queue, context order, shuffle seed, repeat. | A pure function shared with the server, so all devices compute the same next track. |
| Connect client | The tab | One WebSocket: playback state, remote commands, library change events, clock offset. | One connection for all live data, reconnect logic in one place. |
| Media Session adapter | The tab | Metadata, action handlers, position state. | The only code touching navigator.mediaSession, driven by engine events. |
| API client and server cache | The tab | Fetch with auth, retries, and cancellation; a cache of tracks, albums, playlists, keyed by id. | Pages read the same cached entities, so an album opened twice loads once. |
| Service worker | Across visits | Caches the app's code and artwork; never caches audio. | Fast repeat startup and fewer image requests; audio stays under DRM rules. |
| Telemetry | The tab | Start-up time, time to first sound, stalls, errors, sent in batches. | Independent of UI, so it reports even when a page crashes. |
Commands flow one way. A button in the player bar, a media key, a keyboard shortcut, and a command arriving from the phone all call the same small API: engine.play(), engine.next(), queue.addToQueue(id). The engine and queue change their state and emit events.
Components, the Media Session adapter, the Connect client, and telemetry all subscribe to those events. Nothing reaches into the audio element directly except the engine.
Follow the data for the main screen, a playlist page while music plays. The first load is server-rendered: the HTML holds the playlist title, artwork, and the first rows, so link previews and search engines see real content, and the user sees the page before JavaScript finishes. The app then hydrates and takes over.
Playlist pages come from the API through the server cache. Artwork comes from the image CDN through the service worker's cache. Audio chunks come from the audio CDN through the engine's fetches, straight into the source buffer, never touching React.
Licenses go between the CDM and the license server. Live changes arrive on the WebSocket and update the queue, the player bar, or the cache. Writes (reorder, heart, add) go up through the API client with an optimistic local change, and come back to the other devices as WebSocket events.
Check your understanding · Exercise 10
Which of these placements break the lifetime rule? Select all that apply.
Data model: entities and where each one lives
A music app shows the same few things in many places. One song appears in a playlist row, in search results, in the queue panel, in the player bar, on the lock screen, and on the artist page. If each of those keeps its own copy, a change (the user hearts it, its artwork loads, it becomes unavailable) has to be found and updated in six places, and some will be missed. So the client stores each entity once, by id, and lists hold ids.
The second question is where each piece of data lives, and the answer comes from who owns it and how long it must last. Server data goes in a normalized cache. Playback state is owned by the server and mirrored by the engine.
Things the user expects to survive a reload but the server doesn't care about (volume, sidebar width, recent searches) go in localStorage. Things that describe where the user is (the page, the search query) go in the URL.
type Image = { url: string; size: 64 | 300 | 640 };
type Track = {
id: string; name: string; durationMs: number;
artistIds: string[]; albumId: string;
explicit: boolean; playable: boolean; // false if not licensed here
};
type Album = { id: string; name: string; artistIds: string[]; images: Image[]; trackIds: string[]; gapless: boolean };
type Artist = { id: string; name: string; images: Image[] };
type PlaylistItem = { itemId: string; trackId: string; addedAt: string; addedBy: string };
type Playlist = {
id: string; name: string; ownerId: string; images: Image[];
snapshotId: string; // version for safe edits
total: number; // for aria-rowcount and scroll height
pages: Record<number, PlaylistItem[]>; // page index -> 100 items
};
type Device = { id: string; name: string; type: "computer" | "phone" | "speaker"; volume: number; isActive: boolean };
| Data | Where it lives | Why there |
|---|---|---|
| Tracks, albums, artists | Normalized server cache, by id | Shared across pages; a heart or availability change updates every view. |
| Playlist items | Server cache, by playlist and page | Paged loading for long lists; snapshotId guards edits. |
| Playback state and queue | Server, mirrored in the engine and queue reducer | Shared by devices; the server decides the active device. |
| Engine status, buffer, position | Engine module (memory) | Changes many times a second; only the engine and progress bar need it. |
| Devices list | Connect client (memory) | Live data from the WebSocket; meaningless after a reload. |
| Current page and search query | URL | Shareable, works with back and forward, restored on reload. |
| Volume, sidebar width, recent searches | localStorage | Personal, small, survives reloads, and the server doesn't need it. |
| App code and artwork | Service worker cache | Faster repeat visits; images are public and cheap to cache. |
| Audio | Only the source buffer, briefly | Licensing: no persistent copies on the web. |
Check your understanding · Exercise 11
Which data belongs in the URL? Select all that apply.
Interface: the API, the socket, and the player's props
The client talks to the backend in three ways, each chosen for its traffic. Catalog and library data is read far more than it changes and is cacheable, so it uses plain REST over HTTP with JSON. Audio and artwork are large, public or encrypted static files, so they come straight from CDNs.
Live playback state and commands need low delay in both directions, so they use the WebSocket. Keep these separate: a slow playlist request must never delay a pause command.
GET /v1/playlists/{id}?fields=meta -> { id, name, images, snapshotId, total }
GET /v1/playlists/{id}/items?offset=200&limit=100
-> { items: PlaylistItem[], tracks: Track[], offset: 200, total: 10432 }
PUT /v1/playlists/{id}/items/reorder
{ rangeStart: 41, rangeLength: 1, insertBefore: 3, snapshotId: "abc" }
-> 200 { snapshotId: "abd" } | 409 { error: "stale_snapshot" }
GET /v1/search?q=daft&types=track,artist,album,playlist&limit=6
PUT /v1/me/library/tracks { ids: ["t1"] } -> 204
GET /v1/tracks/{id}/playback
-> { files: [{ codec: "mp4a.40.2", kbps: 256, urls: [cdnA, cdnB] }],
license: { keySystem, url }, gapless: { delaySamples: 2112, paddingSamples: 0 } }
POST /v1/license (binary body from the CDM) -> binary license
Errors use one shape everywhere: an HTTP status plus a body like { error: "track_unavailable", message, retryable }. The client maps codes to behavior, not just to text.
track_unavailable makes the queue skip the track. stale_snapshot makes a playlist edit roll back and refetch. rate_limited waits for the Retry-After header. A 401 triggers one token refresh and a retry, never a page redirect, because a redirect would stop the music.
// client -> server
type ClientMsg =
| { type: "hello"; deviceId: string; name: string; deviceType: "computer"; lastVersion?: number }
| { type: "command"; id: string; baseVersion: number; command:
| { kind: "play" | "pause" | "next" | "previous" }
| { kind: "seek"; positionMs: number }
| { kind: "transfer"; toDeviceId: string; play: boolean }
| { kind: "queue"; event: QueueEvent } }
| { type: "report"; state: Pick<PlaybackState, "trackId" | "isPlaying" | "positionMs"> }
| { type: "ping"; t: number };
// server -> client
type ServerMsg =
| { type: "welcome"; serverTime: number; state: PlaybackState; devices: Device[] }
| { type: "state"; state: PlaybackState } // full state, small enough
| { type: "devices"; devices: Device[] }
| { type: "command"; id: string; command: Extract<ClientMsg, { type: "command" }>["command"] }
| { type: "ack"; id: string; ok: boolean; error?: string }
| { type: "library"; playlistId: string; snapshotId: string }
| { type: "pong"; t: number; serverTime: number };
Sending the whole playback state on each change (a few KB at most, since the queue sends only ids and the next 50 or so entries) is simpler than sending patches, and changes happen at human speed: a few per minute. Every command has an id, so the server's ack can be matched to it and the client can roll back an optimistic button state if the command fails.
The player bar's component interface
The player bar is a set of small components that read from the engine and send commands to it. They take almost no props, because the engine is their data source. What matters is the events they emit and the state they show:
| Component | Reads | Emits |
|---|---|---|
PlayPauseButton | status | engine.toggle() |
SkipButtons | queue has next, has previous | engine.next("skip"), engine.previous() |
ProgressSlider | track position and duration (own animation frame loop) | engine.seek(ms) on release, not on every move |
VolumeSlider | volume, muted | engine.setVolume(v), saved to localStorage |
ShuffleRepeat | shuffle, repeat | queue.setShuffle(on, seed), queue.setRepeat(mode) |
DevicePicker | devices, active device | connect.transfer(deviceId) |
NowPlaying | current track and artwork | navigation to album or artist |
Check your understanding · Exercise 12
Why does the progress slider call engine.seek only when the user releases it? Select all that apply.
Fast starts, caching, and why the web has no offline mode
Two moments decide whether the player feels fast. The first is opening the app: how soon the user sees their library and can press play. The second is pressing play: how soon they hear sound. They have different budgets.
For opening, the Core Web Vitals give clear targets: Largest Contentful Paint under 2.5 seconds, Interaction to Next Paint under 200 ms, Cumulative Layout Shift under 0.1, measured at the 75th percentile of real users. For first sound, there is no standard metric, so we set our own: under 1 second at the 75th percentile for a cold play, under 300 ms for a track that was warmed up.
Time to first sound
Break a cold play into its steps and attack each one. Asking the API for the track's files and license details takes one round trip, about 100 ms. The license request takes another, about 150 ms. The first audio chunk takes a third, plus its download time, about 100 ms for 64 KB on a decent connection.
Decoder start is tens of milliseconds. That's around 400 ms if nothing else is in the way, but cold connections add a DNS lookup, a TCP connect, and a TLS handshake to each new host, which can double it.
- Warm the hosts. Add
link rel="preconnect"for the audio CDN and the license server in the shell's HTML, so the first play doesn't pay for new connections. - Warm the track on intent. Hover over a play button, focus on a row, or the next track in the queue: fetch the playback info and license before the click. Then the click costs only the first chunk.
- Run steps in parallel. The license request and the first chunk don't depend on each other: start both as soon as the playback info arrives.
- Restore the player bar instantly. On startup, show the last track from a small
localStoragesnapshot while the real playback state loads over the WebSocket. The bar doesn't pop in late, and no layout shifts. - Keep the shell small. The shell, the router, the engine, and the player bar are in the first bundle; every page is its own chunk. Artwork has explicit width and height so rows don't shift when images load.
Caching artwork and metadata
Artwork is the heaviest thing on most screens and almost never changes. Ask the image CDN for the size each place needs: 64 pixels for list rows, 300 for cards, 640 for the big header. Artwork URLs that change whenever the image changes can be cached forever with Cache-Control: max-age=31536000, immutable. A service worker adds a cache-first rule for those image URLs with a cap, for example the 500 most recent images, so the library and the recently played shelf draw instantly on a repeat visit, as the service-worker-caching lesson shows.
Metadata (tracks, albums, playlists) goes in the normalized cache with stale-while-revalidate: show what's cached at once, refetch in the background when it's older than about a minute. The library list in the sidebar is worth persisting to IndexedDB, so a cold start shows the user's playlists before the network answers. Audio is never put in the service worker cache: the bytes are licensed, often encrypted, and large.
Staying fast for eight hours
A tab open all day must not grow. Remove played ranges from the source buffer. Call URL.revokeObjectURL on each MediaSource URL when it is replaced. Close each track's MediaKeySession when the track is done.
Cap the in-memory caches with a least recently used rule. Unsubscribe engine listeners when components unmount. A useful test: play a 200-track playlist overnight with a script clicking through pages, and compare memory at the start and the end. Growth of a few megabytes is fine; growth of hundreds is a leak.
Offline listening: an app feature, not a web one
Decision Should the web player support downloading music for offline listening?
What we pick: Ship no offline playback on the web, with a read-only cached shell and a clear offline state. This matches what Spotify does: music downloads are a Premium feature of the desktop and mobile apps, and even there the apps must go online at least once every 30 days to keep them. The constraints are real: licensing needs tight control over offline copies, and the web's storage and license persistence can't give that control reliably across browsers.
Check your understanding · Exercise 13
First sound takes 1.8 seconds on the first play of a session and 400 ms afterwards. Which changes would cut the first play's time? Select all that apply.
Accessibility and observability
A player bar is a dense row of small controls that change state constantly, which is exactly where accessibility tends to break. And a player's worst failures (a stall, a gap, a license error in one country) happen on users' machines, not in tests, so the team only learns about them through measurement. Both deserve design time, not a checklist at the end.
Controls that say what they do and what state they're in
- Real buttons. Every control is a
buttonelement, so it is focusable and works with Enter and Space without extra code. Each has a text label, since most are icons. - Play and pause: change the label, not a pressed state. One button whose accessible name switches between "Play" and "Pause" says what pressing it will do. Don't combine a changing label with
aria-pressed; a screen reader would announce "Pause, pressed", which is confusing. - Shuffle: a toggle. A fixed label "Shuffle" with
aria-pressedtrue or false fits perfectly: it's on or off. - Repeat has three states, which
aria-pressedcan't express. Name the current mode in the label: "Repeat: off", "Repeat: all", "Repeat: one". Pressing it moves to the next mode. - Sliders that speak human. The seek bar and the volume are sliders (
input type="range"or the slider role) with min, max, and current value. A rawaria-valuenowof 83 means nothing, so addaria-valuetext: "1 minute 23 seconds of 3 minutes 45 seconds" for the seek bar, "60 percent" for volume. Arrow keys move the seek bar by 5 seconds, Home and End go to the start and end. - Calm updates. The seek bar's value changes every second. Update its
aria-valuetextat that coarse rate, not on every animation frame, so a focused slider doesn't flood the screen reader.
Announce track changes. A sighted user sees the title change in the player bar; a screen reader user hears nothing unless the app tells them. Keep a visually hidden polite live region in the shell and write "Now playing: Song by Artist" into it when a track starts. Polite means it waits until the screen reader is done speaking, so it doesn't interrupt the user mid-sentence. Announce the same way after keyboard reorders ("Moved to position 3 of 120") and after add or remove with Undo.
Keyboard shortcuts make the player usable without a mouse: Space for play and pause when focus is not in a text field or on a button (otherwise the button's own Space would trigger twice), modifier plus arrow keys for next and previous, and a ? key that opens a dialog listing all shortcuts. WCAG's character key shortcuts rule says single-key shortcuts must be possible to turn off or remap, or work only when the relevant control has focus, because speech input users can trigger them by accident.
On every client navigation, move focus to the new page's heading so screen reader users know the page changed, as the routing lesson explains. And respect prefers-reduced-motion: the animated bars on the playing row become a static icon.
Check your understanding · Exercise 14
Which player bar choices are accessible? Select all that apply.
Observability: measure what the ear notices
Standard web metrics (LCP, INP, CLS per route) cover the pages, and the real-user-monitoring lesson explains how to collect them. A music player needs its own metrics on top, measured in real sessions, because a lab test on fast Wi-Fi will never show a stall on a hotel network. Aggregate on the client: one compact record per play, not one event per timeupdate, sent in batches and flushed with navigator.sendBeacon when the page becomes hidden.
| Metric | How it's measured | Target and what it reveals |
|---|---|---|
| Time to first sound | From the play command to the element's playing event, tagged cold or warm. | Under 1 s cold and 300 ms warm at the 75th percentile. Slow license server or CDN by region. |
| Stall rate | Share of plays with at least one waiting event after playback began. | Under 1 in 100 plays. Buffer goal too small, a bad CDN host. |
| Stalled time ratio | Total time stalled divided by total time playing. | Well under 1 percent. How bad the stalls are, not just how many. |
| Track change gap | From the previous track's end to the next track's first audio. | Near zero on gapless albums. Preload starting too late. |
| Start failures | Failed plays by code: license denied, network, decode, autoplay blocked. | A spike in one code points to one cause, like an expired certificate. |
| Remote command delay | From sending a command to its ack over the WebSocket. | Under 300 ms at the 75th percentile. Socket trouble, slow devices. |
| Memory over time | Sampled heap size at hour 1 and hour 8 of long sessions, where the browser exposes it. | Flat is healthy; steady growth is a leak. |
Running it in the interview
Forty-five minutes is short for an app this big. The way to use it well is to show early that you know where the difficulty is (a player that outlives pages and never goes silent), spend most of the time there, and touch the rest with one sharp sentence each. Here is one plan that works.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Ask the questions from chapter 1 and agree on scope. | "Must music keep playing while browsing?" first. Write the non-functional numbers on the board: first sound under 1 s, no gaps, 10,000-track lists. |
| 5 to 12 | Draw the architecture. | App shell with the player bar and route outlet. The engine, queue, and Connect client outside React. Say why: two lifetimes. |
| 12 to 22 | The playback engine. | MSE plus EME, chunks and a buffer goal, preload of the next track, the gapless append. Draw the player state machine. |
| 22 to 30 | The queue and devices. | The three-part queue with a seeded shuffle; server-owned playback state over a WebSocket with one active device and timestamped position. |
| 30 to 38 | Lists and interface. | Virtualized playlist with pages of 100, optimistic reorder with a snapshot id; the main REST calls and socket messages. |
| 38 to 45 | Optimizations and follow-ups. | Time to first sound, Media Session, accessibility, observability. Explain why offline is app-only. |
Say decisions out loud with their reason, in one breath: "The engine is a plain module outside React, because a re-mount would stop the music." "Shuffle uses a seed, so every device computes the same order." Interviewers listen for the reason more than the choice. When you make up a number, say so: "I'll assume 256 kbps, so about 32 KB a second."
Follow-up questions to expect
Think first
How would you add audio ads for free users?
Model an ad as one more item type the queue reducer can insert between tracks, decided by the server so it can't be skipped by editing the client. The engine plays ads through the plain src path, since ads aren't encrypted.
While an ad plays, the skip button is disabled with a reason in its label, the Media Session shows the ad's metadata, and telemetry records that the ad was heard to the end.
Think first
How would you add time-synced lyrics that highlight the current line?
Fetch lyrics as a list of lines with start times when a track starts. Drive the highlight from the engine's track-time position, read in an animation frame loop only while the lyrics view is open, and find the current line with a binary search.
Respect reduced motion for the auto-scroll, and don't put each line change in a live region: it would talk over the music for screen reader users. Offer the full text as a plain list instead.
Think first
The network drops for 20 seconds in the middle of a song. What does the user experience?
With a 20 to 30 second buffer goal, usually nothing: the song keeps playing from the buffer. The engine retries chunk fetches with backoff; the WebSocket reconnects and fetches the full playback state when it can.
If the buffer runs dry, the state machine moves to buffering, the UI shows a small spinner after 500 ms, and playback resumes on its own when chunks arrive. Commands made while offline (a skip) apply locally and are sent with the version they were based on.
Check your understanding · Exercise 15
You have 10 minutes left and haven't covered devices or accessibility. What's the strongest use of the time? Select all that apply.
Summary
- Decide first who owns the audio: a playback engine created once per tab, outside React, under an app shell that navigation never unmounts.
- Use Media Source Extensions with chunked fetches and a buffer goal; add EME for paid streams; keep the plain
srcpath for ads and previews. - Preload the next track well before the end; append it to the same source buffer with trimmed padding for gapless albums, or use a second element when crossfading.
- Model the player as a state machine and the queue as a pure reducer with history, a user queue, and a context order with a seeded shuffle.
- Make the server the owner of playback state across devices: one active device, commands over a WebSocket, and a timestamped position that remotes extrapolate.
- Keep the lock screen honest with one Media Session adapter driven by engine events, updating metadata when a track starts and position state on real changes.
- Virtualize long playlists, load them in pages of about 100, and make edits optimistic with a version to detect conflicts.
- Offline listening is an app feature: licensing, persistent licenses, and browser storage eviction make it unreliable on the web.
- Measure what the ear notices: time to first sound, stalls, track change gaps, and start failures by cause.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Media Source Extensions API (MDN) MediaSource and SourceBuffer: the base of a chunked streaming engine.
- Media Source Extensions for audio (Chrome for Developers) Gapless playback with append windows and timestamp offsets, with the encoder padding explained.
- Encrypted Media Extensions API (MDN) Key systems, media keys, sessions, and the license message flow.
- Media Session API (MDN) Metadata, action handlers, and position state for OS media controls.
- Customize media notifications and playback controls with the Media Session API (web.dev) Worked examples of artwork, actions, and position state.
- Autoplay guide for media and Web Audio APIs (MDN) Why play() can reject without a user gesture, and how to handle it.
- Heavy throttling of chained JS timers beginning in Chrome 88 (Chrome for Developers) How hidden tabs slow timers, and the exemption for pages that made sound recently.
- Spotify Web API: Update playlist items A real reorder API with range start, insert before, and snapshot id.
- Spotify Web API: Transfer playback The public face of moving playback between devices.
- Spotify Web Playback SDK A public browser player that registers as a Spotify Connect device; it needs iframes to allow encrypted-media and autoplay.
- Spotify web player help (Spotify Support) Supported browsers, and how to enable Widevine protected content when playback is blocked.
- Listen offline (Spotify Support) Downloads are in the mobile and desktop apps, and the app must go online at least once every 30 days.
- Shuffle: making random feel more human (Spotify Engineering) Seeded standard shuffle and the Fewer Repeats mode that favors fresh songs.
- Encrypted Media Extensions: media element restrictions (W3C) Section 7.6: decrypted media may be unavailable to Web Audio and canvas.
- Slider pattern (WAI-ARIA Authoring Practices Guide) Keyboard behavior and aria-valuetext for seek and volume sliders.
Concepts behind it
- Single-page vs multi-page appsWhether each click loads a new document from the server or the app swaps views inside one page: a choice that shapes first load, navigation speed, memory over long sessions, the back button, and accessibility.
- Client-side routingHow single-page apps change screens without reloading: matching URLs to nested layouts, loading each route's code and data in parallel, guarding private pages, and restoring scroll and focus.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.