●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Taking a Rork Podcast App to Production — Migrating to expo-audio, Recovering from Interruptions, and Resumable Downloads
Implementation notes for making a Rork podcast app survive real use: migrating off expo-av before SDK 55 removes it, recovering from call interruptions, resuming broken downloads, and budgeting storage against iCloud backup.
Building a podcast app sounds simple until you hit the first wall: audio that stops the moment the user locks their screen. I saw this exact issue in TestFlight feedback while building an audio wellness app — users would start playback, put the phone in their pocket, and the audio would stop within seconds. The fix turned out to be a two-line configuration change, but finding those two lines took longer than it should have.
Plan the Architecture Before Writing Code
Before generating any screens, settle on what you're building:
Feed management: Add RSS URLs, fetch and display episode lists
Playback: Background audio, seek bar, speed control (0.75x to 2x)
Downloads: Save episodes for offline listening
Lock screen: Show episode info in iOS Control Center and Android media notification
For state management, Zustand works best here. Audio is a natural singleton — any screen needs to access and control the same playback state.
Define your data shapes upfront. This also makes prompting Rork's AI more effective — "use these TypeScript types to generate a screen" produces far more accurate code than open-ended requests.
The most common support request in audio apps is audio stopping when the screen locks. It always traces back to one of two missing configurations — and you need both.
Without UIBackgroundModes: ["audio"], iOS suspends your app's audio session the moment it backgrounds, regardless of any code-level settings. This is the declaration that tells iOS your app is a legitimate audio app.
Configuration 2: Audio Session at Runtime
// hooks/useAudioSession.tsimport { Audio } from 'expo-av';import { useEffect } from 'react';export function useAudioSession() { useEffect(() => { const initAudioSession = async () => { try { await Audio.setAudioModeAsync({ // iOS: continue audio when app goes to background staysActiveInBackground: true, // iOS: play even in silent mode playsInSilentModeIOS: true, allowsRecordingIOS: false, // Android: fully stop other audio rather than ducking shouldDuckAndroid: false, interruptionModeAndroid: Audio.INTERRUPTION_MODE_ANDROID_DO_NOT_MIX, interruptionModeIOS: Audio.INTERRUPTION_MODE_IOS_DO_NOT_MIX, }); } catch (error) { // Audio session failure shouldn't crash the app — log and continue console.error('Failed to initialize audio session:', error); } }; initAudioSession(); }, []);}
Call this hook exactly once in your root layout (app/_layout.tsx). Calling it before every playback resets the iOS audio session and can cause other apps' audio to unexpectedly resume.
On shouldDuckAndroid: false: setting this to true means a phone call will just lower your volume rather than pausing playback. For podcasts, pausing on interruption is better UX — users want to catch what was said after a call, not miss it.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A mapping table for migrating off expo-av before SDK 55 removes it, including the known Android limitation on interruptionMode
✦Resumable downloads built on resumeData — why file existence is the wrong completion check and how HTTP 206 breaks naive code
✦Surviving three kinds of interruption (calls, unplugged headphones, storage pressure) plus five one-minute hardware checks
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Everything above assumes expo-av. It works. But if you're starting fresh in 2026, that assumption won't hold much longer.
Expo deprecated the Audio and Video APIs in expo-av in favor of expo-audio and expo-video. In SDK 54, expo-av still runs but is no longer part of the official SDK, and it is removed in SDK 55 (Expo docs: Audio (expo-av)).
So a podcast app built on expo-av today will be forced into a rewrite at the next-but-one major upgrade.
I chose not to put that in the "deal with it later" box. The reasoning is simple: audio playback is the core of this app, and I don't want to rewrite the core in the same week I'm shipping a paywall or a redesign. Migrations are cheapest when nothing else is broken.
Separate interruptionModeIOS / interruptionModeAndroid
One interruptionMode: 'doNotMix'
Teardown
Call unloadAsync() yourself
Follows the hook lifecycle
That last row is the real payoff. Further down, this guide covers the classic bug of forgetting to unload a Sound and leaking memory. In expo-audio, the room to make that mistake mostly disappears — what you were tracking by hand now rides on the hook's lifetime.
Audio Session Setup After Migration
// hooks/useAudioSession.ts (expo-audio)import { setAudioModeAsync } from 'expo-audio';import { useEffect } from 'react';export function useAudioSession() { useEffect(() => { setAudioModeAsync({ // Keep playing in the background — pairs with UIBackgroundModes on iOS shouldPlayInBackground: true, // Play even when the silent switch is on playsInSilentMode: true, // Don't mix with other apps. Also required to hold lock screen controls interruptionMode: 'doNotMix', }).catch((error) => { // Failing here shouldn't kill playback. Log and keep going console.error('Failed to configure audio mode:', error); }); }, []);}
The UIBackgroundModes: ["audio"] entry in app.json is still required. That part doesn't change. Background playback remains the product of two settings in two places, no matter which library you use.
The Player After Migration
// components/EpisodePlayer.tsx (expo-audio)import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';import { View, Text, Pressable } from 'react-native';import { Episode } from '../types/podcast';export function EpisodePlayer({ episode }: { episode: Episode }) { // Prefer the local file when it exists, otherwise stream const source = episode.localPath ?? episode.audioUrl; const player = useAudioPlayer(source); const status = useAudioPlayerStatus(player); const toggle = () => { if (status.playing) { player.pause(); } else { player.play(); } }; return ( <View> <Text>{episode.title}</Text> <Pressable onPress={toggle}> <Text>{status.playing ? 'Pause' : 'Play'}</Text> </Pressable> <Text> {Math.floor(status.currentTime)} / {Math.floor(status.duration ?? 0)} s </Text> </View> );}
The whole pattern of registering a status callback and calling setState inside it goes away. useAudioPlayerStatus returns something you can render. The line count drops, but more importantly the number of state variables you have to reason about drops.
The Bump I Hit
One caveat worth knowing. On Android, setAudioModeAsync has been reported to only apply shouldPlayInBackground and shouldRouteThroughEarpiece from the object you pass, leaving interruptionMode without effect (expo/expo #34473).
Getting interruptions right on iOS is no guarantee you got them right on Android. After migrating, put a real phone call through a real device. This is not something a simulator will surface.
Migrate, or Stay?
Situation
Call
Why
Starting a new app
expo-audio from day one
The only moment migration costs nothing
Shipping on SDK 52 or older, no changes planned
Stay
Not touching what works is a legitimate call
Planning an upgrade to SDK 54
Migrate in the same sprint
Ride the regression testing you're already paying for
You also record audio
Test recording first
The useAudioRecorder API differs more than playback does
The rest of this guide stays on expo-av, since most readers will be reading it against an existing app. Keep the mapping table above nearby as you go.
Parsing RSS Feeds
Podcast RSS follows RSS 2.0 with Apple's iTunes namespace extensions (itunes: prefix). A robust parser handles both.
// lib/rssParser.tsimport xml2js from 'react-native-xml2js';import { Episode, PodcastFeed } from '../types/podcast';/** * Fetch and parse a podcast RSS feed * @throws on network failure or malformed XML */export async function parsePodcastFeed(rssUrl: string): Promise<PodcastFeed> { const response = await fetch(rssUrl, { headers: { // Some podcast hosting servers return 403 without a User-Agent 'User-Agent': 'PodcastApp/1.0', }, }); if (!response.ok) { throw new Error(`Failed to fetch feed: HTTP ${response.status}`); } const xmlText = await response.text(); const result = await new Promise<any>((resolve, reject) => { xml2js.parseString( xmlText, { normalizeTags: true, explicitArray: true, explicitCharkey: true }, (err: Error | null, parsed: any) => { if (err) reject(new Error(`XML parse error: ${err.message}`)); else resolve(parsed); } ); }); const channel = result?.rss?.channel?.[0]; if (!channel) throw new Error('Invalid RSS feed structure'); const feedTitle = channel.title?.[0]?._ ?? channel.title?.[0] ?? 'Unknown Podcast'; const feedDescription = channel.description?.[0]?._ ?? channel.description?.[0] ?? ''; const feedImage = channel['itunes:image']?.[0]?.$.href ?? channel.image?.[0]?.url?.[0] ?? ''; const items: any[] = channel.item ?? []; const episodes: Episode[] = items .map((item: any, index: number) => { const audioUrl = item.enclosure?.[0]?.$.url ?? ''; if (!audioUrl) return null; const durationStr = item['itunes:duration']?.[0]?._ ?? item['itunes:duration']?.[0] ?? '0'; return { id: item.guid?.[0]?._ ?? item.guid?.[0] ?? `episode-${index}`, title: item.title?.[0]?._ ?? item.title?.[0] ?? `Episode ${index + 1}`, description: stripHtml( item['itunes:summary']?.[0]?._ ?? item.description?.[0]?._ ?? item.description?.[0] ?? '' ), audioUrl, pubDate: new Date(item.pubdate?.[0] ?? Date.now()), duration: parseDuration(durationStr), imageUrl: item['itunes:image']?.[0]?.$.href ?? feedImage, }; }) .filter((ep): ep is Episode => ep !== null); return { id: rssUrl, title: feedTitle, description: feedDescription, imageUrl: feedImage, rssUrl, episodes, lastUpdated: new Date(), };}function parseDuration(raw: string): number { if (!raw) return 0; const parts = raw.split(':').map(Number); if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; if (parts.length === 2) return parts[0] * 60 + parts[1]; return Number(raw) || 0;}function stripHtml(html: string): string { return html .replace(/<[^>]*>/g, '') .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, "'") .trim();}
The chained ?? lookups handle format variations across podcast hosts. In production, RSS parsing failures almost always come from an unexpected field location rather than truly malformed XML.
Episode Downloads with Progress Tracking
Downloads separate apps users keep from apps they delete. Three implementation decisions matter here:
1. Use documentDirectory, not cacheDirectory (critical — see common mistakes below)
2. Show percentage progress, not just a spinner
3. Clean up partial files on failure so corrupted downloads don't block future attempts
The downloadEpisode above only works when the connection holds all the way through. Real podcast files run 30MB to 100MB. Enter a tunnel and it drops. Background the app and the OS suspends it.
The name createDownloadResumable promises that you can resume. It does not promise the resume happens on its own. Unless you persist what's needed to resume, the next launch starts from zero bytes.
SDK 54 Moved the Import
One migration note first. In SDK 54, the default export of expo-file-system was replaced by the new object-oriented API, and the familiar documentDirectory and createDownloadResumable moved to expo-file-system/legacy (Expo Blog: Expo File System gets a major upgrade in SDK 54).
// SDK 53 and earlierimport * as FileSystem from 'expo-file-system';// SDK 54+, keeping the existing code working as-isimport * as FileSystem from 'expo-file-system/legacy';
This surfaces right after the upgrade as FileSystem.documentDirectory suddenly being undefined. It can eat an afternoon before you realize the fix is one import line. Point the import at /legacy to get back to working, then treat the move to the new API as separate work.
Persist the Resume Data
A DownloadResumable holds resumeData once you call pauseAsync(). Serialize it with savable(), store it, and rebuild from it next time.
// lib/downloadManager.tsimport * as FileSystem from 'expo-file-system/legacy';import AsyncStorage from '@react-native-async-storage/async-storage';import { Episode } from '../types/podcast';const DOWNLOAD_DIR = FileSystem.documentDirectory + 'podcasts/';const RESUME_KEY = (id: string) => `download:resume:${id}`;type Snapshot = { url: string; fileUri: string; options: FileSystem.DownloadOptions; resumeData?: string;};/** * Start a download, or continue one that was interrupted. */export async function startOrResumeDownload( episode: Episode, onProgress: (progress: number) => void): Promise<string> { await ensureDownloadDir(); const safeId = episode.id.replace(/[^a-zA-Z0-9-_]/g, '_'); const ext = episode.audioUrl.split('.').pop()?.split('?')[0] ?? 'mp3'; const localPath = DOWNLOAD_DIR + `${safeId}.${ext}`; const existing = await FileSystem.getInfoAsync(localPath); if (existing.exists && !(await AsyncStorage.getItem(RESUME_KEY(episode.id)))) { // File on disk, no snapshot left behind => it finished onProgress(1.0); return localPath; } const onWrite = (p: FileSystem.DownloadProgressData) => { if (p.totalBytesExpectedToWrite > 0) { onProgress(Math.min(p.totalBytesWritten / p.totalBytesExpectedToWrite, 1.0)); } }; const saved = await AsyncStorage.getItem(RESUME_KEY(episode.id)); const snapshot: Snapshot | null = saved ? JSON.parse(saved) : null; const downloadResumable = snapshot ? new FileSystem.DownloadResumable( snapshot.url, snapshot.fileUri, snapshot.options, onWrite, snapshot.resumeData ) : FileSystem.createDownloadResumable( episode.audioUrl, localPath, { headers: { 'User-Agent': 'PodcastApp/1.0' } }, onWrite ); const result = snapshot ? await downloadResumable.resumeAsync() : await downloadResumable.downloadAsync(); if (!result) { // Interrupted. Keep the resume data so the next launch can pick it up await AsyncStorage.setItem( RESUME_KEY(episode.id), JSON.stringify(downloadResumable.savable()) ); throw new DownloadInterrupted(episode.id); } if (result.status !== 200 && result.status !== 206) { // 206 is the normal path when resuming. Anything else, throw the file away await FileSystem.deleteAsync(localPath, { idempotent: true }); await AsyncStorage.removeItem(RESUME_KEY(episode.id)); throw new Error(`Download failed: HTTP ${result.status}`); } // Completed. Clearing the snapshot is the only marker of "done" await AsyncStorage.removeItem(RESUME_KEY(episode.id)); return result.uri;}export class DownloadInterrupted extends Error { constructor(public episodeId: string) { super(`Download interrupted: ${episodeId}`); this.name = 'DownloadInterrupted'; }}
If there's one design decision worth extracting here, it's this: don't use file existence to decide whether a download finished.
An interrupted download leaves a file on disk too. Comparing size against an expected value fails on feeds whose CDN omits Content-Length. So completion is defined by the absence of a resume snapshot in AsyncStorage — present means unfinished, gone means done. One source of truth, in one place.
Don't Treat 206 as an Error
The original code called anything other than result.status !== 200 a failure. A resumed download comes back as 206 Partial Content, so that condition throws the moment resume actually starts working.
A surprising share of "I implemented resume and it doesn't work" reports come down to exactly this. If you add resume, widen the accepted status codes in the same change.
Wire Recovery into App Launch
// Once, in app/_layout.tsximport { useEffect } from 'react';import AsyncStorage from '@react-native-async-storage/async-storage';export function useResumePendingDownloads() { useEffect(() => { const resumeAll = async () => { const keys = await AsyncStorage.getAllKeys(); const pending = keys.filter((k) => k.startsWith('download:resume:')); // Resuming everything at once starves every download. Go one at a time for (const key of pending) { const episodeId = key.replace('download:resume:', ''); try { await resumeSingle(episodeId); } catch { // One failure shouldn't stop the rest. Next launch picks it up } } }; resumeAll(); }, []);}
Resuming serially is deliberate. In parallel, four downloads split the bandwidth, none of them finish, and the user watches four progress bars crawl. What people want isn't "everything moves a little." It's "one thing finishes."
Sweep Orphaned Files
Sometimes a snapshot disappears and the partial file stays. Short of a reinstall, it quietly eats storage forever.
/** * Delete files that are neither a known download nor pending resume. * Call this somewhere unhurried — a settings screen, not app launch. */export async function sweepOrphans(knownEpisodeIds: string[]): Promise<number> { const files = await FileSystem.readDirectoryAsync(DOWNLOAD_DIR); const alive = new Set(knownEpisodeIds.map((id) => id.replace(/[^a-zA-Z0-9-_]/g, '_'))); let removed = 0; for (const name of files) { const base = name.replace(/\.[^.]+$/, ''); if (!alive.has(base)) { await FileSystem.deleteAsync(DOWNLOAD_DIR + name, { idempotent: true }); removed += 1; } } return removed;}
Once background playback works, the next report is almost always: "I took a call, hung up, and the audio never came back."
The iOS audio session stops your playback when something interrupts it — a call, an alarm, another app. That part is correct behavior. The problem is that nothing decides who resumes afterward.
The OS tells you the interruption ended. It does not resume for you. Natively you'd observe AVAudioSession interruption notifications, check the shouldResume option, and call play() yourself. expo-av gives you no direct access to those notifications.
Approximating It with AppState
Not a perfect substitute, but it catches most of the real cases.
// hooks/useInterruptionRecovery.tsimport { useEffect, useRef } from 'react';import { AppState, AppStateStatus } from 'react-native';import { usePlayerStore } from '../store/playerStore';export function useInterruptionRecovery() { // Whether the *user* wanted audio. Distinct from whether the OS is playing const intendedToPlay = useRef(false); useEffect(() => { const unsubscribe = usePlayerStore.subscribe((state) => { // Only user-driven play/pause updates intent if (state.lastChangeSource === 'user') { intendedToPlay.current = state.isPlaying; } }); return unsubscribe; }, []); useEffect(() => { const handleChange = async (next: AppStateStatus) => { if (next !== 'active') return; const { sound, isPlaying, resume } = usePlayerStore.getState(); if (!sound) return; const status = await sound.getStatusAsync(); if (!status.isLoaded) return; // Wanted to play, but the OS stopped us => an interruption took it if (intendedToPlay.current && !status.isPlaying && !isPlaying) { await resume(); } }; const sub = AppState.addEventListener('change', handleChange); return () => sub.remove(); }, []);}
The point is holding intendedToPlay separately from the OS playback state.
Use isPlaying as your resume signal and you get the worst possible behavior: a user pauses on purpose, goes to the home screen, comes back, and audio starts blasting. They paused because they wanted silence. Intent and state have to live in different variables or you can't tell those cases apart.
When the Headphones Come Out
One more behavior, and this one can draw review attention. Unplug the earbuds and audio starts playing out of the speaker. On a train, that has real consequences.
iOS pauses playback on a route change, but depending on your expo-av configuration it can slip through. Setting interruptionModeIOS: INTERRUPTION_MODE_IOS_DO_NOT_MIX isn't only about holding lock screen controls — it also guards this.
In expo-audio, interruptionMode: 'doNotMix' plays the same role. Given the reported Android limitation above, testing on a physical Android device is not optional.
Test on Hardware, in This Order
Simulators can't ring and can't unplug. On a real device, this sequence gets there fastest.
#
Action
Expected
What usually happens
1
Lock the screen mid-playback
Audio continues
Stops after seconds (UIBackgroundModes missing)
2
Take a call, then hang up
Audio resumes
Stays silent (no interruption recovery)
3
Pause yourself, background, return
Stays paused
Starts playing on its own (intent vs. state confused)
4
Unplug headphones mid-playback
Pauses
Blasts from the speaker
5
Play a video in another app
Your audio stops
Both play at once (doNotMix not set)
Each takes about a minute. And in my experience no implementation passes all five on the first try — something always breaks. That's exactly why this belongs on a pre-release checklist rather than in your head.
If one feature separates a podcast app from a music player, it's remembering the playback position.
Twenty minutes of an hour-long interview on the way in, twenty-five on the way home, the rest tomorrow morning. If that doesn't work, people don't come back. And position saving, written the obvious way, almost always breaks.
Writing Every Tick Clogs the Queue
setOnPlaybackStatusUpdate fires roughly every 500ms by default. Write to AsyncStorage there and an hour of listening means 7,200 writes.
// ❌ Persisting on every status updatesound.setOnPlaybackStatusUpdate((status) => { if (!status.isLoaded) return; AsyncStorage.setItem(`pos:${episodeId}`, String(status.positionMillis));});
It often isn't slow enough to notice, which is exactly why it ships. But with writes queued up, backgrounding the app can drop the last one. That's a real cause of "I was listening and it jumped back."
Throttle the Writes, Force the Important Ones
// lib/positionStore.tsimport AsyncStorage from '@react-native-async-storage/async-storage';const KEY = (id: string) => `pos:${id}`;const SAVE_INTERVAL_MS = 5000;// Episodes listened to the end start fresh next timeconst NEAR_END_THRESHOLD_MS = 30_000;let lastSavedAt = 0;export async function savePosition( episodeId: string, positionMillis: number, durationMillis: number, options: { force?: boolean } = {}): Promise<void> { const now = Date.now(); if (!options.force && now - lastSavedAt < SAVE_INTERVAL_MS) return; lastSavedAt = now; // Close enough to the end? Drop the position and call it finished if (durationMillis > 0 && durationMillis - positionMillis < NEAR_END_THRESHOLD_MS) { await AsyncStorage.removeItem(KEY(episodeId)); return; } await AsyncStorage.setItem(KEY(episodeId), String(Math.floor(positionMillis)));}export async function loadPosition(episodeId: string): Promise<number> { const raw = await AsyncStorage.getItem(KEY(episodeId)); return raw ? Number(raw) : 0;}
Then give the throttling a place to make up for itself.
// app/_layout.tsximport { AppState } from 'react-native';import { usePlayerStore } from '../store/playerStore';import { savePosition } from '../lib/positionStore';AppState.addEventListener('change', (next) => { if (next === 'active') return; // On the way out, ignore the throttle and write no matter what const { currentEpisode, positionMillis, durationMillis } = usePlayerStore.getState(); if (currentEpisode) { savePosition(currentEpisode.id, positionMillis, durationMillis, { force: true }); }});
Throttled saves every five seconds, plus one forced save as the app goes to the background. Worst case you lose five seconds of position. In an ordinary exit you lose none.
Why the Last 30 Seconds Count as Finished
NEAR_END_THRESHOLD_MS drops the saved position for episodes within 30 seconds of the end.
The tail of an episode is usually credits or a teaser for next week. Someone who stopped there finished it — they weren't interrupted. Save a position anyway and their next tap starts playback with ten seconds left, forcing them to manually seek back to the start.
Thirty seconds is hardcoded here, and how well it holds depends on episode length. For a five-minute episode, thirty seconds is a tenth of the show. Taking the smaller of a percentage and an absolute is closer to right in practice. This is the kind of number worth setting from the distribution of your own feed rather than accepting a default and moving on.
Why Nothing Shows Up on the Lock Screen
The architecture section listed "lock screen support." Time to be honest about that.
expo-av has no API for putting a title, show name, artwork, or skip buttons on the lock screen. On iOS, Now Playing info lives in MPNowPlayingInfoCenter and remote controls in MPRemoteCommandCenter. expo-av exposes neither.
Because it holds the audio session, you get bare play/pause on the lock screen. You do not get the show name. You do not get artwork. You do not get "skip 30 seconds" or "back 15." The single most-used control in any podcast app sits precisely in the part you don't get.
This Is the Fork in the Road
What you're building
Reasonable choice
Rationale
Personal or internal audio player
Stay on expo-audio
Lock screen polish isn't a requirement
Short audio as a secondary feature
Stay on expo-audio
Nobody reaches for the lock screen
A published podcast or music app
react-native-track-player
Now Playing, skip intervals, and queues are requirements
Scaffolding with Rork, then growing it
Expo for the shell, swap the playback layer
Keeps the generated work
That last row is the practical answer when you're building on Rork.
Tell Rork "read an RSS feed, list episodes, navigate to a player screen" and you get screens, navigation, type definitions, and state management scaffolding in minutes. That scaffolding has real value. There's no reason to throw it away.
What you swap is the playback layer alone. That's part of why this guide routes playback through a Zustand store and keeps the audio object inside playerStore. The UI only ever talks to usePlayerStore. Whether the implementation underneath is expo-av or react-native-track-player, the screens don't change.
Take generated scaffolding, then draw one boundary around the piece most likely to be replaced. That's what I do every time I let Rork or Claude Code produce a skeleton. Rewriting everything by hand is too slow; shipping the generation untouched leaves you stuck later. One boundary lands neatly in between.
Planning for the Day Storage Fills Up
Later in this guide there's a rule: use documentDirectory, not cacheDirectory. As a fix for "my episodes vanished overnight," that's right. It's also not the whole story.
documentDirectory — the iOS Documents folder — is included in iCloud backup. Let 2GB of episodes accumulate there and that 2GB weighs on the user's backup. For someone on the free 5GB tier, that alone can stall their photo backups.
Apple's data storage guidelines don't expect re-downloadable content to sit unmarked in Documents. Data you can fetch again belongs in Caches, or carries a backup-exclusion flag. Podcast episodes are the textbook case of "we can get this from the server again."
Between Getting Purged and Getting Bloated
Location
OS may purge?
In iCloud backup?
Fit for podcasts
cacheDirectory
Yes, under storage pressure
No
✗ Deletes what the user chose to save
documentDirectory (unmarked)
No
Yes
△ Bloats backups
documentDirectory + exclusion flag
No
No
✓ But Expo's public API can't set the flag
Row three is the ideal, and row three isn't reachable through expo-file-system alone. Setting isExcludedFromBackup means touching native code — a config plugin, or a library that wraps it.
If You Can't Set the Flag, Cap the Volume
If a config plugin feels heavy, there's another road: accept that the files get backed up, and put a ceiling on how much gets backed up.
// lib/storageBudget.tsimport * as FileSystem from 'expo-file-system/legacy';const DOWNLOAD_DIR = FileSystem.documentDirectory + 'podcasts/';const BUDGET_BYTES = 1.5 * 1024 * 1024 * 1024; // 1.5GB ceilingtype Entry = { path: string; size: number; lastPlayedAt: number };/** * Evict least-recently-played episodes until we're back under budget. * The currently playing episode is protected. */export async function evictIfOverBudget( entries: Entry[], protectedPath?: string): Promise<string[]> { const total = entries.reduce((sum, e) => sum + e.size, 0); if (total <= BUDGET_BYTES) return []; // Oldest listens go first. Never-played (lastPlayedAt = 0) is excluded entirely const candidates = entries .filter((e) => e.path !== protectedPath && e.lastPlayedAt > 0) .sort((a, b) => a.lastPlayedAt - b.lastPlayedAt); const evicted: string[] = []; let freed = 0; for (const entry of candidates) { if (total - freed <= BUDGET_BYTES) break; await FileSystem.deleteAsync(entry.path, { idempotent: true }); freed += entry.size; evicted.push(entry.path); } return evicted;}
Look at the filter. Never-played episodes (lastPlayedAt === 0) are excluded from eviction entirely.
Downloading means "I intend to listen to this." Deleting the unlistened ones to save space betrays what the action meant. Finished episodes, on the other hand, are fair game. Same automatic deletion, opposite experience, depending only on which end you delete from.
And always show what you deleted. A usage figure in settings plus one line — "removed 4 finished episodes to free space" — turns "it vanished" into "that's how it works." Technically identical. The difference is whether you said so.
Three Common Mistakes and Their Fixes
These patterns appear repeatedly in podcast app code reviews — each one looks fine until something specific goes wrong.
Mistake 1: Calling setAudioModeAsync before every playback
// Wrong: resetting the audio session each time can resume other apps' audioconst playEpisode = async (episode: Episode) => { await Audio.setAudioModeAsync({ staysActiveInBackground: true }); // Don't do this const { sound } = await Audio.Sound.createAsync({ uri: episode.audioUrl });};// Right: configure once at startup in useAudioSession()// Called in app/_layout.tsx useEffect, never again
Mistake 2: Not unloading Sound objects before creating new ones
// Wrong: the previous Sound stays loaded, consuming native audio resourcesconst playNext = async (episode: Episode) => { const { sound } = await Audio.Sound.createAsync({ uri: episode.audioUrl }); setCurrentSound(sound); // What happened to the old sound?};// Right: always unload before creating a new Soundconst playNext = async (episode: Episode) => { if (currentSound) { await currentSound.unloadAsync(); // Release first } const { sound } = await Audio.Sound.createAsync({ uri: episode.audioUrl }); setCurrentSound(sound);};
This is the most common cause of podcast apps getting slower over long sessions.
Mistake 3: Using cacheDirectory for downloaded episodes
// Wrong: the OS can delete cacheDirectory contents when storage is lowconst localPath = FileSystem.cacheDirectory + 'episode.mp3';// Right: documentDirectory persists until explicitly deletedconst localPath = FileSystem.documentDirectory + 'podcasts/episode.mp3';
When users report that downloaded episodes "disappeared," cacheDirectory is almost always the cause. The OS is aggressively cleaning up to free space.
App Store and Google Play Submission
iOS: Explaining Background Audio Use
In App Store Connect, you'll encounter a question about why your app needs background execution. In the Review Notes field, add: "This is a podcast player application. Audio must continue when the user locks their screen or switches to other apps, which is the primary use case."
Apple's reviewers will test this — they'll start playback, switch apps, wait a minute, and verify audio is still playing.
The hard part of a podcast app isn't any individual piece. RSS parsing isn't hard. Downloading isn't hard. Playback isn't hard. What's hard is making all of them survive the assumption that they will be interrupted, preempted, and purged.
The calls this guide made, in one place:
Question
Position taken here
expo-av or expo-audio?
expo-audio for anything new. expo-av is gone in SDK 55
How do you know a download finished?
By the absence of a resume snapshot, not the presence of a file
Resuming after an interruption
Track user intent in a variable separate from OS playback state
Lock screen metadata
react-native-track-player for published apps. Wrap the playback layer in a boundary
Where downloads live
documentDirectory plus a storage budget. Evict finished episodes first
Saving playback position
Throttle to 5s, force a write on the way out
If you try one thing today, run the five hardware checks above. Take a call and hang up. Unplug the headphones. Pause yourself and come back. If you already have an audio app shipping, five minutes should turn up two or three problems. It always does for me.
The order holds when you're scaffolding with Rork too. Get one RSS feed rendering a list. Get one episode playing. Only then work through interruptions, resumption, and storage, one at a time. Ask for all of it up front and you lose the ability to tell which part of the generated code fails to meet your requirements.
Audio apps are the ones where users spend the most time not looking at the screen. Almost all the effort goes into not breaking where nobody can see. It's unglamorous work, and it's repaid quietly, in people who simply keep using the thing.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.