Error handling philosophy
native-update makes a few opinionated choices about how to fail. They are not the choices a generic SDK would make. This page explains the reasoning so you can predict the SDK's behaviour without reading the source.
The summary is short: the SDK fails closed where correctness matters and fails open where availability matters. Typed error codes everywhere so your code can branch on cause, not on parsed strings. The host app keeps running through almost every failure path — the only failure that should ever crash your app is one that's already in your own code, not in the OTA pipeline.
The two failure modes that matter
Every error path in a system has to pick one of two postures:
Fail closed means "if anything is wrong, refuse to proceed." The verification of a bundle's signature fails closed: if the signature doesn't verify, the SDK does not apply the bundle. Better to leave the user on the old code than to ship them code that might be hostile.
Fail open means "if anything is wrong, log it and proceed anyway." An analytics write fails open: if the SDK can't POST to /api/v1/analytics/install, it logs the failure and continues. Losing analytics data is worse than dropping the user's update flow because of an unrelated network blip.
The choice between the two is never "which is better generally." It's "which failure mode is worse for this specific operation." The SDK applies each posture deliberately, operation by operation.
What fails closed
The operations where correctness is non-negotiable:
Signature verification. A bundle whose signature does not verify under your public key is rejected. The bundle is deleted; the failed download is reported back to the backend as download_failed. The user stays on the previous bundle. No exception, no override, no "trust me" flag.
SHA-256 checksum mismatch. Identical disposition. The bytes don't match what the server claimed they would be, so the SDK refuses to apply them.
HTTPS requirement. In production builds, serverUrl must be HTTPS. The SDK throws at configure() time if it isn't — the misconfiguration is caught before any bundle is fetched. The exception is http://localhost and http://127.0.0.1 for development.
Plugin not initialised. Calling sync() before configure() throws PLUGIN_NOT_INITIALIZED. The SDK can't know which app the device represents until you tell it.
Required fields missing. Calling setChannel() with an empty string, or downloadUpdate() against a bundle that doesn't exist, throws an INVALID_ARGUMENT error rather than silently doing the wrong thing.
In every fail-closed case, the error is typed — it carries a stable UpdateErrorCode from a small enumerated set (SDK Reference → Security → Error Codes). Your code can switch on the code without parsing the human-readable message.
What fails open
The operations where availability beats strict correctness:
Analytics writes. Three endpoints: mauPing, reportDownload, reportInstall. All three fire-and-forget from the SDK's perspective — if the POST fails (network error, server returns 500, server returns 404), the SDK logs the failure at warn level and continues. Your update flow is not blocked by a broken analytics endpoint.
Background-update task scheduling. The SDK schedules the next periodic check via WorkManager (Android) or BGTaskScheduler (iOS). If the OS-level scheduler rejects the request — task budget exhausted, permitted-identifier missing on iOS, Doze Mode on Android — the SDK logs and returns gracefully. Background updates simply don't fire on this device; foreground sync still works.
Notification rendering. Android's POST_NOTIFICATIONS permission can be denied by the user. The SDK proceeds with the update; the user just doesn't see the "Update downloaded, tap to install" prompt. Foreground sync calls still surface progress to your UI.
Optional callbacks. Config callbacks like onUpdateAvailable and onUpdateDownloaded are user-provided. The SDK wraps each call in a try/catch so a developer bug in the callback doesn't kill the update flow. The exception is logged; the flow continues.
The pattern is: a failure should never deny the user an outcome they could have had if the failure hadn't happened. A broken analytics write doesn't deny the user an update. A broken background scheduler doesn't deny the user a foreground sync. A broken callback doesn't deny the user the bundle that was about to download.
Typed error codes everywhere
The SDK never throws a bare Error('Something went wrong'). Every thrown or rejected error has:
A code field — a stable enum value from UpdateErrorCode. Examples: NETWORK_ERROR, SIGNATURE_VERIFICATION_FAILED, BUNDLE_NOT_FOUND, PLATFORM_NOT_SUPPORTED, RATE_LIMITED, INVALID_CONFIG. The complete list lives in SDK Reference → Security → Error Codes. New codes are added in minor versions; existing codes never change meaning across minor versions.
A message field — a human-readable explanation. Not stable; designed for log inspection by developers, not for parsing by code.
An optional details field — a structured object with extra context. For NETWORK_ERROR it might carry the failed URL; for SIGNATURE_VERIFICATION_FAILED it might carry the bundle ID. Always log this in your own error reporter; never parse it in branching logic.
import { NativeUpdate, UpdateErrorCode } from 'native-update';
try {
await NativeUpdate.sync();
} catch (err) {
switch (err.code) {
case UpdateErrorCode.NETWORK_ERROR:
// User's offline. Quietly retry later.
break;
case UpdateErrorCode.SIGNATURE_VERIFICATION_FAILED:
// Signing-key drift. Alert support.
reportToSupport({ kind: 'signing-drift', err });
break;
case UpdateErrorCode.RATE_LIMITED:
// We hit the API key's rate limit. Back off.
await wait(60_000);
break;
default:
// Unknown — log + carry on.
console.warn('Unhandled OTA error', err);
}
}
Stable codes let your error-handling logic be precise instead of regex-parsing English strings.
The host app must keep running
This is the single most important behavioural rule:
No OTA failure may crash your app.
The native plugin layer catches every internal exception, logs it, and emits a clean rejection back to your JavaScript code. The web layer wraps every async path in try/catch. The bundle-apply step is atomic — the swap either completes fully or doesn't happen — so partial state is impossible. Even if every cryptographic check fails simultaneously, the worst outcome is "the user stays on the old bundle and your callback gets a typed error."
This rule has a corollary: your own code must not crash on a thrown OTA error. If you await NativeUpdate.sync() without a try/catch, an error in the SDK rejects the promise and an unhandled rejection in your bootstrap path can crash the app. The plugin catches its own errors; you must catch the errors at your call site.
The defensive pattern:
async function safeBootstrapOta() {
try {
await NativeUpdate.configure({ config: liveUpdate });
await NativeUpdate.notifyAppReady();
await NativeUpdate.sync();
} catch (err) {
// Log it, report it, but DO NOT rethrow.
// OTA is best-effort; the app must continue.
logger.warn('OTA bootstrap failed; continuing without update', err);
}
}
safeBootstrapOta();
Notice the bootstrap is fire-and-forget. The app's main render path does not await it. OTA is a side-effect of launch, not a launch dependency.
Conservative defaults are the right defaults
Two specific defaults shape the SDK's failure surface:
Two failed boots, then rollback. A freshly-applied bundle stays pending until your app calls notifyAppReady(). Every cold start that never reaches the call counts as one failed boot; after two failed boots in a row, the next launch reverts to the previous verified bundle and marks the new one failed. There is no timer, so a slow boot is never rolled back for being slow.
Could the limit be one? It could, but a single crash on a bad network or a killed process would then throw away a good bundle. Could it be higher? It could, but every extra attempt is another launch where the user sees a crashing app. Two is the point where a crash loop is clear and a one-off failure is forgiven.
The 30-minute signed download URL TTL. When the check endpoint mints a signed URL, the URL is valid for 30 minutes. The figure is calibrated for slow mobile downloads of 20+ MB bundles (the previous 5-minute default routinely expired mid-stream on hotel WiFi). Longer TTLs make replay attacks easier; shorter TTLs make legitimate downloads fail.
If your bundles are unusually large or your users are on unusually slow networks, the TTL is configurable via the backend's NATIVE_UPDATE_DOWNLOAD_URL_TTL_MINUTES env var. The defaults are calibrated for the median case.
What the philosophy gets you
Three properties fall out of this design:
Predictability. The same operation always fails the same way. A signature mismatch always throws SIGNATURE_VERIFICATION_FAILED. A network error always throws NETWORK_ERROR. You can write error-handling code once and trust it across SDK versions.
Recoverability. Every failure leaves the system in a known-good state. A failed download leaves no partial bundle on disk. A failed apply leaves the previous bundle active. A failed sync leaves the configured channel unchanged. There is no "the SDK got confused; uninstall and reinstall" failure mode.
Auditability. Every failure is logged with structured fields (the typed code, the message, the optional details). Hook a logger into your observability stack and you can see exactly what failed and where without parsing logs by hand.
Together these properties make native-update something you can put in a critical path and not worry about. Errors are events, not crises.
Where to go next
- SDK Reference → Security → Error Codes — the full enum of typed error codes.
- Roll back a bad bundle — what to do when a bundle gets past verification but breaks at runtime.
- Architecture — the boundaries between SDK code, native code, and backend code where errors propagate.
Maintained by
The Native Update team maintains native-update and this page.