cf.completefrontend
My progress
SYSTEM DESIGN · #90

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.

QuestionAnswer we assumeWhat 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.
Wireframe of the Spotify web player at open.spotify.com. On the left, Your Library lists playlists and albums. In the middle, the route outlet shows the Liked Songs page with a header and track rows, the second row playing; only visible rows are rendered. On the right, the Queue panel shows next up and history. Along the bottom, the player bar holds Now playing with artwork, the play, skip and seek controls, and the device and volume controls.open.spotify.comYour Libraryplaylists, albumsLiked Songs1 Track one2 Track two (playing)3 Track threeroute outletQueuenext up, historyNow playingPlay, skip, seek barDevice, volume
The player bar, library, and queue belong to the app shell and stay on screen for the whole session. Only the middle area changes when the user navigates, which is why the music never has to stop.

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 setTimeout to 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.

TraitWhat goes wrong if you ignore itWhat it forces
Lives for hoursEach 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 bugThe 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 backgroundA 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 UIMedia 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 accountTwo devices play at once, or a skip on the phone is lost.A server-owned playback state, a WebSocket, and one active device.
Licensed audioPlain 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?

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?

Full page loads, resume after each load

Each page is a separate document.

Before unload, save the track and position in sessionStorage.

After load, restore the track, seek, and call play().

Pros

  • Simplest server-rendered pages.
  • Each page is light and independent.

Cons

  • A gap of 0.3 to 2 seconds on every click.
  • Autoplay rules can block the restart until the user clicks again.
  • The audio is downloaded again, wasting data.

Frame shell: player in the parent, pages in an iframe

A tiny top document holds the player.

Each page loads inside a full-size iframe.

The parent mirrors the iframe's URL into its own with the History API.

Pros

  • Pages can stay multi-page apps.
  • The player is never unloaded.

Cons

  • Address bar, back button, and titles must be synced by hand and break often.
  • Search engines and link previews see the shell, not the page.
  • Focus, screen readers, and scrolling get confusing across two documents.

Single-page app with the player in the app shell

The root layout renders the header, sidebar, player bar, and an outlet for the current route.

The router swaps only the outlet on navigation.

The playback engine is created once, outside any page component.

Pros

  • Zero gap: the document and the audio element never go away.
  • Pages share caches, so artwork and metadata load once.
  • Client navigation is faster than a full load.

Cons

  • A larger first download: the shell, router, and engine.
  • Full reloads still happen sometimes (F5, new deploy), and the app must recover from them.
  • Leaks build up over a long session, so cleanup must be disciplined.

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.

The app shell. Inside a browser tab that lives for hours, the React tree has the App shell at the top with three children: Sidebar, Route outlet, and Player bar. Under the route outlet, the Home page is being replaced by the Album page. Outside the React tree, the Playback engine owns the audio element. The player bar sends commands to the engine.Browser tab: lives for hoursReact treeApp shellSidebarRoute outletPlayer barHomeAlbumswapsPlayback engineowns the audioaudio element
Navigation replaces only what's under the route outlet. The playback engine and its audio element sit outside the React tree, so neither a navigation nor a re-mount can stop the sound.

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.

ts
// 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);
One engine per tab. React reads it with useSyncExternalStore and never owns the audio element.

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 a tag 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.href after an action, for example after creating a playlist. Use the router's navigate instead.
  • 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?

Media element with a file URL

audio.src = url and audio.play().

The browser fetches byte ranges and decodes.

Events like playing, waiting, and ended report progress.

Pros

  • Tiny amount of code.
  • Decoding happens off the main thread, and playback keeps going in background tabs.
  • Works with the Media Session API and OS controls.

Cons

  • No control over how far ahead it buffers.
  • Encrypted playback through a plain src is uneven across browsers, so paid streams need another path.
  • A gap at each track change.

Web Audio API with decoded buffers

Fetch the whole file, call decodeAudioData to get raw samples.

Play it with an AudioBufferSourceNode connected to an AudioContext.

Schedule the next track with start(when) at an exact time on the audio clock.

Pros

  • Sample-accurate scheduling: true gapless and smooth crossfades with a GainNode.
  • Full access to samples for effects and visualizers.

Cons

  • Must download and decode the whole track before it starts.
  • Decoded audio is large: 3.5 minutes of stereo 44.1 kHz float samples is about 74 MB of memory.
  • No encrypted content: script would see the raw samples, which is exactly what DRM (digital rights management) forbids.

Media Source Extensions on a media element

Create a MediaSource, set it as the element's source with URL.createObjectURL.

Fetch audio in chunks yourself and push them with sourceBuffer.appendBuffer.

Add Encrypted Media Extensions (EME) to decrypt with a license.

Pros

  • The app decides chunk size, buffer depth, and retries.
  • Works with EME, so paid streams can stay encrypted.
  • Next track's chunks can be appended to the same buffer for a gapless join.

Cons

  • More code: buffer management, quota errors, codec checks.
  • Files must be in a format MSE accepts, in practice fragmented MP4 or WebM, so ordinary files are repackaged.
  • Bugs in buffer handling show up as stalls that are hard to reproduce.

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.

The playback engine's pipeline. The engine fetches byte ranges of fragmented MP4 chunks from the audio CDN, keeping a buffer goal of 20 to 30 seconds, and appends them to a SourceBuffer. Through a MediaSource the SourceBuffer feeds the audio element, which sends sound to the speakers. For encrypted streams, the license server sends a license to the EME session in the page, which passes it to the CDM with update; the CDM decrypts for the audio element.Audio CDNfMP4 chunksEnginebuffer goal 20-30 sSourceBufferappendBufferchunksappendaudio elementMediaSourcesoundLicense serverEME sessionCDMlicenseupdatedecrypts
The page moves bytes and messages but never sees decrypted audio: the CDM decrypts inside the browser. The engine decides chunk size and buffer depth, which a plain 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. appendBuffer can throw a QuotaExceededError when the buffer is full. Remove played ranges with sourceBuffer.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.

Sequence diagram of pressing play on a track, between the User, the Engine, the API, the CDN, and the License server. The user clicks play. The engine asks the API for playback info and gets the file list and license URL back. The engine then sends the license request and asks the CDN for the first 64 KB at the same time. The CDN returns the chunk and the license server returns the license. The engine reports playing and the UI updates. On the failure path, the license server answers 403 because the track is not available in this country, and the engine skips to the next track with a message.UserEngineAPICDNLicenseclick playplayback infofile listget licensefirst 64 KBchunklicenseUI: playingor 403: skip
The license and the first chunk don't depend on each other, so they go out together: one round trip saved on every cold play. A license refusal is a business error with its own message and an automatic skip, not a generic failure.

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?

Load on end

Wait for ended.

Then fetch the next track's file list, license, and first chunk.

Play when ready.

Pros

  • Simplest code.
  • Never downloads a track the user might skip.

Cons

  • Half a second to several seconds of silence at every change.
  • In a hidden tab the user hears nothing and sees nothing, and may think it broke.

Preload into a second element

Near the end of the current track, prepare a second media element with the next track.

On ended, call play() on the second element and swap roles.

Crossfade by lowering one element's volume while raising the other's.

Pros

  • Network delay is gone: the next track is already buffered.
  • Easy to reason about: one element per track.

Cons

  • Still a small gap: event delivery and decoder start take tens of milliseconds, plus encoder delay.
  • Two elements mean two licenses and two buffers in memory.
  • A crossfade driven by page timers or timeupdate moves the volume in coarse steps, not a smooth curve.

Append the next track to the same source buffer

Near the end, fetch the next track's segments.

Set sourceBuffer.timestampOffset so they start exactly where the current track ends.

Use appendWindowStart and appendWindowEnd to trim encoder delay and padding.

Pros

  • Truly gapless: the decoder sees one continuous timeline.
  • One element, one buffer, no swap.

Cons

  • The engine must map timeline positions to tracks to know which one is playing.
  • A codec change between tracks needs sourceBuffer.changeType() or a fresh buffer.
  • Crossfade isn't possible this way: tracks follow each other, they can't overlap.

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.

Network
Preload
Handoff

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.

Timeline of the last 40 seconds of a 3:30 track and the start of the next. 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.current track, ends at 3:30.0playingnext track: info, license, first chunk (0.35 s)next track starts at 3:30.0nextgap: 0 ms, no gap at allpreload starts 3:15.0
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.

The player state machine. Idle goes to loading when a track is picked. Loading goes to playing on the playing event, to error when the license or network fails, and to paused when autoplay is blocked. Playing goes back to loading when the track ends and the next one starts, to buffering on a waiting event, and to paused on pause. Buffering resumes to playing. Paused goes to playing on play. Error skips to the next track.idleloadingplayingbufferingpausederrorskip to nextpick trackplayingnext trackwaitingresumespause / playfailsblocked
One status instead of several booleans, so impossible mixes like loading and playing can't happen. Every media event and every command is an input to this machine, from any source: a button, a media key, or the phone.
StatusEntered whenWhat the UI shows
idleNothing loaded yet, or the queue ended with repeat off.Player bar with the last track, play button enabled.
loadingA 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.
playingThe element fired playing.Pause button, moving progress bar.
bufferingThe element fired waiting during playback.Progress bar keeps its place; a small spinner after 500 ms.
pausedThe user paused, or play() was blocked by the autoplay rules.Play button. If blocked, a hint: press play to start.
errorLicense 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?

One flat array and an index

tracks[] and current.

Shuffle sorts the array randomly.

Add to queue splices into the array after current.

Pros

  • Tiny and easy to render as a list.

Cons

  • Shuffle destroys the original order.
  • User-added songs get mixed into the context and move on reshuffle.
  • Previous goes to the wrong song after a shuffle.

Three parts: history, user queue, context with an order

history holds what really played.

userQueue holds songs the user added, played first, in order.

context keeps its tracks untouched plus an order of indexes (identity or shuffled) and a cursor.

Pros

  • Shuffle on and off is just swapping the order array.
  • User-added songs stay where the user put them.
  • Previous uses history, so it's always right.

Cons

  • More concepts to explain in the UI (Next in queue versus Next from the album).
  • Every operation must keep three parts consistent, so it needs tests.

Server computes everything, client renders

Every command goes to the server.

The server returns the new queue and the client draws it.

Pros

  • One implementation for all devices.
  • No drift between clients.

Cons

  • Every tap waits a round trip, often 100 to 300 ms.
  • No local response if the network is slow.

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 queue model for an 8-track album with shuffle on. History holds t3 and t7, which already played. Now playing is t1. The user queue holds Q1 and Q2, which play next, in order. The context order is the shuffled list of album positions 3, 7, 1, 5, 8, 2, 6, 4, with the cursor on the third entry; the first two are done. Shuffling builds a new order but never moves the album's tracks.historyt3t7nowt1user queueQ1Q2plays next, in ordercontext order37158264shuffle = a new order; tracks stay put
Shuffle only replaces the order row, so turning it off can return to album order. The user's songs live in their own list, so no shuffle can move them, and previous reads history, not the order.

The rules, one by one

  • Next takes the first song of userQueue if there is one. Otherwise it moves the context cursor forward in order. The finished song is pushed onto history.
  • 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.
ts
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;
}
A pure reducer: same input, same output, on every device and in every test.

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.

Repeat

An album of 8 tracks, t1 playing. Try Add to queue, then shuffle, then Next.

Queue simulator. Now playing: t1. History: empty. User queue: empty. Context order: t1, t2, t3, t4, t5, t6, t7, t8, at position 1. Up next: t2, t3, t4, t5. Shuffle off, repeat off.historynowt1from the albumuser queuealbum ordert1t2t3t4t5t6t7t8up next: t2, t3, t4, t5shuffle off, repeat off
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.

ts
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 });
  });
}
One adapter, subscribed to the engine. It is the only code that talks to navigator.mediaSession.
Sequence diagram of pressing the next track media key, between the User, the operating system, the Media Session adapter, and the Engine. The user presses the next key. The OS sends a nexttrack action to the adapter's handler, which calls engine.next. When the next track actually starts, the engine emits track started. The adapter sets new metadata with artwork and calls setPositionState. The OS shows the new song on the lock screen.UserOSAdapterEnginenext keynexttrack actionengine.next()track startedmetadata, artworksetPositionStatenew song shown
The adapter updates the OS on the engine's 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 position and playbackRate. Call setPositionState on play, pause, seek, track start, and rate change. Calling it 4 times a second wastes work. And position must never exceed duration, 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.currentTime might 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?

Each device independent, polling for others

Every device plays its own queue.

Every few seconds it asks the server what the others are doing.

Pros

  • No persistent connection.
  • Simple server.

Cons

  • Seconds of delay on every remote command.
  • Two devices can play at once.
  • Lots of wasted requests.

Server-owned state, pushed over a WebSocket

The server holds one playback state per account, with a version number and an active device.

Each device keeps a WebSocket open and receives every change.

Commands go to the server, which forwards them to the active device.

Pros

  • Commands arrive in 100 to 300 ms.
  • One authority decides who is active, so only one device plays.
  • Remotes extrapolate the position locally, so no per-second traffic.

Cons

  • A persistent connection per device, and a reconnect strategy.
  • Commands from a remote depend on the server being reachable.

Devices talk directly on the local network

Discover devices on the same Wi-Fi.

Send commands device to device.

Pros

  • Works for devices in the same room even if the cloud is slow.

Cons

  • Browsers can't discover or open raw connections to local devices.
  • Fails for the very common case of phone on mobile data, desktop at home.
  • Still needs a server to decide who is active.

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

ts
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));
}
Position is a sample with a timestamp, so every remote can compute the current second without traffic.

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.
Sequence diagram of moving playback from the phone to a speaker, between the Browser, the Server, the Phone, and the Speaker. The browser asks the server to transfer to the speaker. The server tells the speaker to play from 1:23. The speaker confirms it is playing. Only then does the server tell the phone to stop, and it sends the browser state version 42 with the speaker active. Failure path: if the speaker never confirms, the server tells the browser the transfer failed after 5 seconds, and the phone never stopped.BrowserServerPhoneSpeakermove to Speakerplay from 1:23playingstopv42: Speaker onfailure: no replyplay from 1:23failed after 5 sphone plays on
The new device must confirm before the old one stops. If the speaker is offline, the music keeps playing on the phone and the browser shows a clear error, instead of silence everywhere.

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.

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?

Fetch everything, render everything

One request for all tracks.

Map them all to rows.

Pros

  • Browser find, sort, and filter just work.
  • Simplest code.

Cons

  • Megabytes of JSON before the first row.
  • 200,000 DOM nodes; slow first render and slow interactions.

Classic pages with Next and Previous buttons

Show 100 tracks per page.

Fetch a page per click.

Pros

  • Small, predictable requests and DOM.

Cons

  • A playlist is one ordered thing; splitting it into pages breaks scrolling, drag to reorder across pages, and the feel of the app.

Virtualized list with ranges loaded on demand

The first request returns the total count and the first 100 tracks.

A scroll container is sized for all rows; only the visible rows plus a margin are rendered.

Missing ranges are fetched as they scroll into view, with placeholder rows meanwhile.

Pros

  • About 40 rows in the DOM at any time, whatever the size.
  • Scrolling straight to the bottom loads only the last page.
  • Same component serves search's See all, albums, and the library.

Cons

  • Browser find in page can't see unrendered rows, so the app needs its own filter.
  • Drag to reorder and keyboard focus need extra care.
  • Placeholder rows can flash on very fast scrolls.

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).

Sequence diagram of dragging a playlist row, between the User, the List UI, the Store, and the API. The user drops a row at position 3. The list asks the store to move item 7f3c, and the store re-renders the list with the row moved at once. The store sends PUT reorder with snapshot abc, and the API answers 200 with snapshot abd. Failure path, when someone else edited the playlist: the API answers 409 stale snapshot, the store rolls back the move and shows a message, and refetches the affected pages.UserList UIStoreAPIdrop row at 3move item 7f3cre-render: movedPUT, snapshot abc200, snapshot abdfailure: conflict409 stale_snapshotroll back, messagerefetch pages
The user sees the move instantly; the network only confirms it. A stale snapshot means the local copy is out of date, so the client undoes the move and refreshes before letting the user try again.

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.

The client architecture. Inside the browser tab, the left column holds the Player bar, the Pages, the Server cache, the Media Session adapter, and Telemetry. The right column holds the Playback engine, the Queue reducer, the Connect client, the API client, and the Service worker. The player bar sends commands to the engine; the engine uses the queue and drives the Media Session adapter. Pages read the server cache, which fetches through the API client. The connect client feeds remote commands to the queue. Outside the tab, the engine gets chunks from the Audio CDN and keys from the License server, the Connect client exchanges events with the Socket server, the API client exchanges JSON with the API, and the service worker caches images from the Image CDN.Browser tabPlayer barPages (routes)Server cacheMedia SessionTelemetryPlayback engineQueue reducerConnect clientAPI clientService workerAudio CDNLicense serverSocket serverAPI / BFFImage CDNchunkskeyseventsJSONimages
Each long-lived service on the right owns exactly one connection to the outside: chunks and keys, socket events, JSON, or images. The UI on the left never talks to the network directly.
PieceLives forResponsibilityWhy the boundary is here
App shellThe tabLayout: 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 pageHome, search, artist, album, playlist, library. Loaded with code splitting.Each page can fail, load, or be replaced without touching playback.
Playback engineThe tabMSE 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 reducerThe tabHistory, user queue, context order, shuffle seed, repeat.A pure function shared with the server, so all devices compute the same next track.
Connect clientThe tabOne WebSocket: playback state, remote commands, library change events, clock offset.One connection for all live data, reconnect logic in one place.
Media Session adapterThe tabMetadata, action handlers, position state.The only code touching navigator.mediaSession, driven by engine events.
API client and server cacheThe tabFetch 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 workerAcross visitsCaches the app's code and artwork; never caches audio.Fast repeat startup and fewer image requests; audio stays under DRM rules.
TelemetryThe tabStart-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.

Data flow for the playlist page while music plays, in three columns. Left: the API sends JSON pages of 100 items to the server cache, which gives entities to the playlist page; edits go up from the page to the cache optimistically and then to the API as a PUT. Middle: the Image CDN sends images to the service worker, which serves them cache-first to the track rows. Right: the Audio CDN sends chunks to the engine, which sends status events to the player bar. Along the bottom, the WebSocket brings library events to the playlist page and playback state to the player bar.API / BFFServer cachePlaylist pageJSONPUTreadeditImage CDNService workerTrack rowsimagescachedAudio CDNEnginePlayer barchunksstatusWebSocket: playback state, library eventseventsstate
Three separate pipes with different sizes and rules: JSON through a cache that pages read, images through the service worker, and audio straight into the engine without touching React. Live changes arrive on the WebSocket.

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.

ts
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 };
Entities are stored once, by id. Lists are arrays of ids or of small items that point to ids.
The client data model. A Playlist with snapshotId and total holds PlaylistItems with itemId and addedAt, and each item points to a Track with durationMs and playable. A Track points to its Album, which knows whether it is gapless, and to its Artists. Separately, the PlaybackState with a version and active device holds a QueueState of history, user queue, and context ids, and points to the active Device.PlaylistsnapshotId, totalPlaylistItemitemId, addedAtTrackdurationMs, playableAlbumgaplessArtistimagesDeviceisActivePlaybackStateversion, positionQueueStateids onlyactiveDeviceId
Lists hold ids, entities live once. A playlist item has its own id because the same track can appear twice; the playback state is the server's, mirrored on every device.
DataWhere it livesWhy there
Tracks, albums, artistsNormalized server cache, by idShared across pages; a heart or availability change updates every view.
Playlist itemsServer cache, by playlist and pagePaged loading for long lists; snapshotId guards edits.
Playback state and queueServer, mirrored in the engine and queue reducerShared by devices; the server decides the active device.
Engine status, buffer, positionEngine module (memory)Changes many times a second; only the engine and progress bar need it.
Devices listConnect client (memory)Live data from the WebSocket; meaningless after a reload.
Current page and search queryURLShareable, works with back and forward, restored on reload.
Volume, sidebar width, recent searcheslocalStoragePersonal, small, survives reloads, and the server doesn't need it.
App code and artworkService worker cacheFaster repeat visits; images are public and cheap to cache.
AudioOnly the source buffer, brieflyLicensing: 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.

http
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
REST for catalog, library, and playback setup. Shapes simplified.

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.

ts
// 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 };
WebSocket messages. Every state message carries a version; older versions are ignored.

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:

ComponentReadsEmits
PlayPauseButtonstatusengine.toggle()
SkipButtonsqueue has next, has previousengine.next("skip"), engine.previous()
ProgressSlidertrack position and duration (own animation frame loop)engine.seek(ms) on release, not on every move
VolumeSlidervolume, mutedengine.setVolume(v), saved to localStorage
ShuffleRepeatshuffle, repeatqueue.setShuffle(on, seed), queue.setRepeat(mode)
DevicePickerdevices, active deviceconnect.transfer(deviceId)
NowPlayingcurrent track and artworknavigation 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.

Timeline of time to first sound, with assumed numbers. Cold and one by one: connect, playback info, connect, license, connect, first chunk, decode, 740 ms in total. Preconnected and in parallel: playback info, then the license and the first chunk at the same time, then decode, 280 ms. Warmed on hover: only the first chunk and decode remain, 130 ms.cold, one by one: 740 msconnectinfoconnectlicenseconnectchunkpreconnected, in parallel: 280 msinfolicensechunkwarmed on hover: 130 mschunk
Hatched cells are new connections, pure waiting. Preconnecting removes them, running the license and chunk side by side removes another step, and warming on hover leaves only the chunk.
  • 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 localStorage snapshot 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?

Full offline downloads in the browser

Store encrypted audio in IndexedDB or the Cache API.

Use persistent licenses so the CDM can decrypt without a network.

Pros

  • Same feature as the apps.

Cons

  • Persistent licenses are not available on every browser and platform, and without one the stored files can't be played offline.
  • Browsers may evict site storage under pressure unless navigator.storage.persist() is granted, and Safari can delete script-written storage for sites the user hasn't interacted with in 7 days of browser use.
  • Thousands of tracks at 7 MB each is gigabytes in a store the user can wipe by clearing site data.

No offline playback; a clear offline state and a read-only cached shell

Keep only a short buffer of audio in memory.

Persist the library list and artwork so the app opens without a network.

Show an offline banner, disable play for anything not buffered, and resume when back online.

Pros

  • Honest about what works.
  • Uses only reliable browser features.
  • The current track can finish if its buffer holds it.

Cons

  • No listening on a plane from the browser.
  • Users must install the app for offline.

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 button element, 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-pressed true or false fits perfectly: it's on or off.
  • Repeat has three states, which aria-pressed can'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 raw aria-valuenow of 83 means nothing, so add aria-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-valuetext at 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.

MetricHow it's measuredTarget and what it reveals
Time to first soundFrom 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 rateShare 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 ratioTotal time stalled divided by total time playing.Well under 1 percent. How bad the stalls are, not just how many.
Track change gapFrom the previous track's end to the next track's first audio.Near zero on gapless albums. Preload starting too late.
Start failuresFailed plays by code: license denied, network, decode, autoplay blocked.A spike in one code points to one cause, like an expired certificate.
Remote command delayFrom sending a command to its ack over the WebSocket.Under 300 ms at the 75th percentile. Socket trouble, slow devices.
Memory over timeSampled 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.

MinutesWhat to doWhat to draw or say
0 to 5Ask 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 12Draw 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 22The 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 30The 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 38Lists and interface.Virtualized playlist with pages of 100, optimistic reorder with a snapshot id; the main REST calls and socket messages.
38 to 45Optimizations 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?

Think first

How would you add time-synced lyrics that highlight the current line?

Think first

The network drops for 20 seconds in the middle of a song. What does the user experience?

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 src path 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.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next