◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 26 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 26 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-11-04Beginner

Catching the Few Seconds Before Streaming Audio Starts: A Loading State and a Timeout

You tap play and nothing happens for a moment. Here is how I model that gap with expo-audio: a requested flag, a 400ms delayed spinner, and an 8-second timeout.

expo-audio6streaming3loading statetimeoutReact Native243Rork612

You tap play, and nothing happens. You lift your finger, wait a beat, and the screen still hasn't changed.

I saw this in the player screen of my own healing-sound app one evening, and my stomach sank a little. It wasn't a bug. On a thin connection, the first bytes of the audio file simply hadn't arrived yet. But to the person holding the phone, a silent screen looks the same as a broken one, and the button got tapped two or three times, each tap queuing another play request.

What I'd fix first isn't the network. It's what the screen shows while it waits.

Treat the wait as a state

I can't shorten the time between tap and sound, so I give the waiting period its own state. I use four:

StateMeaningButton
idleNot tapped yetPlay icon
waitingTapped, no sound yetSpinner (disabled)
playingSound is coming outPause icon
failedTimed out or failed to loadRetry hint

Keeping waiting separate from playing matters. A long "pretend playing" state is exactly what invites double taps.

Derive waiting from your own flag

expo-audio's useAudioPlayerStatus returns fields such as isLoaded, isBuffering and playing. Check the exact names against the type definitions for your SDK version. I keep the decision logic in a small function, away from the component:

// What this solves: turn "tapped but silent" into an explicit UI state
type PlayUiState = 'idle' | 'waiting' | 'playing' | 'failed';
 
export function decidePlayUiState(args: {
  requested: boolean;   // did the user tap play?
  playing: boolean;     // status.playing
  failed: boolean;      // timeout or load failure
}): PlayUiState {
  if (args.failed) return 'failed';
  if (args.playing) return 'playing';
  if (args.requested) return 'waiting';
  return 'idle';
}

Owning requested yourself is the key. If you rely only on the player's isBuffering, there can be a moment right after the tap when it is still false, and that single beat is where the screen looks dead. Record the tap first, and waiting starts at the moment of the tap.

Show the spinner after 400ms, give up after 8 seconds

A spinner that appears instantly flickers on a good connection, which feels busier than doing nothing. So I only show it if sound hasn't started after 400ms. That number is a design choice of mine, not a measurement.

import { useEffect, useRef, useState } from 'react';
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
 
const SHOW_SPINNER_AFTER_MS = 400; // avoid flicker
const GIVE_UP_AFTER_MS = 8000;     // timeout
 
export function useStreamingPlayback(url: string) {
  const player = useAudioPlayer({ uri: url });
  const status = useAudioPlayerStatus(player);
  const [requested, setRequested] = useState(false);
  const [showSpinner, setShowSpinner] = useState(false);
  const [failed, setFailed] = useState(false);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
 
  const clearTimers = () => {
    timers.current.forEach(clearTimeout);
    timers.current = [];
  };
 
  const start = () => {
    if (requested) return;            // ignore double taps
    setFailed(false);
    setRequested(true);
    player.play();
    timers.current.push(setTimeout(() => setShowSpinner(true), SHOW_SPINNER_AFTER_MS));
    timers.current.push(setTimeout(() => setFailed(true), GIVE_UP_AFTER_MS));
  };
 
  // Sound started, or we failed: clean up
  useEffect(() => {
    if (status.playing || failed) {
      clearTimers();
      setShowSpinner(false);
      if (failed) setRequested(false); // allow another try
    }
  }, [status.playing, failed]);
 
  useEffect(() => clearTimers, []);   // clean up on unmount
 
  return { requested, showSpinner, failed, playing: status.playing, start };
}

Three things I watch here: stopping double taps at the top of start, clearing timers once sound begins, and clearing them on unmount. Skip the last one and a setState fires later on a screen you've already left.

Eight seconds is also a design value. Too short and you mark a slow connection as failed; too long and it's indistinguishable from a frozen screen.

Hand the retry back to the user

The failure message shouldn't guess at a cause. I write something like "This is taking a while. Please try again," with one next action, because the screen can't tell whether the network or the server is at fault.

I also decided not to auto-retry. In a version that did, on an unstable connection play requests piled up in the background and sounds eventually overlapped. Now there is exactly one retry, and the user triggers it. I set how long we wait, but the user holds the trigger for trying again. I keep that line every time I rebuild this screen.

Pitfalls

What to do next

Add a single requested flag to your play button and check that the button changes the instant it's tapped. Then throttle your connection on purpose and watch the gap. That was my first fix too.

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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-05-05
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.
⬡ Dev Tools2026-10-25
Sound Effects Stop at Play #40 — With expo-audio's createAudioPlayer, remove() Alone Isn't Cleanup
Creating a createAudioPlayer instance per play in expo-audio can leave Android silent after about 40 plays, with didJustFinish never arriving. What remove() and release() each do, three ways to restructure, and how to notice when it happens.
⬡ Dev Tools2026-09-04
EAS secret visibility does not keep a value out of your app — deciding prefix and visibility separately
The EXPO_PUBLIC_ prefix decides what ships inside your app; EAS visibility decides who can read it. Why stacking them blanks a value on OTA updates, and how to check your build.