React Native Web
Playing the same WAV files in Expo Web and a React Native app
When the app and Web build play different sounds, the issue goes past taste. You lose the ability to validate duration, mix, and game feedback in the fastest environment to test.
The bug looked like a lost-game sound.
In Daily Sudoku: Offline Puzzle, we added two new effects: one for losing a game and another for receiving daily reward
credits. The WAV files existed, the TypeScript modules existed, the shared package exports existed, and the native app path
used expo-audio.
On Web, the validation was misleading. The player made a move, something played, but that thing was not the same WAV file. It was a short synthetic tone created with Web Audio. The interface appeared to have feedback, but the test said nothing about the real asset, the real duration, or the real behavior of the new effect.
That difference is easy to accept too early in Expo apps. Web becomes a “good enough for development” version, while Android and iOS remain the truth. For layout, that can sometimes be unavoidable. Canvas, Skia, and native APIs can hit real browser limits. For short WAV sounds, the excuse was weak.
The false positive from the fallback
The audio helper had a simple branch:
if (Platform.OS === "web") {
playWebSoundEffect(effect);
continue;
}
That playWebSoundEffect did not play the asset. It created an oscillator, picked a frequency for each event, and scheduled a
short decay. For correct, it sounded like a positive beep. For mistake, it sounded like a lower beep. That was useful when
nothing else existed. Once the project had a game-soft-* asset series, it became architectural noise.
The problem was not that the fallback existed. The problem was that it was the primary path.
When a fallback becomes the primary path, three bad things happen. First, Web stops detecting a broken file, a missing export, or an invalid URI. Second, the team approves sound behavior that is not what users will hear in the app. Third, overlap bugs become harder to understand, because the synthetic duration does not represent the WAV duration.
That is the kind of bug that showed up around losing a game. The new sound seemed not to play. Part of the investigation found an overlap with the mistake sound. Another part showed that Web was not even trying to play the loss WAV.
The decision: Web plays the asset first
The good fix was not to invent another audio engine. It was to lower the ambition.
The app already had a map from effects to native sources:
const nativeSoundSourceByEffect = {
correct: gameSoftCorrectSound,
mistake: gameSoftMistakeSound,
loss: gameSoftLossSound,
dailyReward: gameSoftDailyRewardSound,
};
That map needed to remain the source of truth. On native, expo-audio receives the source and creates a player. On Web, the
browser needs a URL. In React Native Web, the practical path was to resolve the bundled asset with Image.resolveAssetSource
and pass the URI to Audio.
The flow became more honest:
- if the platform is native, create or reuse an
expo-audioAudioPlayer; - if the platform is Web, resolve the same asset module to a URI and create
new Audio(uri); - if the browser has no
Audio, the URI cannot be resolved, or playback fails, then fall back to the synthetic tone.
That order matters. The fallback still helps with diagnostics and odd browsers, but it no longer hides the normal case.
Reusing a player matters on Web too
Game effects are short and can repeat quickly. Creating a new Audio object for every tap works in tiny examples, but in a
real game it becomes unnecessary allocation and less predictable behavior.
We kept a cache by effect:
const webPlayerByEffect = new Map<GameSoundEffect, WebAudioElement>();
When the effect plays again, the existing player is paused, reset to currentTime = 0, given the current volume, and asked to
play(). That mirrors the intent of the native path, which also reuses one player per effect and calls seekTo(0) before
playback.
The detail is small, but it helps parity. A repeated mistake, a completed unit, or an auto-complete sequence should behave like events from the same system, not like a set of throwaway players competing with each other.
Audio logs need an off switch
During the investigation, logging every sound was useful. The logs showed when an event was mapped, when audio mode was configured, when a player was created, when a Web asset was played, and when the synthetic fallback was used.
Useful diagnostic logging can become production noise. A player filling many cells generates a lot of activity. So the instrumentation went behind environment config:
observability:
sound:
enabled: true
prefix: "[sound]"
The app already had @apps/observability with global logging. There was no reason to create another package or another logging
system. The audio helper only needed local wrappers that read observability.sound, apply the configured prefix, and delegate
to logInfo or logWarning.
In practice, the console stays readable:
[SUDOKU-OBSERVER] [sound] created web bundled sound player
[SUDOKU-OBSERVER] [sound] playing web bundled sound
The global prefix still identifies the app observer. The sound prefix separates the subsystem. The final message says what happened.
How to prove Web played the WAV
Hearing the sound is not enough. Browser autoplay policy, system volume, and background tabs can mislead any manual test.
The validation that resolved the doubt had two parts.
First, we ran a real game action through Playwright: use the existing local tab, continue a Sudoku, select an empty cell, and
enter a number. The action generated a mistake event, so the helper attempted to play mistake.
Second, we looked at signals that do not depend on ears:
- console output with
playing web bundled sound; - a URI containing
packages/audio-assets/assets/sounds/game-soft-mistake.wav; - a
.wavnetwork request returning206 Partial Content.
That 206 is normal for audio. The browser may request a range of the file instead of downloading the whole thing at once. The
important part is that there was a request for the real WAV, rather than just oscillator creation.
That evidence changes the conversation. Before, “it works on Web” meant “some sound came out.” After the change, it meant “the same bundled asset was loaded and the primary playback path ran.”
What we did not unify
Parity does not mean pretending Web and native are the same.
The app can still have real differences. Canvas or Skia on React Native Web, for example, can run into APIs that do not exist or font behavior that differs from native. Short WAV audio was not in that category. The browser can play WAV. Metro can serve the asset. The work was connecting those pieces in the right order.
We also did not replace expo-audio with HTMLAudioElement everywhere. That would be the wrong abstraction. On native, the
Expo player remains the natural path. On Web, the platform Audio element is the direct API for playing a file once the asset
has been resolved.
The goal was to remove differences that were not buying anything.
The checklist that remained
For short sound effects in Expo/React Native with Web:
- keep one effect-to-asset map;
- resolve the asset to a URI on Web;
- play the real WAV as the primary path;
- keep the synthetic tone only as a fallback;
- cache one player per effect;
- log creation, playback, and fallback through configurable logs;
- validate in Playwright through console output and a
.wavrequest; - test native separately, because audio policy, focus, and volume are still platform behavior.
This solution is not sophisticated. That is the point.
The earlier mistake was keeping a technical difference after it was no longer needed. Web had become a sound simulator for the app, not the app using the same assets. For a casual game, that is enough to hide exactly the bugs you want to find early: sounds that are too short, sounds that are too long, effects that overlap, files that do not load, and feedback that feels right in the wrong environment.
When the platform can play the same file, make it play the same file.