All posts
EngineeringOctober 4, 2026 · The Ubriot team · 5 min read

An OTA update should arrive whole or not at all

An over the air update is a bundle plus every font and image it points to. If the device keeps the bundle without the rest, users get blank icons or a crash on a screen nobody tested. Download every file, check each one against the manifest, stage only when the set is complete, and confirm the launch from a real screen.

A team ships an over the air update that fixes a checkout bug and swaps one icon on the cart screen. On most phones it lands without trouble. On a few, the user opened the app on a train, the JavaScript bundle finished downloading, and the connection dropped before the new icon did. Next launch, the new bundle runs and asks for an image file the phone does not have. Depending on how the app loads images, that is a blank square, a fallback to the old icon, or a crash on a screen nobody tested in that state.

The update was not wrong. It was incomplete, and the client treated incomplete as good enough. We changed how the Ubriot React Native update client handles this last week, and the rules we settled on apply to any OTA setup.

An update is a set of files, not one file

It is easy to think of an OTA update as the JavaScript bundle. In practice the bundle is the entry point to a set: the bundle itself, plus every font, image and other asset it references that was not already shipped inside the native build. The manifest the server sends lists all of them. A client that only fetches the bundle, or that fetches the rest on a best effort basis, is quietly betting that nothing in the update depends on a new asset. That bet holds until the first time a designer changes an icon.

Check every file against the manifest

Most update manifests can carry a content hash for each file. Use it. After each download, compute the SHA-256 of what landed on disk and compare it with the manifest. A mismatch means the file was cut short, corrupted on the way, or served from a stale cache, and in each case the right response is the same: do not use it.

Two details are worth getting right. Follow redirects, because many storage setups serve files through one, and a client that treats a redirect as the file itself will save a few hundred bytes of HTML under an image name. And when a manifest entry has no hash, decide on purpose what that means for your app rather than letting it fall through silently.

Stage only when the set is complete

Download into a folder that belongs to that one update, and only mark the update ready once every file in the manifest is present and verified. If any file fails, discard the whole folder and keep running whatever the app already has. A user who stays on yesterday's version for one more launch is fine. A user who gets half of today's version is the one who files the bug report.

This also makes the state easy to reason about. At any moment the device has one of three things for a given update: nothing, a complete verified copy waiting to be applied, or a complete verified copy that is running. There is no fourth state where some of it is there.

Fall back to the build for anything the update did not ship

Not every asset changes in every update. The bundle should resolve each asset to the downloaded file when the update included it, and to the copy embedded in the native build when it did not. Matching by content hash rather than file name keeps this honest: two files with the same name and different contents are different assets.

Confirm the launch from a real screen

Downloading cleanly is half the job. The other half is knowing whether the new code actually runs. The usual approach is to mark a launch successful after a fixed delay, say four seconds after start. That catches a crash on load, but it also marks an update as good when the app is sitting on a frozen splash screen, or when the crash happens on the second screen at five seconds.

A better signal is to confirm success from the first screen the user can actually use, once it has rendered. Two more rules keep the rollback logic sound. Only the bundle that is running right now can be marked good; an update that was downloaded during this session and has not run yet has proved nothing. And when an update is rolled back after failing to launch, remember its id and do not download it again, or a bad update and the rollback that removes it will chase each other on every launch.

A short checklist for any OTA client

Does it download every asset in the manifest, not just the bundle? Does it verify each file against a hash? Does it stage the update only when the whole set is there, and discard partial downloads? Does it fall back to the embedded copy for assets the update did not include? Does it confirm success from a rendered screen rather than a timer? Does it refuse to fetch an update it has already rolled back? If any answer is no, the gap will show up first on slow and interrupted connections, which is exactly where your QA team is least likely to look.

Version 0.2.0 of the Ubriot React Native client works this way. It changes native code, so apps need a new native build before devices pick it up, and older builds keep the previous bundle only behaviour until they update.

Try Ubriot AI

React Native CI/CD from one CLI.

Get started