Background Audio & HLS Streaming in React Native
Mobile18 min read

Background Audio & HLS Streaming in React Native

Build a production-grade background music player in React Native with react-native-track-player: lock-screen controls, queue management, HLS streaming, gapless playback, and a real iOS config-plugin fix for an AVPlayerItem auto-start bug.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

Mar 18, 2026
#React Native#Expo#react-native-track-player#HLS#iOS#Audio#Streaming

Background audio in React Native is the ability for an app to keep playing sound when the screen is locked or the app is backgrounded, and the standard way to implement it is react-native-track-player. The library runs playback in a dedicated headless service (an Android foreground service backed by ExoPlayer, and an iOS background audio session backed by AVPlayer/SwiftAudioEx) so audio survives even when the React tree is unmounted. It also wires lock-screen and notification controls to the native MediaSession and MPRemoteCommandCenter APIs, manages a full playback queue, and streams progressive files as well as adaptive HLS (.m3u8) and DASH. This guide walks through installation, registering the playback service, capabilities, queue management, and HLS streaming, then closes with a real production war story: an iOS HLS auto-start bug fixed by patching the underlying Swift audio engine through an Expo config plugin.

Why Use react-native-track-player?

You could reach for expo-av (or its successor expo-audio) for simple sound effects, but a real music player needs a foreground service on Android, an AVAudioSession-backed player on iOS, remote command handling, and a Now Playing / MediaSession integration. react-native-track-player wraps all of that behind a single JavaScript API and runs playback in a dedicated headless service so audio survives even when your React tree is unmounted.

What the library gives you out of the box:

  • A background-capable playback service that keeps running when the app is backgrounded or killed from the recents list (Android foreground service, iOS background audio mode)
  • Lock-screen and Control Center / notification controls wired to native MediaSession (Android) and MPRemoteCommandCenter (iOS)
  • A managed queue with add, remove, skip, move, and repeat/shuffle semantics
  • Streaming support for progressive files, HLS (.m3u8), DASH, and SmoothStreaming via the underlying native players (ExoPlayer on Android, AVPlayer/SwiftAudioEx on iOS)
  • A rich event API: playback state, track changes, remote commands, progress updates, and buffering
  • Gapless-ish auto-advance with configurable buffering so the next track is ready before the current one ends

Installation and Project Setup

bash
# Bare React Native or Expo (prebuild / dev client — NOT Expo Go)
npm install react-native-track-player

# iOS: install pods
cd ios && pod install && cd ..

# Expo projects: this library needs native code, so you must use
# a development build. Expo Go will not work.
npx expo prebuild
npx expo run:ios

The single most important thing people miss on iOS is the background mode. Without it, audio stops the moment the screen locks. Add the audio background mode to your Info.plist (or, in Expo, to the ios.infoPlist section of app.json / app.config).

typescript
// app.config.ts (Expo) — declare the iOS background audio mode
export default {
  expo: {
    name: 'MyPlayer',
    ios: {
      infoPlist: {
        UIBackgroundModes: ['audio'],
      },
    },
    android: {
      permissions: ['android.permission.FOREGROUND_SERVICE'],
    },
    plugins: [
      // custom native config plugins go here (see the war story below)
    ],
  },
};
bash
<!-- Bare RN equivalent: ios/YourApp/Info.plist -->
<key>UIBackgroundModes</key>
<array>
  <string>audio</string>
</array>

How Do You Register the Playback Service?

react-native-track-player runs remote-control handling inside a headless JS service. You register a factory that returns an async function; the library calls it on the background thread and it is where you subscribe to remote events (play, pause, next, seek). This must be registered at the very top level of your app — outside any component — via the AppRegistry, so it survives your React tree being torn down.

tsx
// service.ts — the playback service
import TrackPlayer, { Event } from 'react-native-track-player';

module.exports = async function PlaybackService() {
  // These handlers are what make the lock-screen / headphone buttons work.
  TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
  TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
  TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.stop());
  TrackPlayer.addEventListener(Event.RemoteNext, () => TrackPlayer.skipToNext());
  TrackPlayer.addEventListener(Event.RemotePrevious, () => TrackPlayer.skipToPrevious());

  TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => {
    TrackPlayer.seekTo(position);
  });

  TrackPlayer.addEventListener(Event.RemoteJumpForward, async ({ interval }) => {
    const position = (await TrackPlayer.getProgress()).position;
    TrackPlayer.seekTo(position + interval);
  });

  TrackPlayer.addEventListener(Event.RemoteJumpBackward, async ({ interval }) => {
    const position = (await TrackPlayer.getProgress()).position;
    TrackPlayer.seekTo(Math.max(0, position - interval));
  });

  // iOS interruptions: a phone call, another app taking the session, etc.
  TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
    if (permanent) {
      TrackPlayer.stop();
    } else if (paused) {
      TrackPlayer.pause();
    } else {
      TrackPlayer.play();
    }
  });
};
tsx
// index.js — register at the app entry point, before anything else
import { AppRegistry } from 'react-native';
import TrackPlayer from 'react-native-track-player';
import App from './App';
import { name as appName } from './app.json';

AppRegistry.registerComponent(appName, () => App);

// The factory is invoked by the native side on the background thread.
TrackPlayer.registerPlaybackService(() => require('./service'));

Setting Up the Player and Capabilities

Before you can add tracks you call setupPlayer once, then updateOptions to declare which capabilities the lock-screen and notification should expose. The capabilities array controls which controls appear; the compactCapabilities array controls what shows in the collapsed Android notification. This is also where you configure the iOS audio session category.

tsx
// playerSetup.ts
import TrackPlayer, {
  AppKilledPlaybackBehavior,
  Capability,
  IOSCategory,
  IOSCategoryMode,
  RepeatMode,
} from 'react-native-track-player';

let isReady = false;

export async function setupPlayer() {
  if (isReady) return true;

  await TrackPlayer.setupPlayer({
    // How much audio to buffer ahead — important for gapless auto-advance
    minBuffer: 15,
    maxBuffer: 50,
    backBuffer: 30,
    // iOS audio session: 'playback' means "keep playing in background,
    // mix out other audio". This is what makes background audio legal on iOS.
    iosCategory: IOSCategory.Playback,
    iosCategoryMode: IOSCategoryMode.Default,
  });

  await TrackPlayer.updateOptions({
    android: {
      // Keep the notification (and playback) alive after the app is swiped away
      appKilledPlaybackBehavior:
        AppKilledPlaybackBehavior.ContinuePlayback,
    },
    // Controls available to the OS (lock screen, Control Center, notification)
    capabilities: [
      Capability.Play,
      Capability.Pause,
      Capability.SkipToNext,
      Capability.SkipToPrevious,
      Capability.SeekTo,
      Capability.Stop,
    ],
    // What shows in the collapsed Android notification
    compactCapabilities: [
      Capability.Play,
      Capability.Pause,
      Capability.SkipToNext,
    ],
    // Optional forward/backward jump buttons
    forwardJumpInterval: 15,
    backwardJumpInterval: 15,
    progressUpdateEventInterval: 1, // seconds
  });

  isReady = true;
  return true;
}

The iOS audio session category is the difference between a player that pauses on lock and one that keeps going. IOSCategory.Playback tells the OS your audio is primary content that should survive backgrounding — pairing it with UIBackgroundModes: [audio] is non-negotiable.

Adding Tracks and Metadata

A Track is a plain object. The url, title, artist, and artwork feed straight into the Now Playing info, so whatever you pass shows up on the lock screen. Artwork can be a remote URL or a local require. Duration is optional for progressive files but helpful for the scrubber before the file has loaded.

tsx
import TrackPlayer, { Track } from 'react-native-track-player';

const queue: Track[] = [
  {
    id: 'track-1',
    url: 'https://cdn.example.com/audio/midnight-drive.mp3',
    title: 'Midnight Drive',
    artist: 'Neon Skyline',
    album: 'Afterhours',
    artwork: 'https://cdn.example.com/art/afterhours.jpg',
    duration: 214,
  },
  {
    id: 'track-2',
    url: 'https://cdn.example.com/audio/coastline.mp3',
    title: 'Coastline',
    artist: 'Neon Skyline',
    artwork: 'https://cdn.example.com/art/afterhours.jpg',
    duration: 198,
  },
];

export async function loadQueue() {
  await TrackPlayer.reset();
  await TrackPlayer.add(queue);
}

Driving the UI with Hooks

The library ships hooks so your components re-render on state changes without you wiring event listeners by hand. usePlaybackState gives you the transport state, useProgress polls position/duration, and useActiveTrack returns the currently playing track object.

tsx
import TrackPlayer, {
  State,
  usePlaybackState,
  useProgress,
  useActiveTrack,
} from 'react-native-track-player';
import { Text, TouchableOpacity, View } from 'react-native';

export function PlayerControls() {
  const playback = usePlaybackState();
  const { position, duration } = useProgress();
  const track = useActiveTrack();

  const isPlaying = playback.state === State.Playing;

  const togglePlay = async () => {
    if (isPlaying) {
      await TrackPlayer.pause();
    } else {
      await TrackPlayer.play();
    }
  };

  return (
    <View>
      <Text>{track?.title ?? 'Nothing playing'}</Text>
      <Text>{track?.artist}</Text>

      <Text>
        {formatTime(position)} / {formatTime(duration)}
      </Text>

      <View style={{ flexDirection: 'row', gap: 24 }}>
        <TouchableOpacity onPress={() => TrackPlayer.skipToPrevious()}>
          <Text>Prev</Text>
        </TouchableOpacity>
        <TouchableOpacity onPress={togglePlay}>
          <Text>{isPlaying ? 'Pause' : 'Play'}</Text>
        </TouchableOpacity>
        <TouchableOpacity onPress={() => TrackPlayer.skipToNext()}>
          <Text>Next</Text>
        </TouchableOpacity>
      </View>
    </View>
  );
}

function formatTime(seconds: number) {
  const m = Math.floor(seconds / 60);
  const s = Math.floor(seconds % 60);
  return `${m}:${s.toString().padStart(2, '0')}`;
}

Queue Management, Repeat, and Shuffle

The queue API is a straightforward list you can mutate live. add appends (or inserts before an index), remove takes indices, move reorders, and skip jumps to an absolute index. Repeat mode is first-class; shuffle is not, so you implement it by reordering the queue yourself while remembering the original order.

tsx
import TrackPlayer, { RepeatMode, Track } from 'react-native-track-player';

// Repeat: Off (advance then stop), Track (loop one), Queue (loop all)
export async function cycleRepeatMode() {
  const current = await TrackPlayer.getRepeatMode();
  const next =
    current === RepeatMode.Off
      ? RepeatMode.Queue
      : current === RepeatMode.Queue
      ? RepeatMode.Track
      : RepeatMode.Off;
  await TrackPlayer.setRepeatMode(next);
  return next;
}

// Insert a track right after the currently playing one ("play next")
export async function playNext(track: Track) {
  const activeIndex = await TrackPlayer.getActiveTrackIndex();
  if (activeIndex == null) {
    await TrackPlayer.add(track);
  } else {
    await TrackPlayer.add(track, activeIndex + 1);
  }
}

// Fisher–Yates shuffle that keeps the current track playing at index 0
let originalOrder: Track[] | null = null;

export async function toggleShuffle(shuffleOn: boolean) {
  const queue = await TrackPlayer.getQueue();
  const activeIndex = (await TrackPlayer.getActiveTrackIndex()) ?? 0;

  if (shuffleOn) {
    originalOrder = queue;
    const current = queue[activeIndex];
    const rest = queue.filter((_, i) => i !== activeIndex);
    for (let i = rest.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [rest[i], rest[j]] = [rest[j], rest[i]];
    }
    await TrackPlayer.setQueue([current, ...rest]);
  } else if (originalOrder) {
    await TrackPlayer.setQueue(originalOrder);
    originalOrder = null;
  }
}

How Do You Stream HLS with TrackType.HLS?

Progressive MP3s are fine for short clips, but for live radio, long-form podcasts, or adaptive-bitrate music you want HLS. An HLS source is a .m3u8 playlist that points at segmented media and (for adaptive streams) multiple bitrate variants. The native players negotiate the best variant for the current network. You must tell the library the source is HLS by setting the track type — otherwise it treats the .m3u8 as a progressive file and playback fails or stalls.

tsx
import TrackPlayer, { TrackType } from 'react-native-track-player';

await TrackPlayer.add({
  id: 'live-radio',
  url: 'https://stream.example.com/live/master.m3u8',
  // Without type: TrackType.HLS the player mishandles the .m3u8 manifest
  type: TrackType.HLS,
  title: 'Neon Skyline Radio',
  artist: 'Live',
  artwork: 'https://cdn.example.com/art/radio.jpg',
  // For live streams isLiveStream hides/rearranges the scrubber on the
  // lock screen and disables seeking past the live edge
  isLiveStream: true,
});

// On-demand HLS (e.g. a VOD podcast) with a known duration
await TrackPlayer.add({
  id: 'podcast-ep-42',
  url: 'https://vod.example.com/ep42/master.m3u8',
  type: TrackType.HLS,
  title: 'Episode 42 — Shipping Audio',
  artist: 'The Mobile Show',
  duration: 3620,
});

On Android, ExoPlayer handles HLS natively and robustly. On iOS, HLS is played through AVPlayer (wrapped by SwiftAudioEx, the Swift audio engine react-native-track-player uses under the hood). That iOS/AVPlayer path is exactly where I hit a subtle, production-breaking bug — which brings us to the war story.

War Story: the iOS HLS Auto-Start Bug

The symptom was maddeningly specific. On Android, adding an HLS track and calling play() worked every time. On iOS, the same code would load the track, show it on the lock screen, report a Playing state — and produce silence. The audio only started if the user manually tapped play a second time, or if I added an artificial delay before calling play().

The root cause is how AVPlayer loads HLS. For a progressive file, AVPlayer can begin almost immediately. For HLS it must first fetch the master playlist, then a variant playlist, then buffer the first segments before the AVPlayerItem transitions to .readyToPlay. If you call play() before the item is ready, AVPlayer accepts the command, the rate is set, but there is nothing to render yet — and in the version of SwiftAudioEx we were on, that early play() did not get replayed once the item became ready. Progressive files were ready fast enough to hide the race; HLS exposed it every time.

The fix is not "add a setTimeout." That only papers over the race and breaks on slow networks. The correct fix is to observe AVPlayerItem.status and (re)issue play once the HLS item actually reaches .readyToPlay.

The proper place for that observation is inside the native Swift layer, in SwiftAudioEx. But that code lives in a CocoaPod inside node_modules, so hand-editing it is not reproducible — a fresh install or a CI machine would wipe the change. The reproducible way to patch native dependency source at build time in an Expo project is a config plugin that runs during prebuild. Conceptually, the plugin injects (or patches in) a KVO observer on the player item so that a deferred play intent fires the instant the HLS item is ready.

The Swift observation we want to inject

swift
// The behavior we are patching into SwiftAudioEx's AVPlayerWrapper.
// Track whether a play() was requested before the item was ready.
private var pendingPlayWhenReady = false
private var statusObservation: NSKeyValueObservation?

func play() {
    // If the current item is not ready yet (typical for HLS master.m3u8),
    // remember the intent and start observing status instead of no-op'ing.
    if let item = avPlayer.currentItem, item.status != .readyToPlay {
        pendingPlayWhenReady = true
        observeItemStatus(item)
        return
    }
    avPlayer.play()
}

private func observeItemStatus(_ item: AVPlayerItem) {
    statusObservation?.invalidate()
    statusObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in
        guard let self else { return }
        switch item.status {
        case .readyToPlay:
            if self.pendingPlayWhenReady {
                self.pendingPlayWhenReady = false
                self.avPlayer.play() // <- the deferred start actually fires here
            }
        case .failed:
            self.pendingPlayWhenReady = false
            // surface the error up to the JS event bridge
        default:
            break
        }
    }
}

The Expo config plugin that applies the patch

A config plugin is just a function that takes the Expo config and returns a modified one. Here we use withDangerousMod to hook into the iOS build phase and rewrite the relevant SwiftAudioEx source file after pods are laid down. In practice you would apply a checked-in .patch (via patch-package or a diff) rather than string-replacing by hand, but the string-replacement form makes the mechanism obvious.

typescript
// plugins/withHlsAutoStartFix.ts
import { ConfigPlugin, withDangerousMod } from '@expo/config-plugins';
import fs from 'fs';
import path from 'path';

const withHlsAutoStartFix: ConfigPlugin = (config) => {
  return withDangerousMod(config, [
    'ios',
    async (cfg) => {
      const projectRoot = cfg.modRequest.projectRoot;

      // The Swift file inside the SwiftAudioEx pod that owns AVPlayer.play()
      const target = path.join(
        projectRoot,
        'node_modules',
        'react-native-track-player',
        'ios',
        'RNTrackPlayer',
        'Vendor',
        'SwiftAudioEx',
        'Sources',
        'SwiftAudioEx',
        'AVPlayerWrapper.swift'
      );

      let src = fs.readFileSync(target, 'utf8');

      // Idempotency guard so re-running prebuild does not double-patch
      if (src.includes('pendingPlayWhenReady')) {
        return cfg;
      }

      // Replace the naive play() with the status-observing version.
      src = src.replace(
        /func play\(\) \{\s*avPlayer\.play\(\)\s*\}/,
        `private var pendingPlayWhenReady = false
    private var hlsStatusObservation: NSKeyValueObservation?

    func play() {
        if let item = avPlayer.currentItem, item.status != .readyToPlay {
            pendingPlayWhenReady = true
            hlsStatusObservation?.invalidate()
            hlsStatusObservation = item.observe(\\.status, options: [.new]) { [weak self] item, _ in
                guard let self, item.status == .readyToPlay, self.pendingPlayWhenReady else { return }
                self.pendingPlayWhenReady = false
                self.avPlayer.play()
            }
            return
        }
        avPlayer.play()
    }`
      );

      fs.writeFileSync(target, src);
      return cfg;
    },
  ]);
};

export default withHlsAutoStartFix;
typescript
// app.config.ts — register the plugin so it runs on every prebuild
import withHlsAutoStartFix from './plugins/withHlsAutoStartFix';

export default {
  expo: {
    name: 'MyPlayer',
    ios: { infoPlist: { UIBackgroundModes: ['audio'] } },
    plugins: [withHlsAutoStartFix],
  },
};

Because the plugin runs during npx expo prebuild, the fix is reproducible on every machine and in CI — no fragile manual edits inside node_modules. The idempotency guard means repeated prebuilds are safe, and when the upstream library eventually fixes the race you simply drop the plugin. This pattern — a config plugin that patches a native dependency at build time — is the escape hatch for any iOS/Android native quirk you cannot wait for upstream to resolve.

Gapless Auto-Advance and Preloading

True sample-accurate gapless playback (for live albums with no silence between tracks) requires format-level support and is hard on both platforms. What you can reliably deliver is seamless auto-advance: the next track is fully buffered before the current one ends, so there is no audible stall. The buffer settings you passed to setupPlayer are the main lever, and the PlaybackActiveTrackChanged event lets you prefetch artwork or metadata for the upcoming track.

tsx
import TrackPlayer, { Event } from 'react-native-track-player';

// In the playback service (service.ts): react as tracks change
TrackPlayer.addEventListener(
  Event.PlaybackActiveTrackChanged,
  async ({ index, track, lastIndex, lastTrack }) => {
    if (track == null || index == null) return;

    // The next track is already queued and buffering thanks to minBuffer.
    // Use this moment to warm caches for what comes AFTER next.
    const queue = await TrackPlayer.getQueue();
    const upcoming = queue[index + 1];
    if (upcoming?.artwork && typeof upcoming.artwork === 'string') {
      // e.g. Image.prefetch(upcoming.artwork) to avoid lock-screen art flicker
    }

    // Analytics: log that lastTrack finished and track started
  }
);

// Detect the true end of the queue (auto-advance ran off the end)
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, ({ position }) => {
  // Stop, loop, or fetch the next page of tracks for infinite playback
});

Increasing minBuffer (the amount of audio buffered ahead) and backBuffer smooths auto-advance at the cost of memory and data. For a music app, 15 to 30 seconds of forward buffer is a good balance; for flaky mobile networks bump it higher. For truly infinite playback (a radio-style feed), listen for PlaybackQueueEnded or PlaybackActiveTrackChanged nearing the end and append the next page of tracks before the queue drains.

Testing on Real Devices

A background-audio checklist the simulator will lie to you about:

  • Lock the screen mid-playback and confirm audio continues and the lock-screen controls (play/pause/skip/scrubber) appear and respond
  • Pull down Control Center on iOS and verify the Now Playing widget shows title, artist, and artwork
  • Trigger an interruption: receive a phone call or start a video in another app, then confirm playback ducks/pauses and resumes correctly (RemoteDuck)
  • Unplug and replug headphones / disconnect Bluetooth — playback should pause on route change, not blast out the speaker
  • Swipe the app out of recents on Android with ContinuePlayback and confirm audio and the notification persist
  • Test HLS specifically on a real device on cellular, not just Wi-Fi, to catch the AVPlayerItem readiness race and adaptive-bitrate switching
  • Throttle the network (Network Link Conditioner on iOS) to verify buffering behavior and that auto-advance stays seamless

The iOS Simulator does not faithfully reproduce the AVAudioSession, background execution, or the AVPlayerItem readiness timing for HLS. The auto-start bug above was invisible on the simulator and only reproduced on a physical iPhone on a real network. Always validate audio on hardware.

Key Takeaways

What to remember when shipping background audio in React Native:

  • Use react-native-track-player for anything beyond a sound effect — it wraps the foreground service, MediaSession, and remote command handling you would otherwise write by hand
  • Register the playback service at the app entry point via registerPlaybackService, and put all remote-control handlers there so audio survives an unmounted React tree
  • iOS background audio needs two things together: UIBackgroundModes: [audio] in Info.plist and IOSCategory.Playback in setupPlayer
  • Declare capabilities and compactCapabilities to control exactly which lock-screen and notification controls appear
  • For streaming, set type: TrackType.HLS on .m3u8 sources — forgetting it is a common cause of silent failures on iOS
  • HLS on iOS goes through AVPlayer/SwiftAudioEx; calling play() before AVPlayerItem.status is .readyToPlay can silently no-op — observe the status and defer the start instead of using setTimeout
  • Patch native dependency quirks reproducibly with an Expo config plugin (withDangerousMod) rather than editing node_modules by hand; keep the patch idempotent
  • Tune minBuffer/backBuffer and use PlaybackActiveTrackChanged to prefetch for seamless auto-advance, and PlaybackQueueEnded for infinite playback
  • Always test on a physical device on a real cellular network — the simulator hides audio-session, background, and HLS readiness bugs

Background audio is one of those features where 90% is a few dozen lines of setup and the last 10% is platform archaeology. react-native-track-player gets you the 90% fast; knowing how AVPlayer loads HLS — and how to reach into the native layer with a config plugin when you must — is what carries you through the last 10% to a player that just works, screen locked, on cellular, every time.

Share this article

Related Articles