npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@otaupdate/react-native

v2.0.0

Published

Over-the-air JS bundle updates for React Native — bare and Expo

Readme

@otaupdate/react-native

Over-the-air JS bundle updates for React Native. Ships the JS + assets of a release to installed apps without going through the App Store or Play Store.

Works in bare React Native and Expo (via a config plugin). Native code handles download, SHA-256 verification, unzip, bundle swap-in, and rollback-on-failure; JS handles the update check and the app-facing API.

What OTA can and cannot ship. Anything that lives in the JS bundle and its assets: screens, logic, images, styles. Nothing native: a new native module, a permission, an SDK version bump, or an Info.plist change still needs a store release. Shipping a JS bundle that calls a native API the installed binary doesn't have will crash the app — use targetBinaryVersion to keep those releases apart.


Install

npm install @otaupdate/react-native     # the SDK, in your app
npm install -g @otaupdate/cli           # the CLI, for publishing

Then connect the app. ota init runs once: it detects your stack, creates the project, writes ota.config.js and the ota:* release scripts, and prints one SDK key per channel (production and staging):

ota login
ota init

A key belongs to one channel and covers both platforms — the device reports its own platform on every check. Put the staging key in staging builds and the production key in store builds; the channel then travels with the binary, so a staging build can never pull production updates. The sections below are only about getting that key into the native build.

One version (1.0.8+) supports RN 0.7x and 0.80+ alike — no version pinning needed. (1.0.4–1.0.7 briefly split into two version lines while an @otaupdate/[email protected]-only mechanism was tried for a flash-free Android install; it depended on an internal RN API whose shape isn't stable across versions, so it was reverted in favor of the single restart-based mechanism every version below uses — see the Troubleshooting table.)

Bare React Native

iOS

cd ios && pod install

Then point React Native at the OTA bundle. In AppDelegate.swift (RN 0.77+):

import OtaUpdate

override func bundleURL() -> URL? {
#if DEBUG
  return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
  return OtaUpdate.bundleURL()      // <- was Bundle.main.url(forResource: "main", …)
#endif
}

Or in AppDelegate.mm (RN 0.76 and earlier):

#import <OtaUpdate/OtaUpdate.h>

- (NSURL *)getBundleURL {
#if DEBUG
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
  return [OtaUpdate bundleURL];     // <- was [[NSBundle mainBundle] URLForResource:…]
#endif
}

Add the channel key to ios/<YourApp>/Info.plist:

<key>OtaDeploymentKey</key>
<string>ota_live_xxxxxxxxxxxxxxxxxxxxxxxx</string>

That is the only required key — the SDK already knows which server to talk to. (OtaServerUrl exists for internal deployments and should stay unset.)

Android

Autolinking registers the module. Add the one override in android/app/src/main/java/.../MainApplication.kt:

import com.otaupdate.OtaUpdate

override val reactNativeHost: ReactNativeHost =
  object : DefaultReactNativeHost(this) {
    override fun getJSMainModuleName(): String = "index"

    override fun getJSBundleFile(): String? =
      OtaUpdate.getJSBundleFile(applicationContext)   // <- add this
    // …
  }

And the same channel key in android/app/src/main/res/values/strings.xml:

<string name="ota_deployment_key" translatable="false">ota_live_xxxxxxxxxxxxxxxxxxxxxxxx</string>

Android — newer (bridgeless) template. Some RN versions generate a MainApplication.kt with no ReactNativeHost/getJSBundleFile() to override at all — it builds reactHost directly via getDefaultReactHost(...). If yours looks like this, pass the bundle path as that call's jsBundleFilePath parameter instead of using an override:

import com.otaupdate.OtaUpdate

override val reactHost: ReactHost by lazy {
  getDefaultReactHost(
    context = applicationContext,
    jsBundleFilePath = OtaUpdate.getJSBundleFile(applicationContext),   // <- add this
    packageList =
      PackageList(this).packages.apply {
        // add(MyReactNativePackage())
      },
  )
}

The strings.xml configuration above is unchanged either way. Mandatory/resume installs on this template restart the process to apply the update (see Platform verification status) — same as the ReactNativeHost override above.

Expo

Add the config plugin to app.json. There is nothing to patch by hand — the plugin writes the configuration, and the bundle swap-in is wired up automatically (see how Expo differs below):

{
  "expo": {
    "plugins": [
      [
        "@otaupdate/react-native",
        { "deploymentKey": "ota_live_xxxxxxxxxxxxxxxxxxxxxxxx" }
      ]
    ]
  }
}

One key, both platforms. Building staging and production from the same config? Use app.config.js and read the key from an environment variable, so the build picks it rather than an edit somebody has to remember:

export default {
  expo: {
    plugins: [['@otaupdate/react-native', { deploymentKey: process.env.OTA_SDK_KEY }]],
  },
};

serverUrl (internal deployments) and channel (only for builds still carrying an older project-wide key) are the remaining options; iosDeploymentKey / androidDeploymentKey still work for apps that predate channel keys.

npx expo prebuild --clean     # or just let EAS Build run it

The native module does not exist in Expo Go. Use a development build (npx expo run:ios / eas build --profile development). In Expo Go the SDK logs a warning and no-ops instead of crashing.

How Expo differs

Expo apps are bridgeless: MainApplication builds a ReactHost straight from ExpoReactHostFactory and never exposes a ReactNativeHost, so there is no getJSBundleFile() to override. Instead this package registers a ReactNativeHostHandler — Expo's own extension point, the same one expo-updates uses — which Expo asks for the bundle path at startup. Expo autolinking discovers it; you don't wire anything up.

Two consequences worth knowing:

  • The config plugin does not modify MainApplication on Android. If you go looking for an edit there after prebuild, its absence is correct.
  • The handler returns null when developer support is on, so a debug build keeps loading from Metro and a downloaded bundle never shadows your live reload.

This package is therefore linked by both linkers, and needs to be: React Native autolinking registers OtaUpdatePackage (the native module JS calls), while Expo autolinking registers OtaUpdateExpoPackage (the bundle path). Expo normally drops a package from React Native autolinking when it is also an Expo module with its own Gradle file, which would leave NativeModules.OtaUpdate undefined — the shipped react-native.config.js opts out of that skip. Don't delete it.

Verified against Expo SDK 57 / React Native 0.86 with the new architecture, on both platforms.

Platform verification status

Android — verified end to end. On an Expo SDK 57 / RN 0.86 release build (new architecture, bridgeless) running on an emulator: publish → download → SHA-256 verify → bundle swap on restart, IMMEDIATE in-process reload, and rollback rescuing a device already on the bad build. Confirmed by screenshot, native log, and server-side install reports.

Bare RN Android specifically applies a mandatory/resume install by restarting the process — verified against a real bare RN 0.87 build on an emulator, logcat-traced end to end: publish → download → SHA-256 verify → install → confirmed healthy, including catching and fixing a real infinite-reload loop, a lost-update race, and a JSON null-coercion bug that silently corrupted persisted state, along the way (see the Troubleshooting table). An earlier attempt at a flash-free in-place swap (OtaUpdate.createReactHost(...), shipped briefly as 1.0.5/1.0.7) was reverted: it depended on constructing ReactHostImpl directly against an @UnstableReactNativeAPI interface whose shape had already changed once between RN releases, which would have meant either two permanently-diverging SDK versions or a build that silently breaks on some RN version. The process-restart mechanism only uses stable, decades-old Android APIs, so one version safely covers every supported RN release instead.

iOS — verified end to end. On an Expo SDK 57 / RN 0.86 Release build (Xcode 26, iPhone 17 Pro simulator, iOS 26.5): publish → download → SHA-256 verify → bundle swap on restart, IMMEDIATE in-process reload, and rollback restoring the earlier bundle byte-for-byte. Confirmed by screenshot, package hash and server-side install reports.

One library fix came out of that first real build: the podspec now sets DEFINES_MODULE = YES. React Native and Expo build pods as static libraries, and an Objective-C static library is not importable as a Swift module unless CocoaPods emits a module map — without it every Expo app with a Swift AppDelegate failed to compile with no such module 'OtaUpdate', even though the pod linked correctly and the Objective-C itself was fine.


Use it

The one-liner — checks on app start and on every foreground, installs on next restart:

import { withOtaUpdate } from '@otaupdate/react-native';

function App() {
  return <YourApp />;
}

export default withOtaUpdate(App);

That is enough for most apps. withOtaUpdate also calls notifyAppReady() on mount, which is what arms rollback protection — see below.

Mandatory releases never show the build they replace

An update check runs in JS, so the outgoing bundle has to boot before it can discover it is stale. Render the app first and the user reads the old version for a second and is then yanked into the new one — the single most common complaint about CodePush-style updates.

withOtaUpdate holds the first render while a mandatory release installs, so the build being replaced is never drawn. A cold start goes splash → new build.

Optional releases are untouched. The gate opens the moment the server says "not mandatory", which is before a single byte is downloaded; the update then fetches in the background and applies on the next restart exactly as it always has. Nothing blocks, and nothing about that path changed.

export default withOtaUpdate(App, {
  fallback: <Splash />,                 // shown only during a mandatory install
  mandatory: {
    holdStartup: true,                  // default
    verdictTimeoutMs: 2000,             // give up waiting to *hear back* and render
    maxHoldMs: 15000,                   // absolute ceiling, covers a slow download
  },
});

Both timeouts exist so a device that is offline, or a server that never answers, boots normally instead of sitting on a splash. holdStartup: false restores the old render-then-swap behaviour.

Already own a native splash? Leave fallback unset and keep yours up instead:

import { startupGate } from '@otaupdate/react-native';

await startupGate();                 // resolves as soon as it is safe to draw
await BootSplash.hide({ fade: true });

Going further: settle it before JS starts at all

The gate above stops the old build being drawn, but it still boots — you pay React Native's cold start before the check can run. Opting into the native pre-boot check removes even that: the check happens in getJSBundleFile() / bundleURL(), before there is a React instance, and a mandatory release is downloaded and handed to React Native as the bundle it starts with. The outgoing bundle never runs, there is no reload, and the launch screen simply stays up until the new build is ready.

| | Android (strings.xml) | iOS (Info.plist) | |---|---|---| | Enable | ota_blocking_startup = true | OtaBlockingStartup = <true/> | | Budget | ota_blocking_startup_timeout_ms | OtaBlockingStartupTimeoutMs |

With the Expo plugin: { "blockingStartup": true, "blockingStartupTimeoutMs": 2500 }.

It is off by default, and the reason is the trade it makes: cold start now waits on a network round trip. Past the budget (default 2500ms) the launch continues on the current bundle and a late download is left queued for the next start — the same ending as ON_NEXT_RESTART, which is a far better failure than holding a splash indefinitely. Optional releases are ignored on this path entirely; blocking app start on an update the user was never required to take would be a worse bargain than the flash it removes.

Rollback protection is unchanged: a pre-boot install goes through the same pending → promote → confirm state machine as any other, so a bundle that crashes before notifyAppReady() is still reverted and blacklisted on the next launch — which is also what stops a bad mandatory release from boot-looping.

Manual control

import OtaUpdate, { InstallMode, SyncStatus } from '@otaupdate/react-native';

const status = await OtaUpdate.sync({
  installMode: InstallMode.ON_NEXT_RESTART,
  mandatoryInstallMode: InstallMode.IMMEDIATE,
  onSyncStatusChange: (s) => console.log(SyncStatus[s]),
  onDownloadProgress: ({ receivedBytes, totalBytes }) =>
    console.log(`${Math.round((receivedBytes / totalBytes) * 100)}%`),
  // Ask before installing. Mandatory releases ignore this.
  shouldInstall: (update) => confirmWithUser(update.description),
});

Update UI with the hook

import { useOtaUpdate, SyncStatus } from '@otaupdate/react-native';

function UpdateBanner() {
  const { available, progress, isSyncing, check, update } = useOtaUpdate();

  useEffect(() => { void check(); }, []);
  if (!available) return null;

  return (
    <View>
      <Text>Version {available.label} is available</Text>
      {available.description ? <Text>{available.description}</Text> : null}
      {progress ? (
        <Text>{Math.round((progress.receivedBytes / progress.totalBytes) * 100)}%</Text>
      ) : null}
      <Button title="Update" onPress={() => update()} disabled={isSyncing} />
    </View>
  );
}

Rollback protection — the part not to skip

An update that crashes on boot would be unrecoverable without this, so the SDK treats every freshly installed bundle as unproven:

  1. install() marks the package pending.
  2. The next launch boots into it and flags it as loading.
  3. notifyAppReady() confirms it. From then on it is permanent.
  4. If the app launches again while the package is still loading — i.e. the last run never reached notifyAppReady() — the native side reverts to the previous bundle, blacklists the bad hash, and reports rolled_back to the server so it shows up in the dashboard.

withOtaUpdate and startAutoSync call notifyAppReady() for you. If you wire things up yourself, you must call it, after your critical startup path:

import { notifyAppReady } from '@otaupdate/react-native';

useEffect(() => {
  void notifyAppReady();
}, []);

Call it too early and a crash a second later still counts as healthy; call it too late (or never) and every update rolls itself back.


Install modes

| Mode | When the new bundle loads | |---|---| | ON_NEXT_RESTART (default) | The next time the user cold-starts the app. Never interrupts. | | ON_NEXT_RESUME | The next foreground, after minimumBackgroundDuration seconds in the background. | | IMMEDIATE | Right away, with a JS reload. Reserved for mandatory fixes — it interrupts whatever the user was doing. |


API

| Export | Purpose | |---|---| | sync(options) | Check → download → install. Concurrent calls share one run. | | checkForUpdate() | Just the check. Returns { update, reason }. | | notifyAppReady() | Confirm the running bundle (see rollback above). | | restartApp(onlyIfPending?) | Reload the JS bundle. | | getCurrentPackage() | Running label, hash, pending/first-run flags. | | clearUpdates() | Wipe all downloads, revert to the binary bundle. | | startAutoSync(options) | The app-start/resume loop withOtaUpdate uses. | | withOtaUpdate(App, options) | Root-component HOC. | | useOtaUpdate(options) | Hook for update UI. | | startupGate() | Resolves when it is safe to draw — i.e. no mandatory update is mid-install. | | isStartupGateOpen() / onStartupGateChange(cb) | The same signal, synchronously and as a subscription. | | isNativeModuleAvailable | false in Expo Go and on web. |

getConfig() returns the resolved configuration (key, server, app version, channel, platform, running label) and OtaApiError is what server-side failures throw.

checkForUpdate() returns a reason when there is no update:

| Reason | Meaning | |---|---| | up_to_date | Already on the newest applicable release. | | not_in_rollout | A newer release exists, but this device is outside its rollout. | | no_release_for_runtime | No release on this channel targets this native app version. | | platform_disabled | This platform is switched off for the project. | | unknown_channel | The key resolved, but the channel it names no longer exists. |


Publishing

From the project root, with the CLI. ota init wrote these as npm scripts, so this is usually all anyone types:

npm run ota:staging                                  # ota release --channel staging
npm run ota:prod                                     # ota release --channel production
ota release --channel production --rollout 20 -m "Fix checkout crash"
ota promote --from staging --to production           # same bytes, no rebuild
ota rollback --channel production                    # rescue devices already updated
ota status                                           # what each release is doing
ota doctor                                           # why isn't my update arriving?

Nothing is positional: the project comes from ota.config.js, the runtime version from your app version, and the release note and commit from git. The runtime version is the native app version the bundle is compatible with — devices on other binary versions never receive the release, which is the most common cause of "I published but nothing happened".


Troubleshooting

| Symptom | Cause | |---|---| | The native module … is not available | Native rebuild missing (pod install / Gradle), or running in Expo Go. A Metro reload is not enough. | | Update downloads but never applies (bare RN) | getJSBundleFile / bundleURL override missing — the app keeps loading the bundle from the binary. | | Update downloads but never applies (Expo) | The ReactNativeHostHandler was not autolinked. Re-run npx expo prebuild --clean; check android/app/build/generated/autolinking mentions OtaUpdateExpoPackage. | | Download fails while the check succeeds | The presigned URL points at a host the device can't reach. Set S3_PUBLIC_ENDPOINT (and PUBLIC_API_URL) to a LAN IP or public hostname, not localhost. | | CLEARTEXT communication not permitted | Android blocks plain HTTP from API 28. Use HTTPS, or usesCleartextTraffic for local testing only. | | Every update rolls back | notifyAppReady() is never reached — usually an early crash, or the call sits behind a screen the user has to navigate to. | | no_release_for_runtime | The release's runtime version does not cover the installed native version. | | Works in debug, not release | Debug builds load from Metro; the OTA path only runs in release builds. | | The old build is visible for a second before a mandatory update swaps in | Fixed in 2.0.0. withOtaUpdate rendered the app immediately and started the check afterwards, so the outgoing build was on screen for the whole check-and-download. It now holds the first render until a mandatory release has installed (optional releases are unaffected). Apps that drive sync() by hand should await startupGate(), or do the check before rendering. For a cold start with no flash at all, also enable the native pre-boot check. | | Android: mandatory update reload-loops (bare RN) | Fixed in 1.0.2. Before that, IMMEDIATE/ON_NEXT_RESUME tried an in-place JS reload that reused a bundle path fixed at process start, so the same mandatory update kept re-triggering forever. 1.0.2 does a real process restart instead — update to it. | | A newer update never shows up, app stays on an older-than-expected release | Fixed in 1.0.3. notifyApplicationReady() used to clear any pending hash unconditionally — if a second update was downloaded and queued for a future restart while the first one was still being confirmed, its pending state was silently wiped and the device never advanced to it. | | Android: old screen briefly visible during a mandatory/resume install | Improved in 1.0.3. The process-restart fix in 1.0.2 is correct but showed Android's default activity-transition animation (old screen sliding/fading out). 1.0.3 suppresses it via overridePendingTransition(0, 0) so the switch is instant. | | Android: a normal (non-mandatory) update applies once, then randomly reverts to an older release and re-downloads | Fixed in 1.0.6 (and every version since). org.json's optString(key) returns the literal string "null" (not Kotlin null) when a saved field was JSON null — every time currentHash/pendingHash was genuinely unset and then reloaded from disk, it silently became the four-character string "null" instead of real null, which then got treated as a real (but non-existent) package and fell back to the binary bundle. This was present since the SDK's first release; iOS was never affected (its store type-checks instead of string-coercing). | | Android: a release published with no description shows the literal text "null" | Fixed in 1.0.6 (and every version since). Same org.json optString() behavior as above, applied to the nullable description field. |