3-minute React Native builds by caching the native side
Most React Native CI time is spent rebuilding things that did not change. A typical commit touches JavaScript. The pipeline still installs pods, compiles every native module, and runs the full Xcode build to produce an app whose native half is byte-identical to yesterday’s. On a heavy production app that was 25 minutes per run, many runs per day.
The fix is a question: did anything that affects the native build actually change? If the answer is no, do not build it. Restore the compiled app from cache and only rebuild the JavaScript.
Fingerprinting the native side
The hard part is answering that question reliably. Expo’s fingerprint package solves it. It hashes everything that influences the native build output: native source, dependency manifests, config plugins, build properties. Same fingerprint, same native binary.
The fingerprint alone is not enough for a cache key. Env files baked in at build time and assets compiled into the binary have to participate too:
- name: Compute native fingerprint
id: fingerprint
run: echo "hash=$(npx fingerprint . | jq -r '.hash')" >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@v4
id: native
with:
path: DerivedData/Build/Products/Release-iphonesimulator/*.app
key: ios-v1-${{ steps.fingerprint.outputs.hash }}-${{ hashFiles('.env.qa') }}-${{ hashFiles('src/**/*.png', 'src/**/*.ttf') }}
Anything that belongs in the key but is missing will produce stale builds that look fine and behave wrong. This is the part to be paranoid about. When in doubt, put it in the key. A false miss costs you 25 minutes. A false hit costs you a day of debugging a binary that lies.
The cache hit path
On a hit, every native step is skipped. No CocoaPods, no gems, no Xcode. The only work left is the JavaScript:
npx react-native bundle \
--platform ios --dev false \
--entry-file index.js \
--bundle-output "$WORK/main.jsbundle.js" \
--assets-dest "$APP"
hermesc -emit-binary -O \
-out "$APP/main.jsbundle" \
"$WORK/main.jsbundle.js"
codesign --force --deep --sign - "$APP"
Bundle with Metro, compile to Hermes bytecode, drop it into the cached .app, re-sign. The app is fresh JS in yesterday’s native shell, which is exactly what the commit changed. Three minutes, most of it checkout and bun install.
On a miss, the full build runs once and saves the compiled app under the computed key. The next JS-only commit hits.
Where this idea comes from
This is not a novel trick. Rock builds its remote build cache around the same fingerprint idea, as a framework feature with hosted storage. EAS does related work on its build infrastructure. If you are starting fresh, look at those first.
We built ours as a small composite action plus a repack script, on plain GitHub Actions cache, because the pipeline already existed and the whole thing is under a hundred lines. There is no service to adopt and nothing to migrate. It is a cache key and a shell script.
The payoff
The build stopped being a reason to batch commits. E2E suites run on every PR because producing the app they test is nearly free. And the release pipeline kept its full, boring, from-scratch build, because release binaries should never come out of a cache keyed by anything.
Fast CI is not about faster machines. It is about refusing to repeat work you can prove you already did. The fingerprint is the proof.