expo-av is being retired — what that means for you
expo-av handled audio and video behind one API. It has been split into expo-audio and expo-video, and new projects should start on the split packages. This is not a rename with a compatibility shim — the shape of the API changed:
- Hooks instead of objects.
expo-avgave you an imperativeSoundobject you created, tracked, and manually unloaded.expo-audiois hook-first, so the player is tied to a component’s lifetime and teardown is handled for you. - That removes the leak class. The most common expo-av bug was a
Soundthat was never unloaded, so screens accumulated audio instances and the app got slower the longer it ran. The hook makes that harder to do by accident. - Migration is a rewrite of the playback layer. Budget real time for it, and do it while your audio code is still small — the cost only goes up.
If you have an existing expo-av app that works, there is no emergency. But do not start new audio work on it, and check your Expo SDK release notes for the current deprecation status before you plan around a specific timeline.
The setting that decides whether anyone hears anything
iOS classifies app audio. By default your app is ambient — polite, mixes with other audio, and goes completely silent when the hardware ringer switch is flipped. That default is correct for most apps and catastrophic for a podcast player. The fix is an audio mode set once at startup:
import { setAudioModeAsync } from 'expo-audio';
// media app: sound is the product
await setAudioModeAsync({
playsInSilentMode: true, // ignore the hardware switch
shouldPlayInBackground: true, // keep going when backgrounded
interruptionMode: 'doNotMix', // pause Spotify, we are the audio now
});
// utility app: sound is a garnish — just leave the defaults alonePick the row that matches what you are building and stop thinking about it:
| App type | Silent switch | Background | Other apps |
|---|---|---|---|
| Podcast / music player | Play anyway | Yes — required | Interrupt others |
| Meditation / sleep app | Play anyway | Yes — required | Mix with others |
| Voice recorder / memo app | Play anyway on playback | Only if recording continues | Interrupt others |
| Game with sound effects | Respect the switch | No | Mix, or duck music |
| App with UI chirps | Respect the switch | No | Mix with others |
Resist the urge to set playsInSilentModeeverywhere because it made your test work. A user who silenced their phone and then heard your app’s achievement fanfare in a meeting is a one-star review with a very specific complaint in it. The switch is a user instruction, and overriding it needs a reason you could defend out loud.
Background playback needs a native declaration
shouldPlayInBackground: true does nothing on its own. The OS has to know your app is allowed to make sound while backgrounded, and that lives in the native project:
// app.json
{
"expo": {
"ios": {
"infoPlist": {
"UIBackgroundModes": ["audio"],
"NSMicrophoneUsageDescription":
"Records your voice memos so you can play them back later."
}
},
"android": {
"permissions": ["RECORD_AUDIO"]
}
}
}This is a build-time value. Adding it and reloading changes nothing — you have to rebuild, and in Expo Go you are running their native config, not yours, so background audio can appear to work or not work there for reasons that have nothing to do with your app. Verify in a development build. The Expo Go versus development build guide covers why this class of bug is so consistently confusing.
Playing a sound
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
import { Pressable, Text } from 'react-native';
export function EpisodePlayer({ uri }: { uri: string }) {
const player = useAudioPlayer({ uri });
const status = useAudioPlayerStatus(player);
return (
<Pressable onPress={() => (status.playing ? player.pause() : player.play())}>
<Text>
{status.playing ? 'Pause' : 'Play'}
{' '}
{Math.floor(status.currentTime)} / {Math.floor(status.duration ?? 0)}
</Text>
</Pressable>
);
}Four things that catch people out:
- Duration is not available immediately. For a remote file it arrives once enough of the stream has loaded, so a progress bar that divides by it renders
NaNfor the first frames. Default it to zero and hide the scrubber until it is real. - Local assets and remote URLs are different sources. A bundled file goes in via
require(); a network file via a{ uri }object. Mixing them up fails quietly rather than loudly. - Changing the source needs a new player. Swapping tracks by mutating a prop does not always reload cleanly — key the component on the track id so a new track gets a new player instance.
- Seeking is asynchronous. Reading
currentTimeimmediately after a seek returns the old position. Drive the scrubber from local state while dragging and reconcile on release, or it fights the user’s finger.
Recording, and the first-run failure
import {
useAudioRecorder, requestRecordingPermissionsAsync,
setAudioModeAsync, RecordingPresets,
} from 'expo-audio';
import * as FileSystem from 'expo-file-system';
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
async function start() {
// 1. ask FIRST, and await it — this is the whole first-run bug
const { granted } = await requestRecordingPermissionsAsync();
if (!granted) return showPermissionExplainer();
// 2. iOS needs recording enabled in the audio mode
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
await recorder.prepareToRecordAsync();
recorder.record();
}
async function stop() {
await recorder.stop();
const tmp = recorder.uri; // temporary location
if (!tmp) return;
// 3. move it somewhere the OS will not purge
const dest = FileSystem.documentDirectory + `memo-${Date.now()}.m4a`;
await FileSystem.moveAsync({ from: tmp, to: dest });
saveToDatabase(dest.split('/').pop()); // store the filename only
}The numbered comments are the three bugs that account for most recording tickets. Permission not awaitedgives you the “fails once, then works forever” report that nobody can reproduce. Recording left enabled in the audio mode after you stop makes playback quiet and routed oddly on iOS, because the session is still configured for input — set it back to false when recording ends. Leaving the file in temporary storage means it disappears eventually, which is the worst possible bug for a voice memo app.
Note the last line stores only the filename. The absolute container path can change between app updates, so a stored full URI breaks while the file is fine — the trap covered in the FileSystem guide.
A pre-ship audio checklist
- Flip the hardware silent switch and confirm the behaviour is what you intended, in both directions.
- Start playback, then background the app. Then lock the phone. Both should behave the same way.
- Start playback while Spotify is playing. Whatever happens should be a decision you made.
- Take a phone call mid-playback and hang up. Does it resume, or die silently?
- Plug in and unplug headphones during playback — iOS pauses on unplug and your UI should agree.
- Deny the microphone permission and try to record. Then grant it in Settings and come back.
- Record, kill the app, reopen it. The file should still be there and still playable.
None of these need a device farm. All of them are things a real user does in the first ten minutes, and each maps to a distinct piece of the audio session model that a simulator will happily lie to you about.
Starting from something that already runs
An audio app is mostly not audio code — it is a library screen, a player UI, local storage, and a subscription. If you want the surrounding app generated so you can spend your time on the part that is actually specific to you, describe it at shipnative.dev and you get a real React Native project running on your phone in minutes, exported as a normal Expo codebase you own and edit.