Shipnative
ShipnativeShipnative
Sign in
GuideSeptember 2026 · 8 min read

Expo Audio: Playback, Recording, and the Silent Switch

Audio is the feature where “it works on my machine” is most likely to mean “my ringer happened to be on.” The code that plays a sound is four lines; the settings that decide whether anyone hears it live somewhere else entirely — in an audio-mode configuration, in a native capability baked in at build time, and in a hardware switch on the side of the phone. This guide covers playback and recording with expo-audio, the audio-mode decisions that matter per app type, and what actually changes if you are coming from expo-av.

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-av gave you an imperative Sound object 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 Sound that 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 alone

Pick the row that matches what you are building and stop thinking about it:

App typeSilent switchBackgroundOther apps
Podcast / music playerPlay anywayYes — requiredInterrupt others
Meditation / sleep appPlay anywayYes — requiredMix with others
Voice recorder / memo appPlay anyway on playbackOnly if recording continuesInterrupt others
Game with sound effectsRespect the switchNoMix, or duck music
App with UI chirpsRespect the switchNoMix 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:

  1. 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 NaN for the first frames. Default it to zero and hide the scrubber until it is real.
  2. 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.
  3. 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.
  4. 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.

Frequently Asked Questions

Why does audio not play on iOS when the ringer switch is on silent?

Because that is the default and correct behaviour — iOS treats app audio as ambient unless you say otherwise, and the hardware silent switch mutes ambient audio. If your app is a podcast player, a music app, or anything where sound is the point, you have to configure the audio mode to keep playing in silent mode. If your app just plays notification chirps, leave the default alone: overriding it so a game plays sound through a silenced phone in a meeting is exactly the behaviour users uninstall over.

Should I use expo-audio or expo-av?

expo-audio for new work. The old expo-av package bundled audio and video behind one API and has been split into the focused expo-audio and expo-video packages, with expo-av on the way out. The APIs are not identical — expo-audio is hook-first rather than an imperative Sound object — so a migration is a real rewrite of the playback layer, not a rename. Do it while the audio code is small.

How do I keep audio playing when the app is in the background?

Two separate things have to be true. The native project must declare background audio capability — on iOS the audio background mode in Info.plist, configured through app.json and baked in at build time — and the audio mode must be set to stay active when the app is not in the foreground. Miss the native declaration and playback stops the moment the user leaves your app, no matter what you set in JavaScript, and no amount of reloading will fix it because it is a build-time value.

Why does my recording fail on the first try but work the second time?

Because the first call triggered the permission prompt and returned before the user answered it. Request microphone permission explicitly, await the result, and only then start recording. Relying on the recording call to prompt for you produces exactly this pattern: silent failure once, works forever after, impossible to reproduce on a device that already granted permission.

Where does an Expo audio recording get saved?

To a temporary file inside your app container, and you get its URI when the recording stops. That location is not a home — move the file into your document directory if the user should keep it, since anything left in temporary or cache space can be purged by the OS without warning. Store the filename rather than the full URI, because the absolute container path can change between app updates.

How do I show a waveform or level meter while recording?

Enable metering on the recorder and read the metering value from the status updates it emits. It arrives in decibels — a negative scale where roughly -160 is silence and 0 is the loudest signal — so you need to map that range onto a bar height rather than plotting it raw. Smooth the values across a few frames too, or the meter jitters unreadably at typical update rates.

→

React Native Video Player

The other half of the expo-av split — playback, controls, and fullscreen.

Read guide →
→

Expo FileSystem: Which Directory

Where a finished recording should live so the OS does not delete it.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.