cf.completefrontend
My progress
SYSTEM DESIGN · #40

Design a podcast audio player

A web podcast player that keeps playing while you browse, shows up on the lock screen, remembers where you stopped on every device, plays faster without chipmunk voices, trims silence, jumps between chapters and works offline. Playing a file is one line; the hard parts are the long life of one audio element, the operating system around it, and a position that must never be lost.

The question

Design the audio player for our podcast web app. People browse shows, search and read episode notes while an episode keeps playing in a bar at the bottom. They expect lock screen controls, playback speed, skip silence, chapters, downloads for the train, and to pick up on their phone exactly where they stopped on their laptop.

Walk me through how playback survives navigation, how we talk to the operating system, how and when we save the position, how speed and audio effects work, how files are streamed and downloaded, and what the component API looks like.

Real-life examples: Spotify podcasts on the web, Apple Podcasts web player, Pocket Casts web player, Overcast, YouTube Music podcasts, Embed players from podcast hosts like Buzzsprout and Transistor

Requirements: what kind of listening are we designing for?

A podcast player looks like a music player with fewer songs. It is not.

A song lasts 3 minutes and you play it again tomorrow from the start. A podcast episode lasts 30 to 180 minutes, you hear it once, often in pieces across two or three days and two devices, and the single worst bug is losing your place in a 2 hour interview. That one fact drives most of this guide.

Several neighbouring guides already cover parts of this, and we link them instead of repeating them. The video player guide covers the media element API, the playback state machine, buffering and the seek bar. The Spotify guide covers the app shell, the play queue and gapless music playback.

The live streaming guide covers a persistent mini-player. Synced transcripts are their own problem. Here we focus on what is special about listening to long spoken audio on the web.

Start with questions. Each answer below changes a part of the design; the middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Does audio keep playing while the user browses the site?Yes, that is the main use: listen while you look for the next showOne audio element must outlive every route change
Is the app a single page app or server-rendered pages?A React SPA with server rendering for show pages (Next.js style)Where the player lives in the tree; what a full reload costs
Must it work from the lock screen and headphones?Yes, most listening happens with the phone locked or the laptop in another windowMedia Session metadata, action handlers and position state
Is the position synced across devices?Yes, for signed-in users, across web, iOS and Android appsA progress API, save timing and a conflict rule
Which audio features?Speed 0.5x to 3x, skip silence, volume boost, chapters, sleep timerWeb Audio graph and its CORS rules
Where are the files?On the podcast's own host, behind analytics redirects; we do not control the CDNRange requests, redirect chains, CORS we cannot always fix
Offline?Yes: download up to about 20 episodes, 30 to 150 MB eachService worker, storage quota, range replies from cache
Embeds?A small embed player for show websites is a follow-up, not the main appKeep the player core free of app-only code

Functional requirements

  • Play, pause, skip back 15 seconds and forward 30 seconds, seek anywhere in the episode.
  • Keep playing across route changes, with a mini-player bar on every page and a full-screen "now playing" view.
  • Show title, show name and artwork on the lock screen, notification shade and smart watch, and obey hardware and headphone buttons.
  • Resume every episode where the user stopped, on this device and on others; mark an episode played near its end.
  • Change speed from 0.5x to 3x without changing the pitch of voices, and remember the speed per show.
  • Skip silence and boost quiet voices, as optional switches.
  • List chapters with their titles and artwork, and jump to one.
  • Up next queue, sleep timer, and a clear rule for what happens when an episode ends.
  • Download episodes for offline listening, with progress and a storage view.

Non-functional requirements

  • Never lose more than about 10 seconds of position, even if the tab is killed, the laptop sleeps or the network drops.
  • Fast start: first sound within about 1 second of pressing play on a good connection; the player code must not slow the first paint of the page.
  • Low cost while playing: a 2 hour episode on a phone must not drain the battery with timers, re-renders or analysis work.
  • Accessible: every control works by keyboard and screen reader, and the seek slider speaks minutes and seconds.
  • Privacy: listening history is personal data; it is stored for the user, not shared with third parties beyond what the podcast host already sees.

Out of scope

  • Synced transcripts and search inside audio: a separate problem.
  • Video podcasts: they reuse the video player.
  • Live audio rooms: that is a live streaming problem with different latency needs.
  • Recommendations, the show catalog and RSS ingestion on the server: we assume an API returns shows and episodes.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most? Select all that apply.

What makes a podcast player hard

Five traits of the problem drive every later decision. Keep them in mind; each decision chapter answers one of them.

  • Long and once. A 90 minute episode at 128 kbps is about 86 MB. People stop in the middle and come back days later, so the position is the most valuable piece of state in the app, more than the queue or the settings.
  • Playing while you do other things. The player must survive route changes inside the app, and it must also work when the tab is hidden, the screen is locked or the user is in another app. That second part is not ours: the operating system draws the lock screen, so we have to feed it data through an API.
  • Many devices, one listener. The same person listens on a laptop at work and a phone on the bus. Two devices can both believe they have the latest position, and their clocks disagree.
  • Files we do not own. Episode files come from thousands of hosts through tracking redirects. We cannot add CORS headers, change cache rules or fix a slow server. Every feature that touches the raw audio bytes must cope with that.
  • Spoken audio, not music. Speech tolerates 1.5x or 2x speed, has gaps of silence worth removing, and is often recorded too quietly. Music players never needed these features, so we cannot copy them from the Spotify guide.

Here is the screen we are building. The mini-player sits at the bottom of every page. Tapping it opens the full-screen view with artwork, chapters and the bigger controls. Both views control the same audio element; neither one owns it.

Wireframe of the podcast player. Left, a show page window: a header, three episode rows each with a play button, and at the bottom the mini-player bar with artwork, the episode title, a thin progress line and a pause button. Right, the full-screen view: chapter artwork, the episode title, a seek bar with 52:10 of 1:04:00, skip back 15, play, skip forward 30, and buttons for speed 1.5x and the sleep timer.show pagerouted page: changes on navigationEp 41: The interviewplayEp 40: Listener mailresumeEp 39: Live showplayartEp 42: Chapterspausefull viewchapter artEp 42: Chapters52:10 / 1:04:00-15play+301.5xsleepmini-player: in the layout footerportal dialog
Both views are remote controls for the same player service. The mini-player takes real space at the bottom of every page; the full view is a dialog that opens over the page and can close without touching playback.

Think first

If the mini-player and the full-screen view both show the current time, which of them should own the audio element?

Keeping one audio element alive across pages

The naive design puts an <audio> tag on the episode page. It works until the user clicks a link to another show. The router unmounts the episode page, React removes the element, and the browser pauses media that leaves the document.

The user was 47 minutes into an interview and now hears silence. Every podcast app with a web player has to solve this first.

The Spotify guide shows the general fix for an SPA: a player bar in the root layout and a playback engine outside React. The live streaming guide adds slots so a video can move between a page and a mini-player.

Audio is easier than video in one useful way: an audio element does not need to be in the page at all. new Audio() creates an element that plays without ever being inserted, and an element that was never inserted cannot be removed. There is no picture to place, so nothing has to move.

Decision Where does the audio element live?

On the episode page

Each episode page renders its own <audio controls>.

Navigation unmounts it.

Pros

  • Zero architecture.
  • Fine for an embed that is the whole page.

Cons

  • Playback stops on every navigation.
  • Two episode pages could each play at once.

In the root layout, owned by a component

A PlayerBar in the root layout renders the <audio> and holds its state in React.

Pros

  • Survives normal route changes.
  • Easy to write.

Cons

  • Any remount of the bar (a changed key, an error boundary reset) restarts the audio.
  • The full-screen view must reach into the bar to control it.

A detached element owned by a player service

A plain module creates one new Audio() per tab when the user first presses play.

It exposes commands and a subscribable state.

The mini-player and full-screen view are views that render through portals and can mount and unmount freely.

Pros

  • No React remount can stop playback.
  • Views are disposable: lazy-load the full-screen view, close it, open it again.
  • Testable without a DOM tree.

Cons

  • One more layer to design: commands, state and events.
  • Must be careful to create it only in the browser, never during server rendering.

What we pick: A detached element in a player service. It costs a small module and a hook, and it makes the most important promise in the app ("audio keeps playing") independent of how the views are arranged. The mini-player still sits in the root layout, but now it is only a remote control.

Where the audio lives. Inside the React tree, RootLayout renders the router outlet, whose pages swap on navigation and contain play buttons, and the MiniPlayer in the footer, which opens the FullPlayer through a portal. Outside React, the PlayerService owns a detached new Audio element that is never in the DOM. The MiniPlayer and the FullPlayer send commands to the PlayerService and subscribe to its state.React treeRootLayoutRouter outletpages swapMiniPlayerfooter regionPlayButtonin episode rowsFullPlayerportal, lazyexpandOutside ReactPlayerServicecommands, statenew Audio()never in the DOMowns
Pages and views can mount, unmount and remount; the Audio object belongs to a module, so none of that reaches it. Only a full page load, which replaces the whole document, can stop playback.

Mini-player and full-screen view through portals

The two views have different homes. The mini-player belongs in the layout's footer region so it takes space and pushes the page content up (otherwise the last row of every page hides behind it). The full-screen view is a modal dialog, so it renders through createPortal into a top-level container, with focus moved into it and back to the mini-player on close. Because neither view owns the element, opening the full view while the mini-player unmounts on small screens costs nothing: playback never notices.

Full page loads: what breaks and how other setups cope

Some navigations are full page loads even in an SPA: a plain <a> to a page outside the app, a forced reload after a deploy, the user pressing refresh, a login redirect. A full load destroys the document and the audio with it.

Nothing can prevent that, so the design must make it cheap: the position was saved a few seconds ago (chapter 5), and the new page restores the episode paused at that position with a clear play button. It should not try to start by itself: autoplay rules usually block sound without a user gesture on the new page (chapter 7), and Chrome's exceptions for sites the user often plays media on are not something to rely on.

  • Next.js App Router: the root layout.tsx stays mounted across client-side Link navigations, so a client component there can hold the player. A template.tsx remounts on every navigation, so the player must not sit inside one. Navigating to a page with a different root layout is a full load.
  • Classic multi-page sites (every click is a full load) have three choices: open the player in a small popup window that the user keeps open, which old radio sites did; turn the site into an app shell that swaps content without reloading, as Turbo and similar libraries do; or accept a pause on every click and resume from the saved position. The third is honest but feels broken for audio.
  • Embeds on a show's own website are the whole page, so they skip all of this.

Check your understanding · Exercise 2

The player service owns a detached new Audio(). Audio still stops in some cases. Which ones? Select all that apply.

Lock screens and headphone buttons: the Media Session API

Most podcast listening happens with the page out of sight: the phone is locked, the laptop shows a spreadsheet. The user controls playback from the lock screen, the Android notification shade, the macOS Now Playing menu, the Windows media overlay, keyboard media keys, a smart watch or a double tap on earbuds. None of that is our UI. The operating system draws it, and it only knows what the page tells it.

Without any work, the browser shows the page title, the site icon and a play or pause button. For a podcast app that means the lock screen says "Pod - Home" with a favicon, there is no skip back button, and the progress bar is missing or wrong.

The Media Session API (navigator.mediaSession) fixes all three. It is supported in Chrome 73+ on desktop (Android had it earlier), Edge 79+, Firefox 82+ and Safari 15+ on macOS and iOS; setPositionState arrived in desktop Chrome 81. Firefox for Android exposes the API but shows no media controls. It has three parts.

  • Metadata: new MediaMetadata({ title, artist, album, artwork }). Use the episode title as title and the show name as artist. Pass artwork in several sizes (96, 256 and 512 pixels square) so each surface picks a sharp one. Artwork URLs are fetched by the browser, so they must be reachable and should come from our image CDN, not the podcaster's 3000 pixel original.
  • Action handlers: setActionHandler(action, handler) for play, pause, seekbackward, seekforward, seekto, previoustrack, nexttrack and stop. Play and pause work by default; for the others, the OS shows a button only when the action has a handler. Setting a handler to null removes the button, which is how we hide "next" when the queue is empty. The spec defines more actions, such as skipad for players that run separate, skippable ads; podcast ads are usually stitched into the audio file itself, so we leave it unset.
  • Position state: setPositionState({ duration, position, playbackRate }) tells the OS where we are. The OS then moves the progress bar on its own, using the rate, so we only call it when something jumps: metadata loaded, play, pause, seek, rate change and chapter skips. Not on every timeupdate.
ts
function syncMediaSession(audio: HTMLAudioElement, ep: Episode, q: Queue) {
  if (!("mediaSession" in navigator)) return;
  const ms = navigator.mediaSession;
  ms.metadata = new MediaMetadata({
    title: ep.title,
    artist: ep.showTitle,
    artwork: [96, 256, 512].map((s) => ({ src: art(ep, s), sizes: `${s}x${s}`, type: "image/jpeg" })),
  });
  const on = (a: MediaSessionAction, h: MediaSessionActionHandler | null) => {
    try { ms.setActionHandler(a, h); } catch { /* action not supported here */ }
  };
  on("play", () => player.play());
  on("pause", () => player.pause());
  on("seekbackward", (d) => player.skip(-(d.seekOffset ?? 15)));
  on("seekforward", (d) => player.skip(d.seekOffset ?? 30));
  on("seekto", (d) => player.seek(d.seekTime ?? 0));
  on("nexttrack", q.hasNext() ? () => player.next() : null);
  on("previoustrack", () => player.restartOrPrevious());
  on("stop", () => player.stop());
}

function syncPosition(audio: HTMLAudioElement) {
  if (!Number.isFinite(audio.duration)) return; // not known yet
  navigator.mediaSession.setPositionState({
    duration: audio.duration,
    position: Math.min(audio.currentTime, audio.duration),
    playbackRate: audio.playbackRate,
  });
}
Wiring the Media Session in the player service. Each action is guarded, because an unknown action throws.

Two details in that code prevent real bugs. setPositionState throws a TypeError if the position is larger than the duration or the duration is negative, and the duration is NaN until metadata loads; a guard keeps one bad call from breaking the rest of the sync. And the playback rate matters: if the user listens at 2x but we report 1x, the lock screen bar moves at half speed and jumps forward each time we resync.

Wired

Nothing is wired: the lock screen shows the page title and a play button.

Mock lock screen. It shows the page title Pod - Home with the site icon. There is no progress bar. Buttons: play or pause.lock screen (mock)iconPod - Homepod.exampleno progress barplayreal position 10:001xpaused
Nothing is wired: the lock screen shows the page title and a play button.

What "next" and "previous" mean for a podcast

Many earbuds send nexttrack on a double tap and previoustrack on a triple tap, and many car head units show only those two buttons, with no seek buttons. For music, "next" means the next song. For a 2 hour episode, jumping to the next episode by accident loses the listener's place in the queue and their focus.

Podcast apps such as Overcast and Pocket Casts let users map these buttons to "skip forward" and "skip back". We do the same: a setting, defaulting to skip, that changes which function each handler calls. When the user chooses track mode, previoustrack restarts the current episode unless it is in its first 3 seconds, the same rule music players use.

Sequence diagram of a remote control press. The OS sends seekbackward to our action handler. The handler calls skip minus 15 on the PlayerService, which sets the audio element's currentTime back 15 seconds. The audio element fires seeked. The PlayerService calls setPositionState so the lock screen bar jumps back. Later the OS sends nexttrack from an earbud double tap; in skip mode the handler calls skip plus 30.OShandlerPlayerServiceaudioseekbackwardskip(-15)currentTime -= 15seekedsetPositionStatenexttrackskip(+30)
The OS never touches the audio element; it only calls our handlers. After any jump we call setPositionState, or the lock screen keeps showing the old position.

Check your understanding · Exercise 3

On a locked phone, the progress bar for an episode played at 2x moves too slowly, then jumps forward every time the user pauses. What could cause this? Select all that apply.

Resume position: when to save, where, and which save wins

The position is the state users notice when it is wrong. The goal from the requirements: never lose more than about 10 seconds on this device, and open the right position on another device. Two naive designs fail in opposite ways.

  • Save on beforeunload only. On phones, a tab in the background is often killed with no unload event at all. The user locks the phone at 47:10, the OS frees memory an hour later, and the next open starts at whatever was saved last time: maybe 0:00.
  • Save to the server on every timeupdate. The event fires about every 250 ms while playing. One listener sends 4 requests a second; 100,000 concurrent listeners send 400,000. The server team will not accept that, and the radio on the phone never sleeps.

Decision When and where do we save the position?

Only at the end of a session

Save on pause and on beforeunload.

Pros

  • Very few writes.

Cons

  • Loses everything when a background tab is killed.
  • beforeunload does not fire when a mobile OS kills a background tab.

Every tick, everywhere

Write localStorage and POST to the server on each timeupdate.

Pros

  • Nothing is ever lost.

Cons

  • About 14,400 server writes per listener per hour.
  • Battery and data cost on phones.

Two tiers: local often, server less often, plus key moments

Local: every 5 seconds while playing.

Server: every 30 seconds while playing.

Both, immediately: on pause, seek, episode change, visibilitychange to hidden and pagehide.

Pros

  • Worst case loss is 5 seconds locally and 30 seconds across devices.
  • 120 server writes per listener per hour, most of them tiny.

Cons

  • A small scheduler to write and test.
  • Needs a conflict rule, because saves from two devices interleave.

What we pick: Two tiers. The local tier protects against the tab being killed; the server tier gives cross-device resume; the key moments (above all, pause) make the common case exact. A user who pauses on the laptop and opens the phone a minute later gets the exact second, because pause sends a save right away.

ts
const LOCAL_MS = 5_000, SERVER_MS = 30_000;
let lastLocal = 0, lastServer = 0;

audio.addEventListener("timeupdate", () => {
  const now = performance.now();
  if (now - lastLocal >= LOCAL_MS) { saveLocal(); lastLocal = now; }
  if (now - lastServer >= SERVER_MS) { saveServer(); lastServer = now; }
});
for (const e of ["pause", "seeked", "ended"]) audio.addEventListener(e, saveBoth);
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") saveBoth();
});
addEventListener("pagehide", saveBoth);

function saveServer() {
  const body = JSON.stringify(progressRecord()); // { episodeId, position, listenedAt, deviceId }
  fetch("/api/progress", { method: "PUT", body, keepalive: true,
    headers: { "Content-Type": "application/json" } })
    .catch(() => outbox.add(JSON.parse(body))); // retried when back online
}
The save scheduler. On mobile, visibilitychange to hidden is often the last event a page sees; pagehide covers reloads and closed tabs on desktop. A keepalive request survives the page going away.

Local saves go to localStorage under one key per episode, because it is synchronous and survives a crash between two lines of code; the record is under 100 bytes. Failed server saves go into a small outbox in IndexedDB and are replayed when the online event fires or the next save succeeds. The client storage and offline-first lessons cover both stores in depth.

When is an episode "played"?

Few people listen to the last 40 seconds of credits and ads. If "played" means ended, half of finished episodes stay "in progress" forever and come back in the "continue listening" row. Mark an episode played when the position passes about 95% of the duration or the listener is within the last 60 seconds, whichever comes first.

Once played, opening it again starts at 0:00 rather than at 57:40, and the user can mark it unplayed by hand. A play shorter than about 10 seconds does not create a progress record at all, so a stray tap does not push an episode into "continue listening".

Two devices, one position: which save wins?

The rule users expect is "what I heard most recently". So each save carries listenedAt, the wall clock time of the last second actually heard, and the server keeps the record with the latest listenedAt. Two traps make that harder than it sounds.

  • Late offline saves. The phone plays an episode offline on a plane from 09:00 to 10:00 and syncs at 14:00. The laptop played it at 12:00. If the server ordered by arrival time, the phone's older save would overwrite the laptop's. Ordering by listenedAt keeps the laptop's position, which is right.
  • Wrong device clocks. A laptop clock 4 minutes fast makes its saves look newer than they are. Every server response includes the server's time; the client keeps an offset (server time minus local time, measured around the round trip) and stamps listenedAt in server time. A residual error of a second or two only matters when two devices play within seconds of each other, which is rare.

Never move a device that is playing. If a newer position arrives from the server while this tab is playing, the listener is right here and the remote save is a mistake or a second person on a shared account. Apply remote positions only when an episode is opened or resumed after a pause, and when the difference is large (more than 30 seconds) show "Continue from 52:10, last played on iPhone?" instead of jumping silently.

Laptop clockServer keeps
Laptop
Phone

Press Play on the laptop to start a 64 minute episode.

Real time 18:00:00. Laptop paused at 0:00, local save none, clock 5 minutes fast. Phone not open at 0:00, 0 saves in its outbox. Server record: none.Laptop: pausedat 0:00, clock +5 minlocal save: nonePhone: not openat 0:00outbox: 0Server: noneno record yetreal time 18:00:00rule: latest listenedAt, server time
Press Play on the laptop to start a 64 minute episode.
Sequence diagram of saving the position on pause, with a failure. The audio element fires pause. ProgressSync writes position 3130 to local storage, then sends PUT progress with keepalive to the API. The request fails with a network error. ProgressSync adds the record to an outbox in IndexedDB. When the online event fires, it replays the outbox to the API, which replies 200 with the record it kept and its server time.audioProgressSyncStorageAPIpausesave pos 3130PUT, keepalivenetwork erroroutbox.addonline: replay200 + serverTime
The local write happens first and never waits for the network, so a crash right after the pause still resumes at the exact second. The server copy may arrive minutes later; its listenedAt keeps it correct when it does.

Check your understanding · Exercise 4

The laptop's clock is 5 minutes fast. The user pauses on the laptop at 18:00 real time at 30:00 into an episode, then at 18:03 plays the phone to 34:00 and pauses. With which designs does the laptop open at 30:00 afterwards (the wrong answer)? Select all that apply.

Speed, skip silence and volume boost

Podcast listeners change speed far more than music listeners; 1.2x to 2x is common. The media element does most of the work: audio.playbackRate = 1.5. By default preservesPitch is true, so voices keep their pitch instead of sounding like cartoon characters. The unprefixed property shipped in Chrome 86, Firefox 101 and Safari 17.2; older Safari used webkitPreservesPitch.

We offer 0.5x to 3x in steps of 0.1; Chrome accepts 0.0625x to 16x, but speech above 3x stops being useful and some browsers mute audio at extreme rates.

Two details catch most teams. First, loading a new source resets playbackRate to defaultPlaybackRate, so the next episode in the queue plays at 1x even though the user chose 1.5x. Set both properties, or set the rate again on loadedmetadata.

Second, remember speed per show: a fast talker at 1.2x, a slow one at 1.8x. Store a map from show id to rate in user settings (on the server, cached locally), with a global default for shows the user has never adjusted. Show "32 min left at 1.5x" rather than "48 min left", because that is the real time the listener has to spend.

Effects need the Web Audio API, and Web Audio needs CORS

Skip silence and volume boost need the samples, not just a play button. The Web Audio API gives that: audioContext.createMediaElementSource(audio) reroutes the element's sound into a graph of nodes.

A GainNode multiplies the volume (a boost beyond the element's maximum of 1, which also matters on iOS, where audio.volume cannot be set by script). A DynamicsCompressorNode lifts quiet voices and tames loud ones. An AnalyserNode measures loudness so we can detect silence.

Here is the trap that breaks this in production. If the audio file is on another origin and the response is not CORS-approved, the source node outputs silence. The browser does this on purpose: otherwise any page could read the bytes of private audio from another site. The episode still plays fine without the graph, but the moment we route it through Web Audio the user hears nothing.

To get real samples, two things must both be true: the element has crossOrigin = "anonymous" set before src, and every response in the chain, including each redirect, carries Access-Control-Allow-Origin. The CORS lesson explains the headers.

And it gets worse. Setting crossOrigin on a file whose host does not send CORS headers does not give silence; the request fails and the episode does not play at all. So we cannot set it on every episode "just in case".

Three more rules: createMediaElementSource can be called only once per element (a second call throws an InvalidStateError in Chrome and Safari), after which the element plays only through the graph for its whole life; an AudioContext created before a user gesture starts suspended and must be resumed from one; and the graph costs CPU, so we build it only when the user turns an effect on.

The Web Audio graph. The audio element feeds a MediaElementSource node inside the AudioContext. The source goes to a GainNode for boost, then a compressor, then the speakers. The source also feeds an AnalyserNode that checks for silence. Two failure notes: with no CORS header on a redirect hop, the source outputs zeros and the user hears silence; with crossOrigin set but no header, the request fails and the episode does not load.audiothe elementneeds CORSAudioContextSourcefrom elementGainboost x2Compressoreven voicesAnalyserspeakerssilence?no CORS on a hop:source outputs zerosthe user hears silencecrossOrigin set, no header:the request itself failsthe episode will not load
The graph only gets real samples if the element has crossOrigin and every response in the chain has Access-Control-Allow-Origin. Build it only when an effect is on, and only for hosts known to send the header.

Decision How do we skip silence?

Client-side analysis while playing

A timer reads loudness from an AnalyserNode about 30 times a second (not requestAnimationFrame, which stops in hidden tabs).

Below a threshold (say -45 dB) for more than 300 ms, raise the rate to 3x; restore it when speech returns.

Pros

  • No server work.
  • Works on any episode the client can analyse.

Cons

  • Needs CORS on the whole redirect chain, which many hosts lack.
  • It cannot see ahead, so the first syllable after a gap may clip.
  • Costs battery on long listens.

A silence map computed on the server

When an episode is first played with skip silence on, a backend job downloads it once and stores silent ranges as JSON, about 2 to 10 KB per hour of audio.

The client speeds through or seeks over those ranges using timeupdate.

Pros

  • No CORS needed: the element plays normally.
  • Exact, sees ahead, cheap on the client.
  • Computed once, used by every listener.

Cons

  • Compute and storage per episode.
  • The first listener waits for the job, or plays without it.

Proxy all audio through our own origin

Serve every episode from our CDN with our CORS headers.

Pros

  • Every Web Audio feature works everywhere.

Cons

  • We pay for all the bandwidth.
  • The podcast host now sees our servers, not listeners, which breaks their download counting and ad targeting. Hosts and advertisers object.

What we pick: A server silence map as the main path, because it needs no CORS and runs well on phones. Client analysis is a fallback for hosts that do send CORS headers, which a backend list records per host. Proxying is off the table for a directory-style app; it fits only a platform that hosts the audio itself.

Volume boost has the same CORS limit. When a host does not support CORS we hide or disable the switch with a short reason ("not available for this show") rather than letting it produce silence. Playback without effects is always the safe default.

Check your understanding · Exercise 5

Users report that turning on volume boost makes some episodes go silent, and that a fix which set crossOrigin = "anonymous" on every episode made other episodes fail to load. Which statements are true? Select all that apply.

Chapters, up next, sleep timer and the end of an episode

Chapters

Chapters turn a 2 hour episode into sections the listener can scan and jump between: "Intro", "The interview", "Listener questions". They come from two places. Older files carry them inside the MP3 as ID3v2 CHAP frames (from the ID3 chapter addendum), each with a start and end time, a title and optionally an image, plus a CTOC frame listing them in order; M4A files use their own chapter track.

Newer feeds link a separate JSON file with the Podcasting 2.0 podcast:chapters tag; its chapters have a startTime in seconds and optional title, img, url and endTime, and toc: false marks a hidden chapter that only carries metadata.

Reading ID3 tags in the browser means fetching the first part of the file with a range request, which needs CORS on the whole redirect chain again. So the backend that ingests the feed extracts chapters from both sources once and serves them in one format with the episode. The client never parses audio files.

  • Chapter list: a list of buttons, each with title, start time and duration; the current chapter is marked with aria-current="true". Activating one calls player.seek(start).
  • Seek bar markers: small ticks at chapter starts, and the slider's spoken value includes the chapter title.
  • Chapter artwork: when the current chapter has img, show it in the full view and pass it as the Media Session artwork, so the lock screen changes with the chapter. Preload the next chapter's image a few seconds before its start.
  • Skip buttons: in the full view, "next chapter" and "previous chapter" next to the 15 and 30 second skips.
Timeline of a 64 minute episode with four chapters: a 4 minute intro, The interview from 4 to 38 minutes, Listener questions from 38 to 58 minutes, which is the current chapter, and a 6 minute outro. The listener is at 52:10. A sleep timer set to end of chapter will stop at 58:00. A dashed line at 95 percent, 1:00:48, marks where the episode counts as played.sleep: end of chapterintrooutroThe interviewListener Qs0:001:04:00you are here: 52:10dashed: 95%, marked played
Chapter starts come from the backend in one format, whatever the source. The sleep timer and the played threshold are both just positions on this line, checked on each time update.

Up next

The queue data structure, reordering and sync are the same as in the Spotify guide. Two podcast rules differ. The queue is a list of episodes to listen to, not a play order to shuffle, so there is no shuffle or repeat. And finished episodes leave the queue when they are marked played, so it does not fill up with episodes the user already heard.

When an episode ends

On ended (or when the sleep timer fires) the player runs one small, ordered routine: save the final position, mark the episode played, remove it from the queue, optionally delete its download, then decide what plays next. The "what next" rule is a user setting: play the next queued episode (default), continue the same show (oldest first for serial shows, newest first for news shows), or stop. Gapless playback, which the Spotify guide covers for albums, does not matter here; a second of silence between episodes is fine.

Moving to the next episode by script hits autoplay rules. audio.play() returns a promise, and browsers reject it with NotAllowedError when the page has no recent user gesture. Advancing in an app that is already playing is normally allowed, and Safari on iOS is happiest when we reuse the same element the user's first tap started, changing only its src. That is one more reason for a single long-lived element.

Always handle the rejection: show the paused state with a play button, never a spinner that waits forever.

Sleep timer

Options: 15, 30, 45 or 60 minutes, end of chapter, end of episode. The countdown timer guide explains why a deadline (Date.now() + 30 * 60_000) beats counting ticks.

One podcast-specific point: hidden tabs throttle timers, but Chrome exempts pages that are playing audio from its strictest throttling, and while audio plays the element keeps firing timeupdate. We check the deadline on each timeupdate and on a 1 second interval as a backup. In the last 10 seconds we lower the volume step by step, so a sleeping listener is not woken by a hard stop, then pause, save and reset the volume.

Check your understanding · Exercise 6

A user queues three episodes, sets a sleep timer for "end of episode", and locks the phone. Which behaviours are correct? Select all that apply.

Streaming: range requests, redirect chains and download counting

Podcasts are not streamed the way video is. There is no adaptive bitrate ladder and no segments: an episode is one MP3 or AAC file at a URL in the RSS feed (the enclosure), typically 64 to 128 kbps, which is 29 to 58 MB per hour. The browser plays it by progressive download: it requests the file, starts playing when enough has arrived, and keeps downloading ahead.

Seeking uses HTTP range requests. The element first asks with Range: bytes=0-; a server that supports ranges replies 206 Partial Content with Content-Range and Accept-Ranges: bytes.

When the user jumps to 1:12:00, the browser estimates the byte offset (4,320 seconds at 128 kbps is about 69 MB in) and asks for bytes=69120000-. A host that ignores ranges and replies 200 with the whole file makes long seeks slow or impossible, and Safari is strict about range support for media. The file uploader guide covers ranges from the upload side.

Redirect chains we do not control

The URL in the feed is rarely the file. Podcasters put analytics prefixes in front of it, and each prefix is a server that logs the request and redirects to the next one. A real enclosure can look like https://dts.podtrac.com/redirect.mp3/op3.dev/e/media.host.com/ep42.mp3: Podtrac logs and redirects, then OP3 logs and redirects, then the host's CDN serves the bytes. Each hop costs a DNS lookup, a TLS handshake and a round trip, often 100 to 300 ms on a phone, so three hops can add most of a second before the first byte.

Sequence diagram of loading an episode through a redirect chain. The audio element requests the enclosure URL at Podtrac with Range bytes=0-. Podtrac replies 302 to OP3. The element requests OP3, which replies 302 to the host's CDN. The element requests the CDN with Range bytes=0- and gets 206 Partial Content. Later a seek sends a new range request, bytes=69120000-, straight to the CDN.audioPodtracOP3host CDNGET, bytes=0-302 to OP3GET302 to the CDNGET, bytes=0-206 Partial Contentbytes=69120000-
Each hop logs the request, which is how the podcaster counts downloads, and each costs a round trip before the first byte. The last request is a seek; the diagram shows it going straight to the final URL, which saves the chain, though whether a browser reuses the redirect target this way is up to its media loader.
  • We must not skip the hops. Rewriting the URL to the final CDN would hide downloads from the podcaster's analytics, and their ad income depends on those numbers.
  • Hops break. When Spotify shut down Chartable in December 2024, its redirect servers went away, and podcasters were warned that episodes behind a leftover prefix could fail to play until they removed it. A player should show "this episode's host is not responding" with a retry, not a spinner, and report the failing host to our backend.
  • Speed up what we can. On hover or focus of a play button, preconnect to the first hop's origin. Do not request the file itself until the user presses play, because fetching audio counts as a download (below).
  • Errors and stalls: the video player guide covers waiting, stalled and MediaError. For audio, after a network error we reload the source and seek to the last known position, because a 2 hour file must never restart from zero.

How downloads are counted, and why the client should care

Podcast advertising is sold on downloads, counted on the server from the request logs of hosts and prefixes. The IAB Tech Lab's Podcast Measurement Technical Guidelines are the common rulebook: version 2.2 is the one most hosts are certified against, and a 2.3 update was published for comment in 2026.

Roughly: a request counts only if at least about one minute of audio was served, and repeated requests for the same episode from the same IP address and user agent within 24 hours count once. Bots and known prefetchers are filtered out. Exact details vary by host and certification, so treat these as the shape of the rules, not exact numbers.

  • Prefetching the next queued episode near the end of the current one serves more than a minute of audio, so it counts as a download even if the user never plays it. Prefetch only the last few seconds before the switch, or not at all.
  • Hovering over episodes must not set preload="auto" on hidden elements. preload="metadata" fetches less, but how much varies by browser and file, so we avoid it on hover lists too.
  • Our own listening analytics (seconds heard, completion, speed used) are a separate stream, sent in batches as the analytics tracking lesson describes, and never include the full listening history of a person in a third-party tool.

Check your understanding · Exercise 7

Which of these client behaviours would distort the podcaster's download numbers? Select all that apply.

Offline downloads: storing 2 GB of audio in a browser

Commuters want to download five episodes at home and listen on the train with no signal. That is about 20 episodes at 30 to 150 MB each in our requirements: up to 2 GB in one origin. The service workers and offline-first lessons cover the app shell; here we look at what is special about large media.

Decision Where do downloaded episodes live, and how does the player read them?

Cache Storage, served by the service worker

cache.put(url, response) stores the file under its stream URL.

The service worker answers the element's requests from the cache.

Pros

  • The player keeps using the normal URL; nothing changes in playback code.
  • Fits the service worker the app already has.

Cons

  • The element sends range requests, and a cached full response must be sliced into 206 replies by hand (Workbox has a range requests module for this).
  • cache.put rejects a 206 response, so the whole file must be fetched with a plain 200 before it can be stored.
  • Seeking a 150 MB file means reading its blob for each range.

Origin Private File System (OPFS), played from a blob URL

Stream the download into a file with createWritable().

To play, getFile() returns a File, and URL.createObjectURL(file) gives the element a local URL.

Pros

  • The browser handles seeking in a blob URL itself, no range code.
  • Writes in chunks, so memory stays flat.
  • Partial files make resuming a broken download easy.

Cons

  • The player must choose between the local and remote source.
  • Object URLs must be revoked when the episode changes, or each one keeps its file reference alive until the page closes.
  • createWritable() arrived in Safari only with version 26; older Safari needs a worker that writes through createSyncAccessHandle().

No web downloads

Offer downloads only in the native apps.

Pros

  • No storage code.

Cons

  • Fails the commuter requirement for web and installed PWA users.

What we pick: OPFS with blob URLs for the audio, Cache Storage only for the app shell, artwork and episode metadata. Seeking just works, memory stays flat during a 150 MB download, and the player's "pick a source" rule is one line: local file if downloaded, else the feed URL.

Every option has the same precondition: we must be able to read the bytes. A cross-origin fetch without CORS returns an opaque response: no status, no headers, no body to read, so no progress bar and nothing to write to a file. Chrome also counts each opaque response against the quota with a large padding (megabytes per entry) so that sizes cannot leak.

So downloads, like Web Audio effects, work only for hosts that send CORS headers on every hop. For the others the button reads "download in the app".

ts
async function download(ep: Episode, onProgress: (done: number, total: number) => void) {
  const dir = await navigator.storage.getDirectory();
  const handle = await dir.getFileHandle(`${ep.id}.audio`, { create: true });
  const have = (await handle.getFile()).size;
  const res = await fetch(ep.audioUrl, {
    headers: have ? { Range: `bytes=${have}-` } : {},
    signal: ep.abort.signal,
  });
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
  const resumed = res.status === 206;
  const total = resumed ? have + Number(res.headers.get("Content-Length")) : Number(res.headers.get("Content-Length"));
  const out = await handle.createWritable({ keepExistingData: resumed });
  if (resumed) await out.seek(have);
  let done = resumed ? have : 0;
  const reader = res.body.getReader();
  for (;;) {
    const { value, done: end } = await reader.read();
    if (end) break;
    await out.write(value);
    onProgress((done += value.byteLength), total);
  }
  await out.close();
}
Downloading into OPFS with progress. A Range header resumes a partial file; the file uploader guide covers ranges in depth.

Quotas, eviction and persistence

  • Ask before downloading. navigator.storage.estimate() returns usage and quota. Chromium lets one origin use up to about 60% of the disk; Firefox and Safari use other rules, so always read the estimate instead of assuming. If an episode will not fit, say so before starting.
  • Ask for persistence. By default storage is best-effort: under disk pressure the browser may evict the whole origin, downloads included. navigator.storage.persist() asks to keep it; Chrome grants it based on engagement (an installed PWA usually qualifies), Firefox asks the user. Call it on the first download, when the reason is obvious.
  • Safari's 7 day rule. Safari can delete all script-writable storage of a site the user has not interacted with during 7 days of Safari use; web apps added to the home screen are exempt. A downloaded episode can vanish, so the player checks the file exists before choosing the local source.
  • Show a storage page listing downloads with sizes, a total and a "delete played downloads" button, plus an automatic rule: delete a download when its episode is marked played.
State machine of an episode download. Not saved goes to queued on a tap. Queued goes to downloading when a slot is free. Downloading goes to done on complete, to paused when offline, and to failed on an HTTP error. Paused goes back to downloading when online, using a Range request. Failed goes back to queued on retry. Done goes to missing when the browser evicts the file or the user deletes it. Missing goes back to not saved when the file check fails.not savedqueueddownloadingdonefailedpausedmissingtapslot freecompleteofflineonline: RangeHTTP errorretryevictedfile check fails
Paused keeps the partial file, so going online resumes with a Range request instead of starting over. Missing is a real state: storage can vanish, and the player checks before it trusts a download.

Check your understanding · Exercise 8

A user downloads 12 episodes on a laptop. A week later, offline on a train, some will not play. Which design choices protect against this? Select all that apply.

Architecture, data model and component API

Now put the pieces together, then pin down the data model and the API other teams use. The rule from chapter 3 shapes everything: a player service owns the audio element and the truth about playback; small modules around it each handle one outside system (the OS, the server, storage, Web Audio); React views only read state and send commands.

PartResponsibilityWhy the boundary is here
Player serviceOwns the one Audio; commands (play, seek, skip, setRate, load); a small store of playback stateSurvives every view change; one place that decides what is playing
Media Session bridgeMetadata, action handlers, position stateTalks to the OS; can be tested with a fake navigator.mediaSession
Progress syncLocal and server saves, outbox, clock offset, conflict ruleThe most critical logic, kept away from UI code
Effects graphLazy AudioContext, gain, compressor, analyser; CORS check per hostCostly and risky; loaded only when a switch is turned on
Download managerOPFS files, quota, persistence, download statesRuns while the user is on any page
Queue and settings storesUp next, per-show speed, button mapping, sleep timerSynced with the server like any user data
Server cacheEpisodes, chapters, silence maps, progress recordsA server state library handles fetching, caching and refetch
ViewsMini-player, full-screen view (lazy), chapter list, seek slider, play buttons in listsDisposable; mount and unmount freely
Architecture of the podcast player in three tiers. Views in React: MiniPlayer, FullPlayer, ChapterList and PlayButton. They send commands to the PlayerService and receive state through selectors. The player core, in plain TypeScript modules: the PlayerService, which owns the one Audio, and four modules under it: MediaSession, ProgressSync, Downloads and Effects, which is optional. Browser and network: the OS controls receive metadata from MediaSession; the Progress API receives PUT requests from ProgressSync; the host CDN sends bytes to Downloads, which writes them to OPFS.ViewsMiniPlayerFullPlayerChapterListPlayButtoncommandsstate (selectors)Player corePlayerServiceowns one AudioMediaSessionProgressSyncDownloadsEffectsbrowser and networkOS controlsProgress APIhost CDNOPFSmetadataPUTbyteswrite
Each module in the middle talks to one outside system (Effects to the Web Audio API, not drawn), so each can be tested with a fake of that system. The views know only the PlayerService.

Views subscribe through useSyncExternalStore with selectors. This matters for battery: timeupdate fires about 4 times a second for hours, and if every subscriber re-rendered on each tick, the whole page would re-render 14,400 times an hour. Only the time label and the seek slider read currentTime; the play buttons in episode lists read only episodeId and status, which change a few times per session.

The data flow for the main screen follows two paths. Metadata (episodes, chapters, progress) comes from our API through the server cache. Audio bytes never touch our API: they flow from the podcast host through its redirect chain straight into the element, or from OPFS for downloads. Writes go the other way: positions to localStorage and the progress API, listening events to the analytics endpoint.

Data flow. Row one: our API sends JSON with episodes, chapters and progress to the server cache, which gives props to the views. Row two: the host CDN sends audio bytes as 206 range replies to the audio element, which sends events to the PlayerService, and the PlayerService sends state up to the views. Row three, the writes: the PlayerService saves to the local store every 5 seconds and sends PUT progress to the Progress API every 30 seconds and on pause.Our APIepisodes, chaptersserver cacheviewsJSONpropshost CDNafter redirectsaudio elementPlayerService206 byteseventsstatelocal storeProgress APIevery 5 sPUT /progress: every 30 s and on pause
Audio bytes never pass through our API, and metadata never passes through the audio element. The two meet only in the PlayerService, which is also where every save starts.

Data model and the component API

Each piece of state lives where its lifetime and its owner say it should. Episode metadata belongs to the server cache. Playback state lives in the player service for the life of the tab. The position lives in three places at once, on purpose: the element (live), localStorage (last 5 seconds) and the server (last 30 seconds, shared by devices).

ts
type Episode = {
  id: string; showId: string; title: string; showTitle: string;
  audioUrl: string;            // feed enclosure, prefixes included
  durationSec: number; artwork: string;
  chapters?: Chapter[];        // normalized by the backend
  corsOk: boolean;             // effects and downloads allowed
};
type Chapter = { startSec: number; title: string; img?: string; hidden?: boolean };

type Progress = {              // localStorage + server
  episodeId: string; positionSec: number; played: boolean;
  listenedAt: number; deviceId: string;
};

type PlaybackState = {         // player service, per tab
  episodeId: string | null;
  status: "idle" | "loading" | "playing" | "paused" | "error";
  currentTimeSec: number; rate: number; effects: { skipSilence: boolean; boost: boolean };
};

type Download = {              // IndexedDB index of OPFS files
  episodeId: string; bytes: number; totalBytes: number;
  state: "queued" | "downloading" | "paused" | "done" | "failed";
};
Client-side entities. Times are seconds as numbers; listenedAt is milliseconds in server time.
Data model sketch in four places. Server cache: Episode with id, audioUrl, durationSec, corsOk and chapters with start and title. Player service, per tab: PlaybackState with status, episodeId, currentTime, rate and effects. Local storage and server: Progress with positionSec, played, listenedAt and deviceId. IndexedDB and OPFS: Download with state, bytes, totalBytes and the file. PlaybackState, Progress and Download all point to an Episode by id.server cacheEpisode: id, audioUrldurationSec, corsOkchapters: start, titleplayer service, per tabPlaybackState: statusepisodeId, currentTimerate, effectslocalStorage + serverProgress: positionSecplayed, listenedAtdeviceIdIndexedDB + OPFSDownload: state, bytestotalBytesfile: ep_42.audioby idby id
Each entity lives where its lifetime fits: the position is the only one stored in two places, because it must survive both a killed tab and a change of device.

The progress API is small. GET /api/progress?episodes=a,b,c returns records for a page of episodes, so lists can show "23 min left" badges. PUT /api/progress takes one record (or a batch from the outbox) and answers with the record the server kept plus its current time, which the client uses to update its clock offset and to learn that another device won.

http
PUT /api/progress
Content-Type: application/json

{ "episodeId": "ep_42", "positionSec": 3130, "played": false,
  "listenedAt": 1792588812000, "deviceId": "web-7f3a" }

200 OK
{ "kept": { "episodeId": "ep_42", "positionSec": 3130, "listenedAt": 1792588812000,
            "deviceId": "web-7f3a" }, "serverTime": 1792588812450 }

For other teams, the player is a hook and a few components rather than one big component, because the views live in different places in the layout. A play button in a search result and the mini-player must control the same service.

tsx
// each selector returns one primitive, so useSyncExternalStore can compare it
const status = usePlayer((s) => s.status);
const episodeId = usePlayer((s) => s.episodeId);

player.play(episode, { from?: number });   // the service module: starts or resumes
player.pause(); player.seek(sec); player.skip(deltaSec);
player.setRate(rate, { rememberForShow: true });
player.setSleepTimer({ minutes: 30 } | "end-of-episode" | "end-of-chapter" | null);

<PlayButton episode={ep} />               // in lists: play, pause, or resume with time left
<MiniPlayer onExpand={openFull} />         // in the root layout
<FullPlayer open={full} onClose={closeFull} />  // lazy, portal, dialog
<ChapterList episodeId={id} />

// events for analytics and other features
player.on("ended" | "progress-saved" | "error" | "remote-position", handler);

Check your understanding · Exercise 9

The episode list page feels slow while audio plays, and a profiler shows every row re-rendering 4 times a second. Which fixes address the cause? Select all that apply.

Accessibility, keyboard and performance

Audio is the one medium many blind users rely on most, so the player must be excellent with a screen reader. The aria patterns and keyboard navigation lessons cover the basics; these are the player-specific rules.

  • Buttons say what they do. The play button is a real button whose label switches between "Play" and "Pause" with the episode title in a description. Do not combine a changing label with aria-pressed; pick one pattern. Toggles that keep their label, like "Skip silence", use aria-pressed.
  • The seek slider speaks time. An input type="range" in seconds reads "3130" by default. Set aria-valuetext to "52 minutes 10 seconds of 1 hour 4 minutes, chapter: Listener questions". Arrow keys move 5 seconds, Page Up and Page Down 30 seconds, Home and End to the ends.
  • Do not announce the clock. The time label must not be a live region; it would speak 4 times a second. Announce only events: "Skipped to chapter 3", "Sleep timer: 30 minutes", "Download complete", through one polite live region.
  • Shortcuts are scoped. Space, J and L, and arrow keys work when focus is inside the player region, or anywhere on the page when focus is not in a text field, never while the user types in search. They are listed in a help dialog and can be turned off, because WCAG 2.1.4 (Character Key Shortcuts) requires a way to turn off or remap single-letter shortcuts like J and L unless they work only while the player has focus.
  • The full view is a dialog: focus moves in on open and back to the mini-player on close; Escape closes it.
  • Words for the audio: link the transcript when the feed provides one, so deaf and hard of hearing users can read the episode.

Performance is about two moments. Opening a page must not pay for the player: the mini-player shell is a few KB, while the full view, the effects graph, the download manager and the chapter artwork load on first use.

Listening for two hours must cost almost nothing: no animation frames while the full view is closed, no waveform drawing when the tab is hidden, saves batched as in chapter 5, and the analyser for skip silence off unless the server silence map is missing. With the selectors from chapter 10, a 2 hour session causes a few thousand small re-renders of two components, not hundreds of thousands across the page.

Check your understanding · Exercise 10

A screen reader user reports the player is "noisy and confusing". Which changes help? Select all that apply.

Running it in the interview

In 45 minutes you cannot cover all of this. Spend about 5 minutes on requirements and make the interviewer choose: background listening, cross-device resume and offline are the big three. Then draw the player service with the views around it (10 minutes), because every later answer refers to it. Spend most of the remaining time on two or three deep dives, chosen by what the interviewer reacts to, and keep 5 minutes for the component API.

  1. Minutes 0 to 5: questions from chapter 1; say out loud "the position is the most valuable state in this app".
  2. Minutes 5 to 15: draw the architecture: player service owning a detached element, mini-player and full view as views, Media Session bridge, progress sync, download manager.
  3. Minutes 15 to 35: deep dives. Resume (tiers, pagehide, conflict rule with clock offset) is the one that most often separates candidates. Then Media Session or effects and CORS.
  4. Minutes 35 to 45: data model, the progress API and the component API; finish with accessibility of the seek slider.

Expect these follow-ups. "What if the user has the app open in two tabs?" Only one should play: use a BroadcastChannel so a tab that starts playing tells the others to pause, and let only the playing tab save progress. "Why not save on beforeunload?" It does not fire when a mobile OS kills the tab; pagehide and visibilitychange are the reliable moments.

"Why is boost silent for some shows?" CORS on the redirect chain, and why we cannot simply set crossOrigin on every file. "How would you build an embed?" The same player service without the router, the queue or downloads, sized for an iframe.

Check your understanding · Exercise 11

With 10 minutes left, the interviewer asks what you would build first for a launch in 6 weeks. Which answers show good judgement? Select all that apply.

Summary

  • Keep one audio element alive in a player service outside the routed tree; the mini-player and full view are only remote controls, and a full page load restores the episode paused at its saved position.
  • Feed the OS through the Media Session API: metadata with sized artwork, handlers for every action you support, and setPositionState with the real playback rate whenever position jumps.
  • Save the position in two tiers (local every 5 seconds, server every 30) plus immediately on pause, seek, hiding the page and pagehide; resolve devices by the latest listenedAt in server time, and never move a device that is playing.
  • Speed is cheap (playbackRate, preservesPitch, reapply after a new source); skip silence and boost need Web Audio, which needs CORS on every redirect hop, or the graph outputs silence.
  • Podcast files are progressive downloads behind analytics prefixes: support range requests, never skip the hops, and do not prefetch whole episodes because that counts as a download.
  • Store offline episodes in OPFS and play them from blob URLs; check quota, ask for persistence, and confirm the file still exists before playing it.
  • A seek slider must speak minutes and seconds, the clock must never be a live region, and shortcuts must stay out of text fields.

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