Bypassing mobile autoplay restrictions in audio players
Mobile browsers block autoplay between media, so Muse required a workaround.
If you don't already know, I'm working on the greatest music downloader ever made - its called Muse.

it's free & self-hostable, allowing users to download and stream their favorite songs from Youtube, SoundCloud, and more. When building the music player for Muse, everything worked fine, at first.
Click a song? It plays.
Click next? It plays the next song.
Press spacebar? It pauses.
All good.
But then, I noticed a weird bug. On mobile, once the current track ended — nothing happened. If you just waited, the next song would never start playing. If you tried skipping the media, it would just reset back to your native music app’s recently played list.
What was happening?
To no surprise, most browsers (especially Safari on iOS) are strict about autoplay. They allow it once the user has tapped play, but creating a new Audio()
instance resets the playback context — which mobile browsers treat as a new sound request.
So even if I had gesture-based approval from the user initially, doing this in production broke Muse's autoplay:
1audioElement = new Audio(song.stream_url);2await audioElement.play();
On desktop though, Muse seemed fine. That’s because on desktop browsers like Chrome use something called the Media Engagement Index (MEI), which allows autoplay in more cases once your site is 'trusted' - typically after a user interacts or plays media for at least 7 seconds.
You can find the MEI in Chrome by going to chrome://media-engagement
. It shows how many times a user has interacted with media on your site, and once you hit a certain threshold, autoplay works more freely.
Here's mine:

Like I said, with mobile browsers, the autoplay policy is farrr stricter. They allow autoplay only if there’s been a direct user gesture — like a tap — and get this, only within the same playback context. If you create a new Audio
element or new AudioContext
, it resets the trust. So even though the user had tapped once, the browser saw it as a new sound request — and blocked it, especially if the tab was in the background or the screen was off.
If Muse wanted to rival native alternatives like Apple Music, I knew the UX had to match the status quo: uninterrupted audio playback.
That’s when I realized: while desktop let Muse create new audio instances freely without issue, mobile required us to reuse the original playback context if we wanted autoplay to work reliably across tracks.
The fix
Instead of creating a new audio element each time, I decided to reuse a single persistent <audio>
element for the user's entire session on the app.
1if (!audioElement) {2 audioElement = document.createElement("audio");3 audioElement.preload = "auto";4 audioElement.crossOrigin = "anonymous";5 audioElement.playsInline = true;6 document.body.appendChild(audioElement);7}8
9audioElement.src = song.stream_url!;10await audioElement.play();
The overhead of updating .src
continually is quite minimal in all modern browsers. We do it because on mobile user agents, that's what is treated as continuous playback.
Sometimes, the audio element would be null, so I check for that and create it if necessary. I also set crossOrigin
to anonymous
to avoid CORS issues with streaming media.
I set playsInline
to true so it plays inline on iOS instead of fullscreen, and I append it to the body so it’s always in the DOM.
This way, the audio element persists across song changes, and I only call .play()
after the initial user gesture. Meaning that once the user taps play on the first song, the same audio element continues to play subsequent tracks without needing to create new instances.
And as we know, this fixed it. Playback continues across songs. The phone screen can be off. No prompts. No crazy buffering. No hacks.
Rich media controls and metadata
Since I was already touching this, I also looked into:
navigator.mediaSession
to show song info + scrub buttons on the lock screen and carplaynavigator.wakeLock
to keep the screen from dimming during playback
1navigator.mediaSession.metadata = new MediaMetadata({2 title: song.title,3 artist: song.uploader,4 artwork: [{ src: song.thumbnail!, sizes: "512x512", type: "image/jpeg" }]5});6
7navigator.mediaSession.setActionHandler("nexttrack", () => get().nextSong());8navigator.mediaSession.setActionHandler("previoustrack", () => get().previousSong());
mediaSession
doesn't unlock autoplay — but it makes the UX feel native, especially with proper artwork and skip controls.


The result
Muse now:
- Autoplays the next song, even with the screen off
- Keeps the lock screen media controls updated
- Doesn’t break when switching tabs or backgrounding
All by avoiding new Audio(...)
calls after the first play and rethinking how I structured playback logic.
If you're hitting this bug
You don’t need a workaround, hack, or service worker.
You just need:
- One persistent
<audio>
element .src = songUrl
on song change.play()
only after initial user gesturemediaSession
if you care about lockscreen polish
That’s it.
1let audioElement: HTMLAudioElement | null = null;2audioElement = document.createElement("audio");3
4audioElement.src = songList[currentSongIndex].stream_url;5audioElement.preload = "auto";6audioElement.crossOrigin = "anonymous";7audioElement.playsInline = true;8
9document.body.appendChild(audioElement);10await audioElement.play();11
12// On song change or gesture:13if (!audioElement) {14 audioElement = document.createElement("audio");15 audioElement.preload = "auto";16 audioElement.crossOrigin = "anonymous";17 audioElement.playsInline = true;18 document.body.appendChild(audioElement);19}20
21const nextSongUrl = songList[currentSongIndex + 1].stream_url;22audioElement.src = nextSongUrl;23await audioElement.play();
Final thoughts
This bug made Muse feel broken for a while on mobile. But understanding how the browser treats playback contexts cleared everything up.
If your autoplay works until the second track — now you know why.