Sunstone Apps
← Back to blog

React Native release debugging

When versionCode changes but JavaScript does not: a React Native release trap

Not every release bug crashes the app. Sometimes Google Play installs the new version while React Native keeps running old JavaScript metadata.

Published: 6 min read
OpenGraph preview image for this article. When versionCode changes but JavaScript does not: a React Native release trap

The most dangerous release bug is not always a crash. It can be the bug that looks fixed in Play Console but is still wrong inside the installed app.

That happened during an internal release of Daily Sudoku: Offline Puzzle.

Play Console accepted the AAB. Android installed the app with versionCode=3. The previous RevenueCat error was gone. It looked done.

But the Sentry startup log said otherwise:

release=com.sunstoneapps.dailysudoku@0.1.0+2
dist=2

The installed app was versionCode=3, but the JavaScript bundle still reported +2.

The symptom

After updating through Google Play, adb confirmed:

versionCode=3
versionName=0.1.0
installerPackageName=com.android.vending

That looked correct. The app came from Play, not sideload.

But the runtime showed:

[crash-reporting] initialize {
  "release": "com.sunstoneapps.dailysudoku@0.1.0+2",
  "dist": "2"
}

This kind of mismatch is easy to ignore because the user does not see it. For release operations, it is serious: any error captured by Sentry would be attached to the wrong build.

Why it matters

In React Native, an Android app is not only manifest, resources, and native code. There is also the JavaScript bundle.

If the manifest changes but the JS bundle is not regenerated, you can end up with an invalid combination:

Android package: versionCode=3
JavaScript runtime: dist=2
Expected Sentry source maps: dist=3

In that state, error reports become ambiguous. You do not know whether you are looking at the new build, the previous one, or an accidental hybrid.

The wrong hypothesis

The natural first hypothesis was that Play Store had not propagated the correct version yet. That often happens on internal tracks: the Console shows a published release while the device still receives the previous one for a few minutes.

But adb ruled that out:

versionCode=3
installerPackageName=com.android.vending

The new package was installed.

The correct hypothesis

The problem was in the build artifact. The AAB had a new manifest, but the JS bundle used by Gradle still carried metadata generated earlier.

The source code was correct:

android.versionCode = 3

The Sentry source map had also been uploaded for +3.

But the runtime disagreed. That pointed to caching in the build path.

The fix

The solution was to generate a new AAB with versionCode=4, forcing Gradle tasks to rerun:

pnpm sudoku:aab:clean

The script started running:

./gradlew bundleRelease --rerun-tasks

And the build confirmed the fix before upload:

createBundleReleaseJsAndAssets_SentryUpload_com.sunstoneapps.dailysudoku@0.1.0+4_4
Uploading sourcemaps for release com.sunstoneapps.dailysudoku@0.1.0+4 distribution 4

After publishing and installing from Google Play, the runtime finally matched:

versionCode=4
installerPackageName=com.android.vending
release=com.sunstoneapps.dailysudoku@0.1.0+4
dist=4

What changed in the process

The technical fix was small. The process change mattered more.

Before:

pnpm sudoku:aab

After, for any AAB that will be uploaded to Google Play after a versionCode change:

pnpm sudoku:aab:clean

The incremental command is still useful for local iteration. The clean command became the release path.

How to catch this early

Do not trust only Play Console and versionCode.

Validate three layers:

  1. Android package:
adb shell dumpsys package com.sunstoneapps.dailysudoku | rg 'versionCode|versionName|installerPackageName'
  1. Sentry runtime:
[crash-reporting] initialize
  1. Uploaded source maps:
release=com.sunstoneapps.dailysudoku@0.1.0+4
dist=4

If the three layers do not match, the release is not trustworthy yet.

The lesson

versionCode proves Android received a new package. It does not prove the JavaScript bundle was regenerated the way you expected.

In React Native apps, a reliable release needs to validate manifest, JS runtime, and observability together.

This bug does not show up in a screenshot, does not break the main flow, and may not generate a crash. It still compromises your ability to understand any problem that comes later.

Make the JavaScript build observable

A cached JavaScript bundle is dangerous because the native shell can look correct. The version code changes, the APK installs, the app icon launches, and yet the runtime still behaves like yesterday’s build. That creates false release confidence.

The fix is not only clearing caches. Clearing caches is a recovery action. Prevention means making the JavaScript build observable inside the app. A release screen, startup log, or diagnostics payload should expose the app version, native build number, JavaScript bundle marker, environment, and config source.

When those values are visible, the question changes from “did Gradle run?” to “which runtime is this device actually executing?” That is the question that matters during release validation.

Release checks that catch this early

A practical release check should include:

  • uninstalling the previous package when the test requires clean state;
  • installing the same artifact that will be uploaded or distributed;
  • opening a screen that shows build and environment data;
  • checking one behavior that exists only in the new JavaScript bundle;
  • verifying crash reporting release/dist when Sentry or another reporter is enabled.

This matters more in Expo and React Native apps because the native build and JavaScript bundle are coupled but not identical. Native versioning alone does not prove that runtime logic changed.

Treat stale bundles as a process bug

If the wrong JavaScript ships once, assume the process allowed it. Add a visible marker, add a release validation step, and prefer deterministic build commands over manual sequences. The goal is not to remember the right incantation; the goal is to make the wrong artifact obvious before testers or users find it.

That small discipline pays off during urgent fixes. When a hotfix build is installed on a device, the team should know within seconds whether it is running the new code.

Separate build identity from app identity

One useful habit is to keep a build identity that is not only the public app version. The public version is for users and stores. The build identity is for the team. It can include a timestamp, commit hash, config environment, or generated bundle marker.

That information does not need to be user-facing forever, but it should be reachable during validation. A hidden diagnostics tap, internal settings row, or startup log can be enough. The important part is that a tester can prove which JavaScript code is running without guessing from behavior.

If the app has crash reporting, send the same identity there. A crash from a stale bundle should be obviously tied to the stale release metadata.

Cache bugs are easier to prevent than explain

Once testers lose confidence in a build, every later report becomes harder to interpret. Is the bug still present, or is the device running old JavaScript? Is the fix broken, or was the wrong artifact installed? Those questions slow the team down more than the original cache issue.

That is why build identity should be part of the release UX for the team. It can be hidden from regular users, but it should be fast for a tester to find. A simple diagnostics row can save hours during a release candidate.

The rule is boring: every install used for release validation should prove both native identity and JavaScript identity. If it cannot, the process is asking people to trust a black box.

A final sanity check is to change something small and visible in the JavaScript layer before a release candidate, then verify it on the installed artifact. It is crude, but it catches the exact class of mistake where native metadata changes and runtime code does not.

Conclusion

The best time to discover a cached JS bundle is before expanding the test track.

The second-best time is while you can still generate a new versionCode, publish internally, and validate again calmly.

The worst time is after real users start reporting errors that you cannot map safely.