Build integration
Published
If your mobile application can push a new JavaScript bundle to installed devices without going through a store release, you have a delivery channel with different properties from the one that put the app there in the first place. Protecting the bundle is the easy part and needs no special mechanism. The interesting questions are all about release management, and one of them will surprise you the first time your update sizes jump.
The protection step does not change
Start with the reassuring part. An over-the-air update for a JavaScript-based mobile app is a bundle: the same artifact the packager would otherwise have written into the application binary, delivered over the network instead. There is no separate format and no separate build.
That means protection attaches in the same place it always did, at the bundler's serialization stage. Wrapping the serializer means every release bundle comes out protected, whether it is destined for a store binary or for an update service, with nothing extra for a developer to remember on release day. The integration derives a bundle name that carries the platform and a development or release suffix, which is a helpful reminder of something worth stating plainly: the development bundle and the shipping bundle are different artifacts. Protect the release one, leave the development one alone, and your local reload loop stays as fast as it was.
If your application compiles the bundle further before shipping — into a bytecode format, for instance — that ordering is covered in the React Native guide, and it does not change here: the protection runs at the JavaScript stage, before anything downstream consumes it.
The threat that actually matters in an update channel
Here is where a hot-update channel differs from a store release in a way worth thinking carefully about.
When you ship through a store, the delivery path is operated by the platform, signed by the platform, and verified by the device. When you push an update yourself, you have built a mechanism that accepts code from a network location and runs it inside your application. The most consequential question about that mechanism is not whether the code is readable. It is who is allowed to put code into it.
An attacker who can deliver a payload to your update channel does not care what your bundle looks like, because they are not reading yours — they are supplying theirs. Obfuscation contributes nothing to that problem in either direction. The controls that do are authenticity controls:
- Sign the update payload and verify the signature on the device before running it.
- Pin the update endpoint so a redirected or intercepted request cannot substitute a source.
- Restrict who can publish a release, and make publishing an auditable action rather than a command anyone with the credentials can run from a laptop.
- Keep a record of which release identifier corresponds to which artifact, so “what is actually running on devices” has an answer.
None of those is a build option, and all of them matter more than the transformation. Protection is worth doing on top — it is what stops a competitor unpacking your update and reading your logic, which is a real and separate concern — but it is the second question, not the first.
Why your delta updates got bigger
This is the surprise, and it is the most useful thing in this article.
Update services generally do not want to send a whole bundle every time. Many of them ship a binary difference against the bundle already on the device, so a small code change becomes a small download. That is the feature that makes frequent updates tolerable for users on metered or slow connections.
Now consider what identifier renaming does. Generated names are, by default, generated afresh on each run. A function that was a in last week's release is q in this week's, along with every other name in the file, whether or not the code around it changed at all. From the differ's point of view the two bundles are not similar documents with a small edit between them. They are two different documents. The delta approaches the size of the entire bundle, and your incremental update quietly becomes a full download for every user.
The fix is a fixed seed. With the seed fixed, the protection step becomes a deterministic function of its input: the same input produces the same output, every time. Modules you did not touch have a much better chance of serialising identically, and the difference between releases collapses back toward the size of the change you actually made.
It is worth being precise about the strength of that claim. A seed gives you determinism, not locality. A change in one module can still shift naming or layout in code around it, so the delta will not always be as small as the source change suggests. What the seed removes is the guaranteed worst case, where nothing matches because nothing was meant to.
The tension: determinism versus variation
Before you fix the seed everywhere, understand what you are giving up, because there is a genuine argument on the other side.
Output that changes every release is hostile to anyone maintaining a modification against your client. A patch written against last month's build — an offset, a hooked function name, a string replaced at a known index — breaks when the next release renames everything, and it breaks again on the release after that. For an application where people actively modify the client, that recurring breakage is one of the more effective deterrents available, precisely because it costs the attacker time on your schedule rather than theirs.
So the two goals genuinely conflict:
- Fixed seed — small deltas, reproducible builds, tidy change records, easier support. Costs you the per-release churn that frustrates durable patches.
- Regenerated each build — every release invalidates existing modifications. Costs you update size and build reproducibility.
There is no universally right answer, but there is usually a clear one for a given product. If you ship often, to a broad consumer base, over cellular connections, size wins and the seed should be fixed. If you ship rarely into an environment where people actively tamper with the client, variation is worth paying for. A reasonable middle path is to hold the seed stable across the frequent small updates within a release train and rotate it at each major version, which gets most of the delta benefit while still resetting the attacker's work periodically.
Symbolication, and the mistake with the maps
A protected bundle produces stack traces full of generated names. The identifier maps emitted with the build are what turn those back into names you recognise, and this delivery model introduces a wrinkle that store releases do not have.
With store releases, filing a map under the application version works, because one version means one bundle. With hot updates it does not. A single store version of your app might run four or five different bundles over its life as you push fixes, and every one of them has its own generated names. A crash report tagged only with the app version is ambiguous across all of them, and picking the wrong map produces confidently wrong function names, which is worse than none.
The discipline is straightforward:
- Key the map to the update release identifier, not to the application version.
- Store it at publish time, automatically, as part of the release step — not as a manual follow-up.
- Keep it in private storage that your crash tooling can reach and your users cannot.
- Retain every published release's map for as long as any device could still be running that release. Devices that never opened the app do not update on your schedule.
- Make sure your crash reports carry the release identifier in the first place. If they do not, fix that before you enable protection rather than after.
Rollback deserves its own line here. When you roll a release back, devices resume running an earlier artifact and start reporting traces against that artifact's names. If your retention keeps only the newest map, you lose readable diagnostics exactly when you are investigating the problem that caused the rollback. Retention has to cover everything still in the field.
Keep the source maps out of the payload
Anything you hand to the update service can be delivered to a device, and a source map sitting next to a protected bundle hands back everything the protection removed to anyone who fetches it. The Metro integration can drop the map from the emitted output for exactly this reason.
Generate maps, keep them privately for symbolication, and publish only the bundle. This is the same rule as for web builds, and it fails in the same quiet way: nothing looks wrong, because everything works.
What the channel does not change
A fast update path is genuinely useful, and it tempts teams into treating the client as somewhere logic can safely live, on the reasoning that anything wrong can be fixed within the hour. The reasoning is fine and the conclusion does not follow. Code delivered to a device is readable with effort, whether it arrived through a store or over the air, and the ability to change it quickly is not the ability to keep it secret. Entitlement decisions, pricing rules, and anything you cannot afford to have copied still belong behind an authenticated endpoint.
The other thing worth a deliberate decision rather than a habit: the channel lets you change the JavaScript half of the application after it was reviewed. Platform rules constrain what those updates may contain, and the sensible policy is to keep updates within the behaviour your reviewed application described — fixes and refinements rather than functionality nobody assessed. No build setting makes that call for you.
A release checklist
- Protect at the serializer, so update bundles and store bundles come from one path.
- Sign update payloads and verify on device. Pin the endpoint. Restrict and audit who can publish.
- Decide the seed policy deliberately, and write down why.
- Record a source hash and an output hash per release so you can prove which input produced which artifact.
- Store the identifier map at publish time, keyed to the release identifier, retained for everything still in the field.
- Publish the bundle without its source map.
- Run the end-to-end suite against the protected bundle before publishing, because a hot update reaches users faster than a store release and therefore so does a mistake.
Frequently asked questions
Can you obfuscate a JavaScript bundle that ships as an over-the-air update?
Yes, and it does not need a separate mechanism. An over-the-air update for a JavaScript-based mobile app is the same bundle the packager would have put inside the binary, delivered through a different channel. If you protect at the bundler's serialization stage, the artifact you upload to the update service is already protected, because it is the same artifact. The work is in the release process around it rather than in the protection step.
Does the protection step run differently for an update build than for a store build?
No, and that is the point of putting it at the serializer. The Metro integration wraps the serializer, so whatever produces a release bundle produces a protected one. The bundle name the integration derives carries the platform and a development or release suffix, which is a useful reminder that the development bundle and the shipping bundle are distinct artifacts. Protect the release one; leaving the development bundle alone is what keeps the local edit-and-reload loop fast.
What is the real risk in an update channel that obfuscation does not address?
Substitution. The threat that matters most for a channel that pushes code to installed applications is somebody delivering a payload of their own, and readability has no bearing on that. If an attacker can serve your app an update, they do not need to read your bundle because they can supply their own. The controls that address this are authenticity controls: signing the update payload and verifying the signature on the device, pinning the update endpoint, and restricting who can publish a release. Protection raises the cost of reading what you shipped and asserts nothing about who shipped it.
Why did our delta updates get bigger after enabling protection?
Because many update services ship a binary difference against the previously installed bundle, and identifier renaming is generated per build by default. When names differ throughout, the new bundle diverges from the old one almost everywhere, so the difference approaches the size of the whole file and your incremental update becomes close to a full download. This is the most common unpleasant surprise in this area and it is entirely fixable.
How does a fixed seed help with update size?
It makes the protection step a deterministic function of its input rather than a fresh source of variation on every run. With the seed fixed, identical input produces identical output, so the parts of your bundle that did not change between releases have a much better chance of serialising the same way and compressing into a small delta. It is not a guarantee of locality, because a change in one module can still shift things around it, but it converts a guaranteed worst case into an ordinary one.
Is there a reason to want the output to change every build?
Yes, and it is the tension worth understanding before you fix the seed. Output that differs each release is what breaks a patch someone wrote against a specific build, which is a genuine benefit if people modify your client. So the two goals pull in opposite directions: per-build variation frustrates durable tampering, and determinism keeps updates small and change control tidy. Most teams shipping frequent updates over cellular connections should weigh size heavily, and teams shipping rarely into a hostile environment should weigh variation heavily.
How do we read crash reports from a protected update?
By keeping the identifier maps and keying them to the release rather than to the application version. A protected bundle produces stack traces in terms of generated names, and the maps emitted with the build are what turn those back into your own names. The complication specific to this delivery model is that one store version of your app can run any of several bundles pushed during its lifetime, so a map filed under the app version alone is ambiguous. File the map under the update release identifier, keep it in private storage, and store it at publish time rather than trying to reconstruct it later.
What happens if we roll a release back?
You need the bundle and its map to move together, which is a process question rather than a technical one. Rolling back means devices resume running an earlier artifact, and any incoming crash report then refers to that artifact's generated names. If your retention only keeps the newest map, traces from rolled-back devices become unreadable at exactly the moment you are investigating why you rolled back. Retain the map for every published release for as long as any device could still be running it.
Should source maps be uploaded to the update service?
No. Whatever you send to the update service can be delivered to devices, and a source map delivered alongside a protected bundle undoes the protection for anyone who fetches it. The Metro integration can drop the map from the emitted output for exactly this reason. Generate maps, keep them in your own private storage for symbolication, and keep them out of the payload you publish.
Does this change anything about what belongs in the bundle in the first place?
Not at all, and it is worth restating because a fast update channel tempts people into treating the client as a safe place for logic. Anything shipped to a device is readable given effort, whether it went through a store or a hot update. Secrets, entitlement decisions and rules you cannot afford to have copied belong behind an authenticated endpoint. What the update channel genuinely gives you is the ability to change client behaviour quickly, which is valuable on its own terms and is not a substitute for putting authority on the server.
Does the update channel let us bypass what the store reviewed?
It lets you change the JavaScript half of the application after review, which is exactly why platform rules constrain what those updates may contain, and why this deserves a deliberate policy rather than a habit. Keep updates within the behaviour your reviewed application described, and treat the channel as a way to fix and refine rather than as a way to introduce functionality that was never assessed. That is a governance decision, and no build setting makes it for you.
Related reading