Navigation
AI

PWA and Web Audio API: Offline Player and Background Playback Traps

October 30, 2026 techy.sk

It is possible to build an offline audio player as a PWA. However, a Service Worker stores the files in memory, not the playback itself. Whether the audio plays on a locked screen is determined by the browser and the operating system. This guide shows what works reliably, where the pitfalls are, and why the choice between the Web Audio API and the `audio` element is more important than caching itself.

First, let’s clear up a misconception

It’s often claimed that PWAs “save” web apps from being killed in the background. That’s not entirely true. Service Workers handle offline access and fast loading, but audio playback and its continued operation when the screen is locked depend on the browser’s behavior:

  • With a standard element audio and a properly configured Media Session API, background playback usually works, although there are differences between systems.
  • With a pure AudioContext , the situation is worse. On iOS, the audio often pauses when the app moves to the background, and developers have also reported cases where the audio remains silent upon returning to the foreground.
  • PWAs added to the iOS home screen are particularly problematic. In the fall of 2025, bugs were reported after updating to iOS 26, in which Web Audio behaved unreliably in such apps. The situation may have changed in the meantime, so always test the behavior on an actual device.

That is why we propose an architecture that minimizes dependence on AudioContext during long playback.

Architecture

  • Service Worker caches both the app and audio files and can serve them offline.
  • The `audio` element plays long recordings (ambient sounds, music). It is the most reliable way to play audio in the background.
  • The Media Session API provides control over playback on the locked screen and in system notifications.
  • Use the Web Audio API for synthesis and effects, such as binaural tones, which do not require any files.

Web App Manifest

{
  "name": "Offline player",
  "short_name": "Player",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#111111",
  "theme_color": "#111111",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

Service Worker: caching and Range requests

When loading audio and video, browsers send requests with the Rangeso they can skip ahead in the file. However, the Cache API does not allow storing a partial response (206) and cache.match does not handle ranges on its own. Safari, in particular, requires ranges for media. Therefore, the Service Worker must handle them on its own:

const CACHE = "audio-v1";
const SHELL = ["/", "/app.js", "/styles.css", "/manifest.webmanifest"];

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys()
      .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
      .then(() => self.clients.claim())
  );
});

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.pathname.startsWith("/audio/")) {
    event.respondWith(serveAudio(event.request));
  } else {
    event.respondWith(caches.match(event.request).then((r) => r || fetch(event.request)));
  }
});

async function serveAudio(request) {
  const cache = await caches.open(CACHE);
  const cached = await cache.match(request.url);
  if (!cached) return fetch(request);

  const range = request.headers.get("range");
  if (!range) return cached;

  const blob = await cached.blob();
  const size = blob.size;
  const m = /bytes=(d*)-(d*)/.exec(range);
  let start = m[1] ? parseInt(m[1], 10) : size - parseInt(m[2], 10);
  let end = m[1] && m[2] ? parseInt(m[2], 10) : size - 1;
  end = Math.min(end, size - 1);

  return new Response(blob.slice(start, end + 1), {
    status: 206,
    statusText: "Partial Content",
    headers: {
      "Content-Type": cached.headers.get("Content-Type") || "audio/mpeg",
      "Content-Range": `bytes ${start}-${end}/${size}`,
      "Content-Length": String(end - start + 1),
      "Accept-Ranges": "bytes",
    },
  });
}

Downloading files for offline use

Download the file as a full response (without the Range header) and save it to the cache. Also request persistent storage so that the browser doesn’t delete it if space runs out:

async function saveOffline(url) {
  const cache = await caches.open("audio-v1");
  const res = await fetch(url);
  if (!res.ok) throw new Error("The download failed: " + res.status);
  await cache.put(url, res);
  if (navigator.storage?.persist) await navigator.storage.persist();
}

Storage quotas vary by browser and device. Before downloading large files, call navigator.storage.estimate() and inform the user of the remaining space.

Background playback via the audio element and Media Session

const audio = document.querySelector("audio");

async function playAmbient() {
  audio.src = "/audio/ambient.mp3";
  audio.loop = true;
  await audio.play(); // must be initiated by the user

  navigator.mediaSession.metadata = new MediaMetadata({
    title: "Ambient",
    artist: "My app",
    artwork: [{ src: "/icons/512.png", sizes: "512x512", type: "image/png" }],
  });
  navigator.mediaSession.setActionHandler("play", () => audio.play());
  navigator.mediaSession.setActionHandler("pause", () => audio.pause());
}

Be careful with pauses. On iOS, there have been reports of cases where, after a long pause in the background, a PWA stopped responding to buttons on the lock screen until the app returned to the foreground. Testing on an actual device is therefore essential.

Binaural tones without files

The binaural effect occurs when slightly different frequencies are sent to the left and right ears. No recorded file is needed for this; two oscillators are sufficient. It must be played through headphones. Scientific evidence regarding the effects of binaural beats on mood or concentration is mixed, so do not present them as a treatment in the app.

const ctx = new AudioContext();

function startBinaural(base = 200, beat = 10) {
  const left = ctx.createOscillator();
  const right = ctx.createOscillator();
  left.frequency.value = base;
  right.frequency.value = base + beat;

  const merger = ctx.createChannelMerger(2);
  const gain = ctx.createGain();
  gain.gain.value = 0.15;

  left.connect(merger, 0, 0);
  right.connect(merger, 0, 1);
  merger.connect(gain).connect(ctx.destination);

  left.start();
  right.start();
  return () => { left.stop(); right.stop(); };
}

// The context is only triggered by a user gesture
button.addEventListener("click", async () => {
  await ctx.resume();
  stop = startBinaural();
});

// An attempt at a comeback after returning to the limelight
document.addEventListener("visibilitychange", async () => {
  if (document.visibilityState === "visible" && ctx.state !== "running") {
    try { await ctx.resume(); } catch (e) { /* The context could not be restored */ }
  }
});

If the context cannot be restored after the user returns, the most reliable approach is to display a “Continue” button to the user, which will recreate the context based on the user’s action.

Memory during long playback

This method decodeAudioData expands the entire file into its uncompressed form. A ten-minute stereo recording at 44.1 kHz and 32-bit resolution takes up approximately 212 MB. On a mobile device, this may be enough to crash the app. Therefore:

  • Play long recordings via the element audiothat streams them,
  • while decodeAudioData load only short loops and effects,
  • Do not use multiple instances at once AudioContext; one is enough,
  • disconnect unnecessary nodes and free up resources after use.

Also pay attention to the combination. If you audio is linked via createMediaElementSource, the behavior AudioContext. If you don’t need it, leave the element standalone.

Wake Lock won’t help here

The Screen Wake Lock API keeps the screen on. For playback on a locked screen, this is the opposite of what you want, because it unnecessarily drains the battery. Reliable playback is achieved by correctly using the audio element and Media Session, not by keeping the screen on.

Pre-launch checklist

  • Test the locked screen, switching between apps, and returning after a few minutes.
  • Also test the PWA added to the iOS home screen, not just the bookmark in the browser.
  • Test airplane mode and the app after restarting the device.
  • Check the behavior when storage is full and when a download is completed.
  • Provide a safe solution if the audio cuts out, such as a resume button.

What to Take Away From This

PWA and Service Workers give a web app offline capabilities, but they don’t guarantee background playback on their own. The most sensible approach is a hybrid one: use the `audio` element for long recordings, the Web Audio API for synthesis and effects, and thorough testing on real devices. If you need 100% reliability on iOS, a native app remains the safer choice.

Share Article

You Might Be Interested