I run ambient sound apps for sleep in my own projects. 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 Expo's docs announced its removal 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 says that on Android you have to enable lock screen controls with setActiveForLockScreen for sustained background playback; otherwise the audio stops after approximately three minutes of background playback (an OS limitation, not a bug to be fixed on your side).
My build stopping 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 control the lock screen at a time, as the docs also state. 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.
For a long time my own check was "lock the screen, listen for thirty seconds." The results were not good: I was missing, on my own desk, exactly the class of bug that only shows up after release. These days I keep a timestamped note instead:
| Elapsed | What to check | If it fails here |
|---|---|---|
| Right after locking | Audio playing; media notification in the shade | Layer 1 or 2 (missing setting) |
| 1–2 minutes | Notification still present | If it vanished, re-check the order of your layer-3 call |
| Just past 3 minutes | Audio still playing | Layer 3: setActiveForLockScreen was never called |
| 10 minutes | Audio and notification both still there | Look at other causes, such as battery optimization |
One thing I haven't been able to confirm: on Android 13 and later, the notification permission state can change what you see in the shade, so I run these checks with notifications allowed. I haven't tested what happens on a device where the user has denied them — if that matters for your app, I'd check on a real device.
Ship on "still playing after three minutes," not on "playing." For sleep audio, that's a line I don't plan to move.
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.