All posts
EngineeringSeptember 27, 2026 · The Ubriot team · 5 min read

An OTA update should never be older than the binary it runs on

A fresh native build can be hidden by an over-the-air bundle that was published before it. Why that happens, and the ordering rule we now apply on the update server.

We shipped a new native build of one of our own apps, installed it on a test phone, and opened it. The old screens came back. The build was correct and the native version was the new one. The JavaScript the app was actually running was not.

The cause was ordinary. Earlier, an over-the-air update had been published to the same channel and runtime. The phone had downloaded and stored it. When the new binary launched, the update client found a stored bundle that matched its runtime, preferred it over the bundle embedded in the binary, and the newer native release was quietly shadowed by older code.

Nothing in that chain was a bug in the usual sense. Each component followed its rule. The failure was that no rule compared the age of the update with the age of the binary it was about to run on.

Why a runtime match is not enough

Runtime versions answer a compatibility question: can this JavaScript run on this native code without calling something that does not exist? They do not answer a freshness question. A team that keeps the same runtime across several native builds, which is common when a native change is small, can have many binaries that all accept the same stored update.

Most of the time that is the point. You want one OTA to reach every compatible install. The trouble starts when the embedded bundle inside a new binary is itself newer than the last OTA on the channel. At that moment the update is compatible and stale at once, and a client that only checks compatibility will pick the stale one.

The symptom is confusing because it looks like the native build failed. People rebuild, resubmit, and reinstall on their own devices, which can fix the phone in their hand and nothing else.

The ordering rule

The rule we settled on is simple to state: the update server does not offer an update that was created before the native build the device is running. If the installed binary is newer than every eligible update on its channel, the correct answer is no update, and the app should run the bundle it shipped with. The server can only stop offering stale code. The client still has to prefer its embedded bundle over an older one it already stored, which is why we test both halves.

Two pieces make that possible. At build time, the worker stamps the binary with its build identity and the time the build was created. At request time, the client sends those values with its update check, and the server compares them with the creation time of the update currently assigned to that channel and runtime. If the update is older, the server answers with no update. If an operator has blocked the current update group, the answer is also no update, so a withdrawn release cannot keep reaching devices through the channel pointer.

The comparison uses times recorded by our own systems, not the phone's clock. A device clock can be wrong by hours or set deliberately. The build time comes from the build record and the update time from the publish record, so both sides of the comparison come from the same source of truth.

Where the rule needs care

Creation time is a proxy for newness, and proxies have edges. Suppose a hotfix is cherry-picked onto an older branch and published as an OTA after a native build from the main branch. By time, the hotfix is newer. By content, it may be missing work the binary already contains. The ordering rule will offer it. That is why we still treat channel and branch discipline as the primary control and the timestamp check as a floor, not a replacement.

Some builds cannot be stamped. Apps with fully dynamic configuration may not give the worker a stable place to write the values. In that case the build log carries a warning, and the server falls back to the coarser check described below for that binary. We would rather say plainly that the guard is not active than let a missing stamp look like a pass.

There is also a server-side check for clients that send nothing. If the latest successful native build for the app finished after an update was created, that update is not served as the newest choice. This is weaker than the per-device check, because it does not know which binary a given phone has, but it closes the most common gap for older clients.

What to check on your own pipeline

Whatever update system you use, it is worth asking three questions. Does the binary know when it was built, in a form the update check can read? Does the server compare that against the update before offering it? When the answer is no update, does the app fall back to its embedded bundle cleanly, rather than to whatever it cached last?

If any answer is unclear, test it directly: publish an OTA, then ship a native build on the same runtime, install it on a device that already has the OTA, and see which code runs. It takes a few minutes and tells you more than reading configuration.

We are rolling this guard out across Ubriot's update service and the native builds it produces. It does not make every OTA decision correct. It removes one way for an old decision to overrule a new one.

Try Ubriot AI

React Native CI/CD from one CLI.

Get started