Offline Music in an iPhone Web App: What iOS 27 Allows, and How We Built Downloads
September 23, 2026


Our native iPhone apps are coming, but the web app is the one that will run on every device from day one. So when someone asked whether the web app could keep a few playlists on the phone for a flight, the honest answer at the time was “no, and on iOS maybe never properly.” We checked that answer against iOS 27, found it had become partly wrong, and built the feature. This is what we found along the way — what the platform allows now, what it still doesn’t, and the one design decision that made downloads work at all.
Where we started
The web console had no offline support whatsoever: no service worker, no manifest, nothing stored on the device. Every song was streamed from a short-lived signed link to object storage, fetched when you pressed play. Lose the connection and the app couldn’t even open, let alone play anything.
Before writing code, we listed what an offline music player actually needs from the browser, because that list is where web apps on iOS have historically fallen over:
- Room to store a few gigabytes, and a promise it won’t be quietly deleted.
- A way to play a stored file with seeking, from a queue.
- Playback that keeps moving to the next song with the screen locked.
- Downloads that continue when you’re not looking at the app.
- The app itself opening with no network.
What iOS 27 actually allows
Storage is no longer the problem. Since Safari 17, a Home Screen web app gets the same quota as the site in Safari: up to 60% of the device’s disk for one origin, per WebKit’s storage policy. navigator.storage.persist() is supported, and WebKit grants it based on heuristics that include whether the site is running as a Home Screen web app. Persistent storage is excluded from eviction.
The old seven-day rule doesn’t apply the way people fear. Safari deletes script-writable storage — IndexedDB, caches, service worker registrations — after seven days of Safari use without interaction with the site. But Home Screen web apps have their own counter based on actual use of the app, and WebKit says outright that it doesn’t expect a first party in such an app to lose its data.
Safari 27 fixed the most important bug for us. Moving to the next track in a playlist while the page is in the background had been unreliable, which is fatal for a music player. The Safari 27 release notes include: “Fixed an issue where media playback could not move to the next item in a playlist when the tab was in the background” (172676372). The same release fixed MediaSession.setActionHandler throwing (178167294) and URL parsing in the Media Session API (177330315), which is what puts artwork and controls on the lock screen. It also added the service worker static routing API, and fixed three separate IndexedDB bugs, including connections from workers not recovering after a network process crash.
What didn’t change. There is still no Background Fetch in Safari or Safari on iOS, including version 27, and nothing in the iOS 27 or Safari 27 notes touches background execution for web apps. A Home Screen web app downloads only while it’s open and in front. That’s item 4 on our list, and it’s still a no.
So the list came out three and a half out of five. Enough to build on, as long as the design took the missing half seriously.
The decision that mattered: don’t put audio in the service worker
The obvious design is the one most PWA tutorials show: cache the audio files with the Cache API and let the service worker serve them. We didn’t, and it’s the most useful thing in this post.
Media elements request audio in byte ranges and expect a 206 Partial Content back. A Cache API entry is a whole file with a 200. Serving one in answer to the other is exactly the failure Workbox’s own documentation on cached media walks through: you need a range-requests plugin, crossorigin on the media element, and a cache you fill explicitly, because caching a stream at runtime doesn’t work. Each of those is fixable, and each is a place for Safari to disagree with you in a way you only find on a real device.
Instead, a downloaded song is a Blob in IndexedDB, and playback gets an object URL for it:
// One place turns a library track into a player entry. A downloaded song
// never asks the server for a link, which is what lets a queue start offline.
streamUrl: (await offlineStreamUrl(t.id)) ?? (await getTrackStreamUrl(t.id)),
The audio element seeks inside a blob by itself; there is no range dance, because no network request exists. The service worker we did add is deliberately boring: it caches only the app’s own build files so the app opens offline, and it never touches the API or storage. It’s generated at build time with that build’s hashed file names, so each deploy is a new worker with a fresh cache and the previous one is dropped on activation.
Two small details that were worth getting right. We don’t trust the storage response’s Content-Type to be something Safari can play from a blob, so the type is set from the file extension when the header is generic. And the audio, the song’s details and its removal from the queue are written in one IndexedDB transaction, so a crash halfway can never leave a song listed as downloaded with no audio behind it:
const tx = db.transaction(["tracks", "audio", "queue"], "readwrite");
tx.objectStore("audio").put(blob, entry.id);
tx.objectStore("tracks").put(entry);
tx.objectStore("queue").delete(entry.id);
Covers are stored alongside, so rows and the lock screen still show artwork offline.
Downloads that don’t need the background
Since iOS won’t let the app download in the background, the queue had to survive being interrupted at any moment — a dropped connection, the app being swiped away, the phone locking.
So the queue lives in IndexedDB too. Every requested song is written there before anything is fetched, and the queue runs one song at a time. One at a time is intentional: on a weak connection, one finished song is worth more than three half-finished ones, and each song is held in memory until it’s written. When a download fails, the queue sorts the failure into one of three kinds:
- The network is gone. Stop, keep the song in the queue, and wait.
- The server refused — a song deleted or no longer shared. Move it to the back and try again, and give up after three attempts with a message rather than forever.
- The device is full. Stop the whole queue and say so; removing any download restarts it.
Waiting is the part that makes it feel like it resumes on its own. The queue restarts on three signals:
window.addEventListener("online", () => void runQueue());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") void runQueue();
});
// …and once on every launch.
visibilitychange is the one that matters on an iPhone: coming back to the app after locking the phone is the moment the queue gets to continue. And because navigator.onLine can say “online” behind a captive portal or in a tunnel — where no online event will ever arrive — a network failure while the browser still claims to be online also schedules a retry after 30 seconds.
One thing we didn’t have to change: the backend. The existing streaming endpoint already returns a signed storage link, so downloading uses the same link as playback. The only question was CORS: an <audio> element doesn’t need it, but fetch() does. Our storage bucket already allowed GET from the app’s origins, which we checked against staging with a real request before writing anything.
Downloads are also wiped on sign-out, and whenever a different user signs in on the same device. A shared family iPad shouldn’t hand one person’s private music to the next.
Testing it, including the test tool’s own limits
We ran the whole flow in a scripted Chromium against our staging server: sign in, download a song from its menu, queue five more, cut the network, reload the page, reconnect.
With the network off, the app reopened from the service worker’s cache, the queue still listed its five songs as waiting, and a downloaded song played from a blob: URL. After reconnecting, all five finished without anything being clicked — in about 30 seconds. A three-song EP came to 33 MB on the device.
One thing surprised us, and it’ll save someone an afternoon. In our setup — Playwright driving Chromium — emulated offline mode flipped navigator.onLine to false but never fired the offline event, and a reload while “offline” came back reporting navigator.onLine === true. Our offline banner, which listens for those events, looked broken in the test while working correctly when the events were dispatched. If your offline UI is event-driven, test the events directly rather than trusting emulation to send them.
What we haven’t done yet is the test that matters most: a real iPhone on iOS 27, playlist playing, screen locked, confirming it moves to the next downloaded song. Safari 27’s release note describes the fix for a tab in the background; whether that covers a Home Screen web app behind a locked screen is the thing to confirm on a device, not from a changelog.
What still doesn’t work
- No background downloads. The app must be open. A long playlist started on Wi-Fi at home needs the app kept open until it finishes.
- No background sync. Anything that should reach the server later has to wait until the app is open again.
- Storage is persistent by heuristic, not by promise.
persist()is a request; we make it on the first download and design as if it could still be refused. - Our own gap: listening done offline is held in memory until the connection returns. Plays and skips are signals our smart playlists learn from, and closing the app before reconnecting currently loses them. Storing them on the device is the next small fix.
And Android?
Chrome on Android supports Background Fetch, which is exactly the piece iOS is missing, and in principle lets a long download continue without the page open. We haven’t tested our build on Android yet — that’s a follow-up post, written after running it on real phones rather than from the spec.
What we’d tell someone building the same thing
Check the platform again before believing the folklore; “iOS web apps can’t do offline audio” was true enough a few versions ago and isn’t the whole story now. Keep media out of the Cache API unless you enjoy range requests. Put the download queue in storage, not memory, and treat “the app just came back to the foreground” as your background task, because on iOS it’s the only one you get. And test on the device the release note is about.
The web app, like the rest of own.audio, is still in development. Join the waitlist on the front page if you’d like to try offline playlists on your own phone when it opens — and tell us where it breaks.