I might have shipped a production build that still talks to the development API. The first hint would not be the app's behavior. It would be one line in the build log.
> Task :app:createBundleReleaseJsAndAssets UP-TO-DATE
Say you change EXPO_PUBLIC_API_URL to the production value and rebuild the Android release. The build ends with "BUILD SUCCESSFUL". Yet the app you get may still carry last time's development value. A report on Expo's GitHub describes exactly this.
Let me put the conclusion first. To Gradle, a build where only a value changed looks like a build where nothing changed, and it can skip regenerating the JavaScript bundle. Below I keep what the report confirms separate from what I reproduced myself.
How to recognize it
The conditions in the report are quite specific.
| What to check | If the answer is yes |
|---|---|
| Did you change only an environment variable or a .env value (no JavaScript source touched)? | You match. With identical sources, Gradle decides the inputs are unchanged |
| Does the build log show `createBundleReleaseJsAndAssets UP-TO-DATE`? | Then the bundle was not regenerated |
| Are you building repeatedly in the same `android` folder? | It happens when the previous output is still there. A fresh checkout should be less exposed, but the report does not confirm that |
| Does the variable start with `EXPO_PUBLIC_`? | Those values are inlined into the bundle at build time, not read when the app runs |
The report is Issue 49630 in expo/expo. It was opened on September 1, 2026 and observed on SDK 57, React Native 0.86, macOS, and a bare Android project. Expo's automated triage on the same issue says it reproduced the problem, and adds that a changed .env file behaves the same as a changed shell variable.
Why it gets skipped
Values starting with EXPO_PUBLIC_ are written into the bundle as plain strings while it is built, not at runtime. So if the value changes, the bundle's contents should change too.
But Gradle only looks at what a task has declared as inputs. For the bundle task, those are the JavaScript sources and the bundle settings. The environment variables the bundler reads are not on the list. Inputs and outputs look the same as last time, so Gradle concludes there is nothing to do and prints UP-TO-DATE.
In short, the bundler depends on the environment while Gradle does not know it. That mismatch is the whole cause.
Reproducing it in a minimal project
Reading a report gives me little to hold on to. So I recreated the same mismatch with Gradle alone, no Android and no Expo. The task imitates the bundle task: its only input is a src folder, its output is one file, and the contents come from an environment variable.
tasks.register('createBundle') {
inputs.dir('src') // input: sources only
outputs.file(layout.buildDirectory.file('index.bundle'))
doLast {
def out = layout.buildDirectory.file('index.bundle').get().asFile
out.parentFile.mkdirs()
// stands in for the bundler inlining an environment variable
out.text = "var API = '" + (System.getenv('EXPO_PUBLIC_API_URL') ?: '') + "';\n"
}
}Running it twice with a different value gave this (Gradle 8.14.3).
# Run 1: EXPO_PUBLIC_API_URL=https://dev.example.invalid
> Task :createBundle
var API = 'https://dev.example.invalid';
# Run 2: EXPO_PUBLIC_API_URL=https://prod.example.invalid
> Task :createBundle UP-TO-DATE
var API = 'https://dev.example.invalid';
Run 2 was UP-TO-DATE and the output kept the development value. The build counts as a success, so skimming the log, you would not notice.
The fix: declare the variables as inputs
The workaround in the report is to tell Gradle about the EXPO_PUBLIC_ variables as inputs of the bundle task. I added the same idea to my minimal project with two lines.
def pub = providers.environmentVariablesPrefixedBy('EXPO_PUBLIC_')
tasks.named('createBundle') { inputs.property('expoPublicEnv', pub) }With that in place, I ran three builds in order.
# Run 1: dev value
> Task :createBundle → https://dev.example.invalid
# Run 2: prod value
> Task :createBundle → https://prod.example.invalid
# Run 3: prod value again
> Task :createBundle UP-TO-DATE
The run with a changed value executed again, and only the unchanged third run was skipped. You keep the speed and lose the mix-up.
Here is what I did not verify. That behavior comes from my own minimal model. In a real Expo project you would use the form in the report, which adds the input to tasks whose names start with createBundle and end with JsAndAssets. I did not try it on a real Android project or device.
Until it is fixed
Expo has a change that declares these inputs automatically, opened as Pull Request 49839. As of October 27, when I checked, it had not been merged. Until then, pick one of these.
- For builds where only a value changed, add
--rerun-tasksto force every task to run again. It is reliable, but the whole build gets slower. - Add the declaration above to your Android build settings. If you regenerate the
androidfolder each time, what you added will be wiped. - Make production builds in an environment with no previous output, such as a clean checkout.
Whether EAS cloud builds are affected is listed as unconfirmed in the report. I have not checked either, so I won't claim anything.
One small step before you ship
As an indie developer who keeps Android apps in the store, I would rather catch a wrong server address in my own log than in a user report. In the end, the most dependable habit is to look inside what you built. A successful build and the right value being inside are different facts. Once the release file exists, search it once for the development address before you publish.
A success message is not a guarantee about the contents. On days when I catch myself forgetting that, I go back to the log and look for UP-TO-DATE.
Stale state surviving a deploy has a cousin on the web side, which I dig into from the cause in Why Redeploying Doesn't Fix the Application Error You See Right After a Deploy.
The next time you switch the server your build points at, open the build log first and check only whether the createBundle line says UP-TO-DATE.