Your test suite is green. TypeScript is happy. The web preview looks perfect.
Then you install the APK on a real phone and the app crashes before the splash screen finishes.
I shipped an Expo SDK 56 app to Google Play internal testing last week. Three separate bugs made it through 376 passing tests, clean tsc, and a working web export. None of the three involved broken logic. The code was correct. The runtime wasn't what I thought it was.
Each bug only surfaced on a physical Android device running Hermes. Here's what they were, why they slipped through, and the fixes.
Bug 1: The Gold Button That Turned Grey
My app uses a theme system. A configureTheme() function mutates a shared Colors object at runtime, and components read from it via StyleSheet.create.
The problem: my main CTA button rendered grey (RGB 231,231,231) instead of gold (RGB 213,195,139) on every screen that used the shared Button component. But screen-specific styles showed the correct gold.
Root cause: JavaScript module evaluation order.
// app/_layout.tsx (BROKEN version)
import { Button } from '@core/ui'; // Step 1: Button.tsx evaluates
import { configureTheme } from '@core/constants';
import { MY_BRAND } from '@app/config/theme';
configureTheme(MY_BRAND); // Step 3: Colors mutated... too late
When _layout.tsx imports @core/ui, JavaScript evaluates Button.tsx immediately. Button.tsx runs StyleSheet.create at module scope, which copies the color string by value. At that point, configureTheme() hasn't run yet, so the button captures the neutral fallback color.
Screen-level styles worked fine because route components are lazily imported after configureTheme() runs.
Why tests missed it: Jest evaluates all modules fresh per test file. Fast Refresh already mutated the Colors singleton from a previous render. Only a cold start on a real device evaluates modules in the exact import order of your entry point.
The fix: Create a bootstrap file that runs configureTheme at module scope, and import it first.
// config/themeBootstrap.ts
import { configureTheme } from '@core/constants';
import { MY_BRAND } from './theme';
configureTheme(MY_BRAND); // Runs at module-eval time
// app/_layout.tsx (FIXED)
import './config/themeBootstrap'; // Step 1: theme configured
import { Button } from '@core/ui'; // Step 2: Button captures real colors
Rule: Any component that bakes brand colors into StyleSheet.create must be imported after configureTheme runs. Or resolve colors at render time as inline styles.
Bug 2: The Environment Variable That Existed Until It Didn't
The app crashed on launch with a clear error: EXPO_PUBLIC_SUPABASE_URL is required. But the .env file had the value. The dev server worked fine. npx expo export --platform web bundled without errors.
Root cause: Metro's static replacement only works with dot notation.
// storage/supabase.ts (BROKEN)
function readRequiredEnv(name: string): string {
const value = process.env[name]?.trim(); // Dynamic bracket access
if (!value) throw new Error(`${name} is required`);
return value;
}
const SUPABASE_URL = readRequiredEnv('EXPO_PUBLIC_SUPABASE_URL');
Metro (Expo's bundler) statically replaces process.env.EXPO_PUBLIC_SUPABASE_URL with the literal string value at bundle time. It's a compile-time text substitution, not a runtime lookup.
process.env[name] is dynamic. Metro can't know what name will be at runtime, so it leaves it as-is. In the bundled Hermes bytecode, process.env is an empty object. The dynamic lookup returns undefined, and the app throws.
Why tests missed it: Node.js has real process.env at runtime. Your test runner loads values from .env files into the actual process environment. Metro's static replacement is a bundler concern that only affects the production artifact.
The fix: Static dot notation. No abstraction.
// storage/supabase.ts (FIXED)
const SUPABASE_URL = process.env.EXPO_PUBLIC_SUPABASE_URL?.trim();
const SUPABASE_ANON_KEY = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY?.trim();
if (!SUPABASE_URL) {
throw new Error('EXPO_PUBLIC_SUPABASE_URL is required');
}
Rule: In Expo/React Native, never wrap process.env.EXPO_PUBLIC_* in a helper function that uses dynamic access. The DRY instinct is wrong here. Each env var needs its own static process.env.EXPO_PUBLIC_X line.
Bug 3: The Missing Crypto API
The app launched fine. Onboarding worked. Then the user navigated to the chat screen and got a ReferenceError: crypto is not defined.
Root cause: The uuid package calls crypto.getRandomValues() internally. Hermes (React Native's JavaScript engine) doesn't ship the Web Crypto API.
- Node.js: has
cryptoglobally since v19. Tests pass. - Chrome/Safari (web preview): has
cryptoonwindow. Web export works. - Hermes on Android: no
cryptoobject. Crash.
ReferenceError: Property 'crypto' doesn't exist
at v4 (uuid/dist/esm-browser/native.js:1:15)
at ChatScreen (screens/companion.tsx:42:18)
The fix: One polyfill import at the top of your app entry, before anything else.
npm install react-native-get-random-values
// app/_layout.tsx
// This MUST be the first import. It patches globalThis.crypto
// before uuid or any other Web Crypto consumer evaluates.
import 'react-native-get-random-values';
import { useEffect } from 'react';
import { Stack } from 'expo-router';
// ... rest of imports
Rule: If any dependency uses crypto.getRandomValues() (uuid, nanoid, webcrypto-based auth libraries), add this polyfill. It's not optional on Hermes.
The Pattern
All three bugs share the same shape:
| Bug | Dev/Test | Web Export | Real Device |
|---|---|---|---|
| Theme freeze | Gold (Fast Refresh) | Gold (lazy eval) | Grey (cold start) |
| Dynamic env | Works (real process.env) | Works (inlined) | Crash (empty object) |
| Missing crypto | Works (Node has it) | Works (browser has it) | Crash (Hermes lacks it) |
Tests run in Node. Web preview runs in Chrome. Neither environment matches Hermes on Android. Three different reasons, but the same gap: your test environment is lying about what the production runtime can do.
What I Do Now
After these three burned a full day each, I added a mandatory step before any release build:
Cold-start the app on a real device. Not Fast Refresh. Kill the process, relaunch. This catches module-order bugs like the theme freeze.
Test the EAS build artifact, not the dev server. The dev server has real
process.env. The production bundle has static replacements. They behave differently.Install a crash monitoring SDK early. If you ship to testers without crash reporting, you're running blind. The
cryptobug only triggered on a specific navigation path. A crash report with the Hermes stack trace would have cut debugging time from hours to minutes.
Your test suite verifies your logic. It doesn't verify your runtime. The device is the only source of truth.
Top comments (0)