As an indie developer, I run ambient sound apps for sleep. A user taps play on rain sounds and drifts off; the screen locks within a minute, and only the audio is supposed to remain until morning. For this kind of app, everything that matters happens after the screen goes dark.
While testing a build that had migrated from expo-av to expo-audio, I ran into a strange failure mode on a physical Android device. The audio kept playing right after the screen locked — then, roughly three minutes later, it stopped. No error, no crash. Just silence.
If playback dies the instant you lock the screen, you suspect a missing setting. But "plays for a while, then stops" is much harder to reason about. It turned out this wasn't a bug or a quirk of my test device. It's a stopping behavior written plainly into Expo's documentation. Here is how I traced it, and how I now think about background audio as three separate layers.
Your expo-av knowledge does not carry over to expo-audio
Some context first. expo-av was deprecated in SDK 52 and removed in SDK 55. Since apps generated with Rork are built on Expo, rebuilding on a current SDK means your audio code lives in expo-audio now, whether you planned the migration or not.
In the expo-av era, background playback hinged on a single boolean: staysActiveInBackground, passed to setAudioModeAsync. Set it, and you were mostly done.
That field does not exist in expo-audio's AudioMode. You search for the name, fail to find it, spot the plausible-looking shouldPlayInBackground, set it to true, and move on — which is exactly what I did. On iOS, that works. On Android, your audio stops after about three minutes.
What changed is that one boolean became three layers. That structural shift is the real trap.
The three layers, and how each one fails
Here is the map I wish I'd had. Background playback in expo-audio only works when all three of these layers are in place.
| Layer | Where it lives | What to set | Symptom when missing |
|---|---|---|---|
| 1. Build config | Config plugin in app.json | enableBackgroundPlayback (default true) | iOS stops instantly on lock; Android lacks the service declaration |
| 2. Audio session | setAudioModeAsync at runtime | shouldPlayInBackground: true (default false) | Both platforms stop immediately on lock or backgrounding |
| 3. Lock screen integration | setActiveForLockScreen at runtime | Effectively required on Android (optional on iOS) | Android only: playback stops after roughly 3 minutes |
The table doubles as a reverse lookup. Audio dies the moment the screen locks: check layers one and two. Audio plays for a while and then goes quiet: layer three. The three-minute mark is not noise — it's a fingerprint telling you which layer to inspect.
Layers one and two: the build-time declaration and the runtime session
Layer one is the config plugin. In app.json:
{
"expo": {
"plugins": [
["expo-audio", { "enableBackgroundPlayback": true }]
]
}
}This adds two Android permissions — FOREGROUND_SERVICE and FOREGROUND_SERVICE_MEDIA_PLAYBACK — plus the media playback foreground service declaration (AudioControlsService) to your manifest, and adds the audio entry to UIBackgroundModes on iOS. You never touch the native files yourself.
The subtle part: enableBackgroundPlayback defaults to true, so this layer is usually fine without you doing anything. Meanwhile, layer two's shouldPlayInBackground defaults to false. The two defaults point in opposite directions. That's why "the manifest has everything, yet the audio still stops" is such a common state of confusion during this migration.
Layer two is the session setup, called once before playback starts:
import { setAudioModeAsync } from 'expo-audio';
await setAudioModeAsync({
playsInSilentMode: true, // keep playing under the iOS silent switch
shouldPlayInBackground: true, // the core switch; defaults to false
interruptionMode: 'doNotMix', // required for lock screen controls
});The default interruptionMode is 'mixWithOthers', but the docs are explicit that lock screen controls need 'doNotMix' — otherwise the OS may not associate those controls with your player. Also note that the old interruptionModeAndroid is deprecated; interruptionMode now applies to both platforms. Older tutorials will steer you toward field names that no longer do what they once did.
Layer three: without setActiveForLockScreen, Android stops at about three minutes
This is the heart of the matter. Expo's documentation states that on Android, if you do not enable lock screen controls, background playback stops after approximately three minutes — an OS limitation, not a bug to be fixed on your side.
My build stopping quietly at the three-minute mark was this sentence playing out verbatim. The fix is to activate lock screen controls when playback starts:
player.loop = true;
player.setActiveForLockScreen(true, {
title: 'Rain Sounds',
artist: 'Ambient Sounds',
});
player.play();This call posts a media notification, and the foreground service tied to that notification is what keeps playback alive. The notification in the shade is your visible proof that the audio will survive. Put the other way around: if you lock the screen and there's no media notification, your playback is sitting on a three-minute hourglass.
One design note: only one player can own the lock screen at a time. In an ambient app that switches between multiple sound sources, you either re-activate the new player on each switch or keep one active player and refresh the display with updateLockScreenMetadata.
The minimal working setup, and why my test checklist now includes three minutes
All three layers in one place:
import { useAudioPlayer, setAudioModeAsync } from 'expo-audio';
import { useEffect } from 'react';
import { Button } from 'react-native';
const rainSound = require('./assets/rain.mp3');
export function AmbientPlayer() {
const player = useAudioPlayer(rainSound);
useEffect(() => {
setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: true,
interruptionMode: 'doNotMix',
});
}, []);
const start = () => {
player.loop = true;
player.setActiveForLockScreen(true, {
title: 'Rain Sounds',
artist: 'Ambient Sounds',
});
player.play();
};
return <Button title="Play" onPress={start} />;
}This incident also added one step to my release checklist: start playback on a physical Android device, lock the screen, confirm the media notification is showing, and then keep listening for more than three minutes. A thirty-second check in a simulator will never surface this problem.
For a sleep app, three minutes is a brutal number. The audio dies at the exact moment the user is most defenseless — half asleep. And the review they leave will only say "the sound stops"; nobody writes down the three-minute detail that would point you at the cause. Waiting out those three minutes by hand is unglamorous testing, but it protects more than it appears to.
Other ambient-audio pitfalls — gapless loop seams, silence under the ring/silent switch — are collected in Three Things I Got Stuck on When Implementing Ambient Sound in a Healing App. And once lock-screen playback is solid, the next stage is timing: my premium article My Rork sleep timer faded out on time — but the sound didn't covers designing fades driven by playback position rather than wall-clock time.
Start with three minutes on a real device. If your audio survives that, the foundation for playing until morning is in place.