If you've been shipping a React Native app that leans on expo-av for audio, the runway is officially up. As of Expo SDK 55, the audio module from expo-av is fully removed from Expo Go and no longer receives patches. Its replacement, expo-audio, is now stable, hook-first, and built on Media3/ExoPlayer on Android and AVFoundation on iOS.
So, this guide is a practical, end-to-end tutorial for expo-audio in 2026 — playback, recording, status updates, background mode, lock-screen controls, and a side-by-side migration path from expo-av. Every snippet here was tested against expo-audio 55.x in an Expo SDK 55 project.
Why expo-av is being replaced
The old Audio API in expo-av was an imperative, class-based wrapper around AVAudioPlayer and MediaPlayer. It pre-dated React Hooks, required manual unloadAsync on every unmount, and shared a single audio session between video and audio code that often fought each other. (I personally lost a weekend to that one — a video preview hijacking the podcast session, with no obvious culprit in the stack trace.)
expo-audio rebuilds the same surface as a small set of hooks (useAudioPlayer, useAudioRecorder, useAudioPlayerStatus, useAudioRecorderState, useAudioSampleListener) plus a handful of module-level helpers. The new module:
- Cleans itself up when the component unmounts — no more leaked
Soundinstances. - Uses Media3/ExoPlayer on Android instead of the legacy
MediaPlayer, so HLS audio, gapless playback, and lock-screen metadata work out of the box. - Splits configuration between a config plugin (build-time) and
setAudioModeAsync(runtime), which means background audio finally works without manual manifest edits. - Ships first-class support for the New Architecture in SDK 55.
Installation and project setup
Install the package using the Expo-aware installer so the version matches your SDK:
npx expo install expo-audio
If you're using Continuous Native Generation (the default for managed Expo apps), enable the config plugin in app.json or app.config.ts. Honestly, the plugin is the only correct way to set the microphone permission string on iOS and to enable background audio modes — don't try to hand-edit the Info.plist:
{
"expo": {
"plugins": [
[
"expo-audio",
{
"microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone."
}
]
]
}
}
If your app needs background playback (podcasts, music, meditation apps), also add the UIBackgroundModes entry for iOS and a foreground-service permission on Android:
{
"expo": {
"ios": {
"infoPlist": {
"UIBackgroundModes": ["audio"]
}
},
"android": {
"permissions": [
"WAKE_LOCK",
"RECORD_AUDIO",
"FOREGROUND_SERVICE",
"FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"FOREGROUND_SERVICE_MICROPHONE"
]
}
}
}
You'll need to run npx expo prebuild --clean and create a development build. None of this works in Expo Go, because expo-av was the last audio library shipped there.
Playing audio with useAudioPlayer
The simplest playback case is a single sound bundled with the app. Pass a static require() or a { uri } object to useAudioPlayer:
import { useAudioPlayer } from 'expo-audio';
import { Button, View } from 'react-native';
const beep = require('../assets/beep.mp3');
export default function PlayButton() {
const player = useAudioPlayer(beep);
return (
<View>
<Button title="Play" onPress={() => player.play()} />
<Button title="Pause" onPress={() => player.pause()} />
<Button title="Restart" onPress={() => {
player.seekTo(0);
player.play();
}} />
</View>
);
}
The hook owns the AudioPlayer instance for the lifetime of the component. When PlayButton unmounts, the native resources are released — you don't call release() yourself. That alone is worth the migration.
Remote streams and HLS
For remote URLs, pass an object. expo-audio autodetects HLS (.m3u8) on both platforms via ExoPlayer and AVPlayer:
const player = useAudioPlayer({
uri: 'https://example.com/podcasts/episode-12.m3u8',
});
Need to swap the source after mount (a playlist, for example)? Call player.replace(newSource) instead of re-mounting the component. It keeps the same native player and avoids a re-buffer.
Reactive playback status
The player object itself is mutable and doesn't trigger re-renders. To drive a progress bar, pair it with useAudioPlayerStatus:
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
import { Text, View } from 'react-native';
export default function NowPlaying({ source }) {
const player = useAudioPlayer(source);
const status = useAudioPlayerStatus(player);
return (
<View>
<Text>{status.isLoaded ? 'Ready' : 'Buffering…'}</Text>
<Text>
{status.currentTime.toFixed(1)} / {status.duration.toFixed(1)}s
</Text>
<Text>Playing: {String(status.playing)}</Text>
</View>
);
}
The shape returned by useAudioPlayerStatus includes playing, currentTime, duration, isLoaded, isBuffering, didJustFinish, and playbackRate. It re-renders only when those values actually change, which keeps your UI cheap.
Looping, rate, and volume
All transport state lives on the player instance as plain properties — no async setters here:
player.loop = true; // restart automatically when finished
player.volume = 0.5; // 0.0 – 1.0
player.muted = false;
player.setPlaybackRate(1.25, 'high'); // 1.25x with pitch correction
The pitch-correction algorithm options are 'low', 'medium', 'high', and 'varispeed'. Use 'high' for spoken-word content (podcasts, audiobooks) and 'varispeed' only if you actually want a chipmunk effect. (Yes, I tried it. No, you don't want it.)
Recording audio with useAudioRecorder
Recording follows the same hook pattern. Request permission first, then prepare and start:
import {
AudioModule,
RecordingPresets,
setAudioModeAsync,
useAudioRecorder,
useAudioRecorderState,
} from 'expo-audio';
import { Button, Text, View } from 'react-native';
import { useEffect } from 'react';
export default function VoiceMemo() {
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const state = useAudioRecorderState(recorder);
useEffect(() => {
(async () => {
const { granted } = await AudioModule.requestRecordingPermissionsAsync();
if (!granted) return;
await setAudioModeAsync({
playsInSilentMode: true,
allowsRecording: true,
});
})();
}, []);
const start = async () => {
await recorder.prepareToRecordAsync();
recorder.record();
};
const stop = async () => {
await recorder.stop();
console.log('Saved to', recorder.uri);
};
return (
<View>
<Text>Recording: {String(state.isRecording)}</Text>
<Text>Duration: {(state.durationMillis / 1000).toFixed(1)}s</Text>
<Button title="Start" onPress={start} disabled={state.isRecording} />
<Button title="Stop" onPress={stop} disabled={!state.isRecording} />
</View>
);
}
RecordingPresets.HIGH_QUALITY records 44.1 kHz / 128 kbps AAC in an .m4a container on both platforms. LOW_QUALITY drops to 22 kHz / 64 kbps — fine for voice memos that you'll transcribe with Whisper or send to an LLM.
Custom recording options
For a podcast app, or anything that needs a specific codec, pass an options object:
const recorder = useAudioRecorder({
extension: '.wav',
sampleRate: 48000,
numberOfChannels: 1,
bitRate: 256000,
bitRateStrategy: 'constant',
android: {
outputFormat: 'mpeg4',
audioEncoder: 'aac',
},
ios: {
audioQuality: 96,
outputFormat: 'lpcm',
linearPCMBitDepth: 16,
linearPCMIsBigEndian: false,
linearPCMIsFloat: false,
},
});
Bit-rate strategies are 'constant', 'longTermAverage', 'variableConstrained', and 'variable'. Constant is the safest choice for ML pipelines that expect a known bitrate; variable produces smaller files for the same perceived quality.
Background audio that actually works
This is the area where expo-av users got burned most often. With expo-audio, the rules are simpler:
- Enable background playback on the player itself.
- Configure the audio session with
setAudioModeAsync. - For Android, the foreground-service permissions in
app.jsonare non-optional.
import { setAudioModeAsync, useAudioPlayer } from 'expo-audio';
import { useEffect } from 'react';
export default function PodcastPlayer({ episode }) {
const player = useAudioPlayer({ uri: episode.audioUrl });
useEffect(() => {
setAudioModeAsync({
playsInSilentMode: true,
interruptionMode: 'duckOthers',
shouldPlayInBackground: true,
shouldRouteThroughEarpiece: false,
});
player.shouldCorrectPitch = true;
}, []);
return /* … */ null;
}
The interruption modes determine how your audio behaves when the user gets a phone call or starts a navigation prompt:
doNotMix— your audio pauses other apps and resumes after the interruption ends. Use it for music players.duckOthers— your audio plays at full volume, other apps are temporarily lowered. Use it for navigation, or podcasts that benefit from being audible over background music.mixWithOthers— your audio coexists at full volume with no focus request. Use it for short sound effects.
Lock-screen controls
On both platforms, the lock-screen "Now Playing" widget appears automatically once shouldPlayInBackground: true is set and the player is actively playing. To populate the metadata, set it on the player:
player.setMetadata({
title: episode.title,
artist: episode.show,
artworkUri: episode.coverUrl,
});
Play/pause from headphone buttons, plus CarPlay and Android Auto, are wired up by Media3 and MPRemoteCommandCenter respectively. You don't write any glue code for the common case — and that used to be a real source of pain.
Audio sampling for visualizations
Building a waveform or spectrum visualizer? useAudioSampleListener streams PCM samples to JavaScript on every audio frame. This is the modern replacement for the setOnAudioSampleReceived callback in expo-av:
import { useAudioPlayer, useAudioSampleListener } from 'expo-audio';
import { useState } from 'react';
export default function Visualizer({ source }) {
const player = useAudioPlayer(source);
const [level, setLevel] = useState(0);
player.setAudioSamplingEnabled(true);
useAudioSampleListener(player, (sample) => {
const channel = sample.channels[0]?.frames ?? [];
const peak = channel.reduce((m, f) => Math.max(m, Math.abs(f)), 0);
setLevel(peak);
});
return /* draw level */ null;
}
The sample object delivers normalized floats between -1.0 and 1.0. Run any expensive math (FFT, RMS) inside a useSharedValue/runOnUI worklet if you're pairing this with Reanimated 4. Sending dozens of state updates per second through React is a fast way to drop frames — ask me how I know.
Migrating from expo-av
The mental model shift is from imperative methods on a singleton Audio.Sound to declarative hooks. Good news, though: the actual code change per call site is usually three or four lines.
Playback migration
Before, with expo-av:
import { Audio } from 'expo-av';
const { sound } = await Audio.Sound.createAsync(require('./beep.mp3'));
await sound.playAsync();
// ...later
await sound.unloadAsync();
After, with expo-audio:
import { useAudioPlayer } from 'expo-audio';
const player = useAudioPlayer(require('./beep.mp3'));
player.play();
// unload happens automatically on unmount
Recording migration
Before:
import { Audio } from 'expo-av';
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
await recording.stopAndUnloadAsync();
const uri = recording.getURI();
After:
import { RecordingPresets, setAudioModeAsync, useAudioRecorder } from 'expo-audio';
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
await recorder.prepareToRecordAsync();
recorder.record();
// later
await recorder.stop();
const uri = recorder.uri;
Common rename map
Audio.Sound→useAudioPlayer/createAudioPlayerAudio.Recording→useAudioRecorder/AudioRecordersound.playAsync()→player.play()sound.pauseAsync()→player.pause()sound.setPositionAsync(ms)→player.seekTo(seconds)(note the unit change — easy to miss)sound.setStatusAsync({ rate, shouldCorrectPitch })→player.setPlaybackRate(rate, 'high')allowsRecordingIOS→allowsRecording(cross-platform now)playsInSilentModeIOS→playsInSilentModestaysActiveInBackground→shouldPlayInBackground
Run a project-wide search for from 'expo-av' and migrate one call site at a time. The two packages can coexist temporarily while you migrate, so you don't need a flag-day rewrite. (I'd still recommend gating the work behind a feature branch — auditing audio code is the kind of thing you want to do once, properly.)
Persisting players outside the component tree
The hook pattern works for 95% of use cases, but it breaks down when you want a single player to survive screen transitions — for example, a podcast that keeps playing while the user browses the episode list. In that case, drop down to createAudioPlayer and put the instance in a Zustand store or React context:
import { create } from 'zustand';
import { createAudioPlayer, AudioPlayer } from 'expo-audio';
type PlayerStore = {
player: AudioPlayer | null;
load: (uri: string) => void;
dispose: () => void;
};
export const usePlayerStore = create<PlayerStore>((set, get) => ({
player: null,
load: (uri) => {
get().player?.release();
const next = createAudioPlayer({ uri });
next.play();
set({ player: next });
},
dispose: () => {
get().player?.release();
set({ player: null });
},
}));
The trade-off is real: you now own the lifecycle. Forgetting release() leaks a native player, and that memory doesn't come back until the app is force-killed. Call it in your app's onAppStateChange handler if you ever swap users or sign out.
Troubleshooting
"Audio session activation failed" on iOS
Almost always caused by calling player.play() before setAudioModeAsync has resolved. Wrap your first play() in an effect that awaits the mode call, or just call setAudioModeAsync once at app startup and forget about it.
Background audio stops after ~30 seconds on Android
You're missing FOREGROUND_SERVICE_MEDIA_PLAYBACK in your android.permissions array. The OS kills the process once the activity is backgrounded if no media-playback foreground service is registered.
Recording produces a 0-byte file
You called recorder.record() without awaiting prepareToRecordAsync(). Unlike expo-av, the new API splits preparation from start and will silently no-op if you skip prep. (A more loudly-failing error message would be nice — maybe in a future SDK.)
Playback is silent on iOS even though status.playing is true
The device's silent switch is on and you haven't set playsInSilentMode: true. This is intentional — Apple's HIG says audio should respect the silent switch by default — but for media-playback apps you almost always want to override it.
FAQ
Is expo-audio production-ready in 2026?
Yes. expo-audio went stable in Expo SDK 52 and is the default audio module in SDK 55. It's used by Spotify, Audible, and Headspace's Expo-based prototypes, and the underlying engines (ExoPlayer/Media3 on Android, AVFoundation on iOS) are the same ones every major audio app on the App Store ships with.
Can I use expo-audio with bare React Native (no Expo)?
Yes. expo-audio is an Expo Module, which means it works in any React Native app via npx install-expo-modules. You'll need to configure the iOS background mode and Android foreground-service permissions manually in the native projects, since you're not using the config plugin.
What is the difference between expo-audio and react-native-track-player?
react-native-track-player is a higher-level library with a built-in queue, persistent service, and richer remote-command handling. expo-audio is a thinner wrapper that gives you one player at a time and expects you to manage queueing yourself. For most podcast and music apps, expo-audio plus a small Zustand store covers what you need. If you need gapless queue playback across the app's full lifecycle, react-native-track-player is still the better fit.
Does expo-audio support HLS and DASH?
HLS is supported on both iOS and Android out of the box. DASH is not currently supported — if you have DASH-only audio streams, you'll need to transcode to HLS or reach for a third-party library.
How do I record stereo audio?
Set numberOfChannels: 2 in your recording options. Most phone microphones are mono, so you'll only get true stereo if the user has plugged in or paired a stereo input device. The recording will still succeed with a single-channel input — you'll just get duplicated mono on both channels.
Where to go next
Once your audio code is on expo-audio, the same migration playbook applies to video — see our companion guide on migrating from expo-av to expo-video. If you're also moving to SDK 55 in this cycle, the full breaking-change list lives in our Expo SDK 55 Migration Guide. Together, those three migrations close out the multi-year deprecation arc that began when Expo first split AV into focused modules.