My React Native app has never been compiled on my own machine. Not once. That started as a limitation and turned into the thing that keeps my releases boring.
The setup
During development I don't build at all. The JavaScript layer runs in Expo Go on a physical phone over the local network — save a file, see it on the device a second later. That covers the overwhelming majority of what I write: screens, state, business logic, database queries.
Native code is different. The moment you add a library with native modules, or touch the Android manifest, you need a real build. That build goes to EAS, not to my laptop.
Why not locally?
Because "works on my machine" is a sentence about my machine, not my app.
A local Android build depends on which JDK I happen to have, which SDK platforms are installed, which NDK version, which Gradle cache. None of that lives in my repo. If it breaks in six months, I'm debugging my laptop's history, not my code.
A hosted build is defined by files that are committed: app.json, eas.json, package.json. The build environment is an input, not an accident. That's the whole argument, and it's the same argument as any other CI.
The quota, which turned out to be a feature
My build credits reset monthly, and there aren't many. I can't rebuild on every commit — realistically I build every two or three days.
I expected this to be pure friction. Instead it changed how I work. When a build is free you use it as a test. When it's scarce you push verification earlier:
npx tsc --noEmit # types, across the whole project
npx expo export --platform android # does the bundle actually build?
Both run in seconds, locally, and catch most of what would otherwise fail ten minutes into a remote build. Nothing goes to EAS until both are clean. I've spent far fewer builds on "oh, a typo" than I used to.
The constraint also forces batching. Instead of one native change per build, I queue several, then verify them together on the device. Fewer builds, longer device-testing sessions, and honestly better testing — because I'm looking at a batch of changes with fresh eyes rather than confirming the one thing I just wrote.
The config trap
One hard-won detail. I needed Android package visibility so the app could detect whether certain other apps were installed. The obvious move is to add a queries block to app.json.
It does nothing. It isn't an error, there's no warning — the key is simply ignored, and you find out when the feature silently fails on a real device.
The actual mechanism is a config plugin that modifies the manifest during prebuild:
const { withAndroidManifest } = require('expo/config-plugins');
module.exports = (config) =>
withAndroidManifest(config, (cfg) => {
const manifest = cfg.modResults.manifest;
manifest.queries = [{ package: [{ $: { 'android:name': 'com.example.target' } }] }];
return cfg;
});
The lesson generalises past Expo: when a config key is silently ignored, you don't have a configuration problem, you have a wrong layer problem. Find the layer that actually owns the output.
What I'd tell past me
Don't fight to get native builds working locally. The reproducibility you get from a defined build environment is worth more than the iteration speed you think you're losing — because the fast loop was never the native build anyway. It was Expo Go, and that still runs on your desk.
Top comments (0)