Lesson 1 of 5
Builds, Signing and EAS
"Omnia mutantur, nihil interit." — everything changes, nothing perishes. — Ovid, Metamorphoses (8 AD)
The thing a user installs has almost nothing in common with the thing you wrote.
Your JavaScript is collected by a bundler into one file, that file is compiled into bytecode by a separate compiler, the bytecode is embedded as a resource in a native binary produced by a different toolchain again, and the binary is cryptographically signed by an identity issued to your organisation by a platform vendor. Four transformations, three of which you did not write and cannot easily inspect.
Everything in this chapter is a consequence of that chain. Why a release build's stack traces are unreadable without two source maps composed together. Why the file called main.jsbundle in a release app is not JavaScript. Why a lost signing key on Android can cost you the app. And why so much of the industry has moved this whole process onto somebody else's machines.
A server deploy is reversible: you push, it breaks, you roll back, and the whole cycle is minutes. A mobile release is none of those things — it is reviewed by a third party, installed by users at their own pace, and cannot be recalled from a device that already has it. Almost every practice in this Part exists because of that asymmetry. What does it change about how you decide something is ready?
Four transformations, and what each one destroys
The iOS build script that Xcode runs is in React Native's own package, and it is readable. Here is the pipeline it describes, stage by stage.
That second one is the finding I did not expect. Everybody knows a release build is minified; in a Hermes React Native app it explicitly is not, and the reason is that minification is the wrong tool once the output is not JavaScript.
The script also ends with a guard worth knowing, because it is the difference between a bad release and a caught one:
if [[ $DEV != true && ! -f "$BUNDLE_FILE" ]]; then
echo "error: File $BUNDLE_FILE does not exist. Your environment is"
"misconfigured as Metro was not able to produce the bundle so your"
"release application won't work!" >&2
exit 2
fiA release build that silently produced no bundle would compile, link, sign and install perfectly, and then show a white screen on launch — because there would be no JavaScript for the app to run. The check costs nothing and turns the worst possible outcome into a build failure, which is the right place for it.
Translating a book, then typesetting it, then printing it, then putting a publisher's imprint on the spine. Each step is faithful and each one destroys information the previous step had: the translation loses the original's idiom, the typesetting loses the manuscript's line breaks, the printing fixes everything permanently, and the imprint asserts who is answerable for it. Nobody reading the finished book can reconstruct the manuscript from it, and that is fine — right up until someone reports an error on page 312 and you need to know which sentence of the original that was. The apparatus that answers that question is not part of the book. It is a separate record kept by the publisher, and if it was not kept at the time it cannot be reconstructed afterwards. Source maps are that record, and a release shipped without them is a book with an erratum you cannot trace.
Two source maps, composed
That last point is the practical consequence of the pipeline, and the build script handles it explicitly.
Because there are two transformations of your code, there are two mappings — one from your source to the Metro bundle, one from that bundle to Hermes bytecode — and a stack trace from a device is expressed in terms of the bytecode. Neither map alone can get you home.
That is the strongest operational argument in this chapter. Capture the source map as a build artefact at the moment of the build, upload it to whatever will receive your crash reports, and keep it for as long as any user might still be running that version. It cannot be recovered afterwards.
The engine is no longer a choice
One flag in this area has stopped being a flag. React Native's CocoaPods script contains a function whose name says it:
def error_if_try_to_use_jsc_from_core()
explicitly_not_use_hermes = ENV['USE_HERMES'] != nil && ENV['USE_HERMES'] == '0'
not_use_3rd_party_jsc = ENV['USE_THIRD_PARTY_JSC'] == nil || ENV['USE_THIRD_PARTY_JSC'] == '0'
if (explicitly_not_use_hermes && not_use_3rd_party_jsc)
message = "Hermes is the default engine and JSC has been moved to community support.\n" +
"Please remove the USE_HERMES=0, as it is not supported anymore.\n" +
"If you want to use JSC, you can install it from `@react-native-community/javascriptcore`."
puts message.red
exit()
end
end
Setting USE_HERMES=0 does not fall back to JSC. It exits the pod install. JavaScriptCore has moved to a community package, and Hermes is not a default so much as the engine.
That has consequences elsewhere in this volume. Part V's performance.memory reads Hermes heap statistics and returns nothing useful on other engines — which, now that Hermes is the engine, means it works for essentially everybody rather than being a lucky case.
Signing: two models, one of which is unforgiving
Both platforms require a cryptographic identity, and they differ in a way that matters enormously if something goes wrong.
Signing on Android used to be entirely the developer's problem, and the consequences were exactly as brutal as the block above describes — there is a long tail of abandoned apps whose authors lost a keystore and simply could not continue. Play App Signing, introduced with the Android App Bundle, changed the model: you upload a bundle signed with an upload key, Google verifies it, strips your signature, and re-signs with the app signing key it holds. The upload key is resettable because it only proves you are the uploader; the signing key is the one devices care about and Google keeps it safe. That is also why the distributable format changed from APK to AAB — Google needs to be able to re-sign and to generate device-specific APKs, which requires an artefact that is not yet a final signed app. Apple's model arrived at similar safety from a different direction: certificates are revocable and profiles are regenerable because trust is anchored in an account rather than in a file on a laptop. Both platforms, independently, concluded that the worst outcome was a developer locked out of their own app by a lost file.
What EAS and similar services are actually for
The reason so many teams build in the cloud is not convenience, though it is convenient. It is that the local build environment is a large, undocumented, drifting dependency.
A React Native build depends on Xcode and its command-line tools, a specific Android SDK and NDK, a JDK, CocoaPods, Ruby, Node, and whatever versions of each happen to be on the machine. None of that is in your repository. The classic failure — it builds for one developer and not another — is not carelessness; it is the predictable result of a dependency set that is real, load-bearing, and untracked.
The --minify false line stopped me. I read it three times assuming I had the condition backwards, because "release builds are not minified" is exactly the shape of a bug. It is deliberate, and once the reason lands it is obvious: Hermes compiles to bytecode, identifier names do not exist in the output, so minifying the JavaScript first shortens names that are about to be discarded and mangles the source map for no benefit. What I find instructive is how confidently I would have asserted the opposite. "Release builds are minified" is one of those things I have never had cause to check, and it is now false for the majority of React Native apps — not because anyone changed their mind about minification, but because the artefact stopped being JavaScript and the old advice quietly stopped applying. That is the same shape as the StyleSheet.create finding in Part III: advice that was true, a mechanism that changed underneath it, and no feedback anywhere to tell me.
The incident that hurts most is the unsymbolicated crash. A release goes out, reports arrive, and every stack frame is a hexadecimal address — because the build ran on someone's laptop, the source map was written to a build directory, and the build directory was cleaned. You cannot regenerate it: rebuilding the same commit on a different machine, or with a different Xcode, can produce different bytecode offsets, so the map you make today does not describe the binary users are running. The fix is a pipeline rule rather than a code change — treat the source map as a release artefact, upload it in the same job that produced the binary, and keep it as long as any user might still be on that version. The other one worth naming is the build that works for one person: it is almost never a mystery, it is an undocumented toolchain difference, and the durable answer is to stop building on laptops at all. Both failures share a property that makes them easy to under-invest in — they cost nothing until the day they cost a great deal, and the day is always one you did not choose.
Common Mistakes
- Expecting
main.jsbundleto contain JavaScript. After Hermes it is bytecode under the same filename. - Assuming release builds are minified. With Hermes the script explicitly passes
--minify false, and the reason is sound. - Keeping only one source map. Symbolication needs the packager map and the Hermes map, composed.
- Not capturing source maps at build time. They cannot be regenerated later for a binary that already shipped.
- Self-managing the Android app signing key. Losing it means no update can ever reach an existing install.
- Setting
USE_HERMES=0. It does not fall back to JSC; it exits the pod install. - Treating the build environment as reproducible. Six of its dependencies are pinned nowhere in your repository.
- Building releases on a laptop. The environment is untracked, and "works for me" is the predictable outcome.
- Putting signing identities in the repository. They belong in a vault or a hosted service's credential store.
- Thinking a hosted build service solves releases. It pins the toolchain; review, rollout and versioning are untouched.
Interview Corner
hermesc -emit-binary -O. The bytecode is embedded in the native binary as a resource, so the app ships with compiled bytecode rather than JavaScript source. One detail that surprises people is that the output keeps the same filename — the file called main.jsbundle in a Hermes release build is not readable JavaScript. Another is that Metro is told not to minify in that configuration, because identifier names do not survive into bytecode, so minifying first buys nothing and makes the source maps worse.Hands-on
Model the pipeline. Implement bundleArgs, reproducing the script's flag logic including the --minify false case and the -O / -Og choice; pipelineFor, listing the stages and saying what the shipped bundle contains; composeMaps, chaining two mappings and reporting when one alone is insufficient; signingRisk, judging what losing each artefact costs; and reproducibility, counting which build dependencies a repository actually pins. The checks below run either way — a stub scores zero, so a passing run means the behaviour is real.
Summary
- Four transformations separate your source from a user's app: Metro, Hermes, the platform toolchain, and signing.
main.jsbundlein a Hermes release build is bytecode, written to the same filename Metro produced.- Release builds are explicitly not minified with Hermes — the script passes
--minify false, because identifiers do not survive into bytecode. - There are two source maps, and the build script composes them and deletes both intermediates.
- A source map cannot be regenerated later, because a rebuild is not guaranteed to produce identical bytecode offsets.
USE_HERMES=0exits the pod install. JSC has moved to a community package; Hermes is the engine.- A self-managed Android app signing key is the one unrecoverable artefact in mobile release engineering.
- Play App Signing removes that failure mode by holding the signing key and letting you use a resettable upload key.
- Six build dependencies are pinned nowhere in your repository, which is why "it builds for me" happens.
- A hosted build service pins the toolchain and nothing else — review, rollout and versioning are all still ahead of you.
In the next chapter, Over-the-Air Updates, we go after the one thing this chapter's pipeline makes possible: shipping new JavaScript without shipping a new binary — and the rules, both technical and contractual, about what you may not change that way.
Reading a build script is not the most glamorous research in this volume and it was among the most corrective. Two beliefs I held confidently turned out to be wrong in the same file — that release builds are minified, and that main.jsbundle contains JavaScript — and both were wrong for the same underlying reason: the artefact stopped being JavaScript, and advice about JavaScript stopped applying without anybody announcing it. What strikes me about build tooling generally is that it is the least-read code in most projects and the code with the most authority over what users actually receive. Everything else in this volume can be reasoned about from the source of a library; this part can only be known by reading the script that runs it, because the script is where the decisions are. If I were to give one piece of advice off the back of this chapter it would be unglamorous: open your build script once, read it end to end, and find out what your users are actually running. It takes twenty minutes and it is the only way to know.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.