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.
| Question | Answer we assume | What 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 show | One 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 window | Media Session metadata, action handlers and position state |
| Is the position synced across devices? | Yes, for signed-in users, across web, iOS and Android apps | A progress API, save timing and a conflict rule |
| Which audio features? | Speed 0.5x to 3x, skip silence, volume boost, chapters, sleep timer | Web Audio graph and its CORS rules |
| Where are the files? | On the podcast's own host, behind analytics redirects; we do not control the CDN | Range requests, redirect chains, CORS we cannot always fix |
| Offline? | Yes: download up to about 20 episodes, 30 to 150 MB each | Service worker, storage quota, range replies from cache |
| Embeds? | A small embed player for show websites is a follow-up, not the main app | Keep 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.
Think first
If the mini-player and the full-screen view both show the current time, which of them should own the audio element?
Neither. If the mini-player owns it, the element dies whenever the mini-player unmounts, for example when the full-screen view hides it. Put the element in a player service at the root of the app and let both views subscribe to its state.
Views come and go; the service lives as long as the page. This split is the subject of the next chapter.
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?
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.
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.tsxstays mounted across client-sideLinknavigations, so a client component there can hold the player. Atemplate.tsxremounts 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 astitleand the show name asartist. 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)forplay,pause,seekbackward,seekforward,seekto,previoustrack,nexttrackandstop. Play and pause work by default; for the others, the OS shows a button only when the action has a handler. Setting a handler tonullremoves the button, which is how we hide "next" when the queue is empty. The spec defines more actions, such asskipadfor 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 everytimeupdate.
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,
});
}
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.
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.
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
beforeunloadonly. 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?
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.
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
}
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
listenedAtkeeps 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
listenedAtin 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.
Press Play on the laptop to start a 64 minute episode.
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.
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?
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 callsplayer.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.
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.
- 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,
preconnectto 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,stalledandMediaError. 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?
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".
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();
}
Quotas, eviction and persistence
- Ask before downloading.
navigator.storage.estimate()returnsusageandquota. 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.
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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Player service | Owns the one Audio; commands (play, seek, skip, setRate, load); a small store of playback state | Survives every view change; one place that decides what is playing |
| Media Session bridge | Metadata, action handlers, position state | Talks to the OS; can be tested with a fake navigator.mediaSession |
| Progress sync | Local and server saves, outbox, clock offset, conflict rule | The most critical logic, kept away from UI code |
| Effects graph | Lazy AudioContext, gain, compressor, analyser; CORS check per host | Costly and risky; loaded only when a switch is turned on |
| Download manager | OPFS files, quota, persistence, download states | Runs while the user is on any page |
| Queue and settings stores | Up next, per-show speed, button mapping, sleep timer | Synced with the server like any user data |
| Server cache | Episodes, chapters, silence maps, progress records | A server state library handles fetching, caching and refetch |
| Views | Mini-player, full-screen view (lazy), chapter list, seek slider, play buttons in lists | Disposable; mount and unmount freely |
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 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).
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";
};
listenedAt is milliseconds in server time.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.
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.
// 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
buttonwhose label switches between "Play" and "Pause" with the episode title in a description. Do not combine a changing label witharia-pressed; pick one pattern. Toggles that keep their label, like "Skip silence", usearia-pressed. - The seek slider speaks time. An
input type="range"in seconds reads "3130" by default. Setaria-valuetextto "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.
- Minutes 0 to 5: questions from chapter 1; say out loud "the position is the most valuable state in this app".
- 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.
- 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. - 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
setPositionStatewith 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 latestlistenedAtin 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.
References
- MDN: Media Session API Metadata, action handlers and position state.
- web.dev: Customize media notifications and playback controls with the Media Session API Worked examples, including
setPositionState. - MDN: HTMLMediaElement.preservesPitch
- MDN: MediaElementAudioSourceNode How a media element feeds a Web Audio graph.
- Chrome for Developers: Autoplay policy in Chrome When
play()is allowed and whatNotAllowedErrormeans. - Podcast namespace: JSON chapters format The
podcast:chaptersfile format. - MDN: Origin private file system
- web.dev: Storage for the web Quotas, eviction and persistent storage.
- IAB Tech Lab: Podcast Measurement Technical Guidelines How the industry counts podcast downloads.
Concepts behind it
- Single-page vs multi-page appsWhether each click loads a new document from the server or the app swaps views inside one page: a choice that shapes first load, navigation speed, memory over long sessions, the back button, and accessibility.
- Service workersA script the browser runs beside your pages, with no DOM and no memory it can trust, that wakes up for events (fetch, message, push, sync), handles them, and is shut down when idle: a programmable network proxy with sharp limits.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- CORSThe rules that let a page read responses from another origin, and the headers a server sends to allow it: a browser feature that relaxes the same-origin policy to protect users' data, not a wall that protects your server.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.