← All posts

Are React-Native OTA Updates Allowed on iOS?

TL;DR: yes, OTA JS updates are allowed under App Store Guideline, within limits. Here's the history and the line you can't cross.

What is an OTA update

Hot patching is standard practice in mobile development.

The concept behind this practice is to fetch code "over-the-air" while the app is running, in order to change screens and logic dynamically without going through the tedious process of resubmitting the app to the stores.

Two layers of code

Think of your browser: Google Chrome, for example. It embeds a huge amount of functionality: the TLS/SSL handshake, the network stack, a JavaScript engine, a rendering engine… and at runtime it dynamically fetches content and code from remote servers to run.

OTA updates work exactly the same way. Your native build ships all the native capabilities, and it fetches the JavaScript that orchestrates them from a remote server.

The browser analogy goes further and this is where it gets interesting.

A browser is made of two parts.
There's the native engine: Chrome itself, written in C++, shipped and controlled by Google. It's the powerful, sensitive layer: it runs the TLS handshake, opens sockets, reads and writes files, talks to the OS. And on top of it runs the JavaScript of the websites you visit, untrusted third-party code that can only do what the engine's sandbox allows.

Notice who controls what. You don't define Chrome's native capabilities; Google does. A website can't add a native permission or reach your filesystem; it only gets the sandbox the browser hands it.

Your app has the exact same two layers with one key difference: you control both.

• The native build is your privileged layer. It's the security and privacy-sensitive code: it's what requests access to the camera, location, contacts or photos, intercepts network traffic, and calls the OS APIs.
You define it and Apple reviews it before it ships. Once submitted, it's frozen: changing it means a new build and a new review.

• The JavaScript bundle is your sandboxed layer: the equivalent of a website's code. It drives your screens and your logic, but it can only use the native capabilities the build already exposes. It can't add a permission, can't reach a native or private API that wasn't compiled in. It orchestrates the native layer.

The day the line was drawn (March 2017)

If you've heard that Apple "banned hot patching," this is the event people mean and it's the source of a myth that still scares developers away from OTA today.

In March 2017, Apple sent a wave of rejection warnings to hundreds of apps. The target was a class of SDKs most notably Rollout.io and JSPatch that offered "hot code push" for native apps.

The mechanism

To understand why Apple reacted, you have to see the mechanism.

Objective-C is a deeply dynamic runtime. Method calls are not resolved at compile time. They are dispatched by name at runtime through objc_msgSend, and the mapping from a selector (a method name) to its implementation can be rewritten while the app is running.

That last part is the problem. You can take any method on any class and swap its implementation for another one:

Method original    = class_getInstanceMethod(cls, @selector(viewDidLoad));
Method replacement = class_getInstanceMethod(cls, @selector(patched_viewDidLoad));
method_exchangeImplementations(original, replacement);

Nothing here was linked at build time. Nothing was declared in Info.plist. Nothing was reviewed. A remote string reached straight into a private API and pulled data the app was never authorized to touch.

That is the point: this was not "updating JavaScript." It was a remote control over the entire native runtime, delivered after review. An app could ship clean, pass review, then quietly become something else.

What Apple actually said

The rejection letters cited two documents:

  • App Store Review Guideline 2.5.2
  • section 3.3.2 of the Developer Program License Agreement

Read on its own, 2.5.2 sounds like a flat ban: apps must be self-contained in their bundle, must not read or write outside their container, and must not download, install or execute code that introduces or changes the app's features or functionality. It carries exactly one exception, for educational apps that let students write and run code, and that exception is narrow enough to be irrelevant to everyone else.

The carve-out that matters is somewhere else entirely: in the license agreement, the contract every developer signs. At the time of the JSPatch wave, 3.3.2 allowed downloaded scripts only if they were run by WebKit or JavaScriptCore, and only if they didn't change the app's primary purpose into something inconsistent with what was submitted to the App Store.

Worth being precise here, because this is where most write-ups on the subject go wrong: the guideline prohibits, the agreement permits under conditions. They're two different documents with two different functions, and quoting one without the other produces either "Apple bans OTA" or "Apple explicitly allows OTA." Neither is the rule.

What changed in June 2017

Three months after the rejection wave, Apple rewrote the clause.

The engine restriction disappeared. The rule stopped naming WebKit and JavaScriptCore, and became a set of conditions instead. Interpreted code may be downloaded into an app, provided it:

  • doesn't change the primary purpose of the app by adding features or functionality inconsistent with what was submitted and advertised;
  • doesn't create a store or storefront for other code or apps;
  • doesn't bypass the signing, sandbox, or other security features of the OS.

Since June 2022 the clause lives at 3.3.1(B) rather than 3.3.2, and Apple revised its wording again in October 2025: reordering the conditions and scoping the storefront rule to App Store distribution. The carve-out itself has been stable for nine years.

The ordering matters more than the text. Apple demonstrated it would enforce the line, then widened the language around it. If the line had been "downloading code is forbidden," the rewrite would have gone the other way. It didn't. The line was never about whether you download code. It was about what the downloaded code is allowed to touch.

JSPatch failed all three conditions at once. It reached private APIs, it changed what the app did after review, and it did so by rewriting the native runtime.

The capability ceiling

Go back to the two layers.

A JavaScript bundle in a React Native app talks to native code through one door: the modules compiled into the binary. Turbo Modules, native modules, view managers, all of them are registered at build time, in code Apple reviewed. JavaScript calls into them by name. If the name isn't registered, the call fails.

There is no objc_msgSend on the other side of that door. No dlopen. No selector table to rewrite. React Native's bridge doesn't hand the JS runtime any reflective access to the Objective-C runtime, so JSPatch's entire mechanism has no equivalent. It isn't blocked by a policy check. There's nothing to call.

The capability boundary is enforced in three places, all frozen at build time:

Permissions. Camera, location, contacts, photos: each requires a usage description string in Info.plist and, at runtime, a native API that Apple compiled into your binary. A bundle can ask for a permission the build already declares. It cannot declare one.

Entitlements. Push, HealthKit, keychain sharing, associated domains: signed into the app at build time, validated by the OS at launch. Outside JavaScript's reach entirely.

Native modules. Want to add Bluetooth? That's a new native dependency, a new binary, a new review. This is the part developers experience as a limitation, and it's the same property that makes the mechanism acceptable to Apple.

Where the ceiling is enforced

The native/JS split isn't just conceptual. It's versioned.

Every update payload is tied to a runtime version. Change a native dependency, an entitlement, a permission string, and the runtime version changes with it. Updates built against the new runtime are not served to binaries running the old one. A build only ever receives bundles its native layer can actually execute.

That's usually described as a compatibility mechanism, and it is. It's also where the ceiling is applied. The case where a bundle would need capabilities the reviewed binary doesn't have isn't caught at runtime by some check. It never reaches the device, because the two artifacts are versioned separately and matched before delivery.

That ceiling is real. It's also narrower than most people assume: it constrains what the bundle can reach, not what you can do with what's already there.

What the ceiling doesn't cover

Go back to the browser analogy and the difference we flagged: you control both layers. Google decides what Chrome's engine exposes to a website. In your app, you decide what the binary exposes to the bundle. That's the property that makes OTA useful. It's also the loophole.

You can rebuild the door

Nothing in React Native prevents you from writing a native module that takes a method name as a string and invokes it:

SEL selector = NSSelectorFromString(methodName);
[target performSelector:selector withObject:argument];

Compile that in, expose it to JavaScript, and you've reimplemented JSPatch inside React Native. The bundle is now unbounded, not because the framework let it through, but because you shipped the unboundedness yourself.

The JS layer is sandboxed by what you compiled, not by React Native. Same for anything that evaluates remote code in a WebView with a bridge back into native, or embeds a scripting runtime with a generic native call surface. The 2017 line isn't about Objective-C or about JavaScript. It's about whether a remote payload can reach arbitrary native behaviour, and that's your architectural decision.

You can over-provision the build

Declare NSContactsUsageDescription, NSLocationAlwaysUsageDescription, link a payments SDK, register the native modules for all of it, then ship a bundle that uses none of them. Review sees an app that does none of those things. Nothing about the submission is false; the capabilities are simply dormant.

Then push a bundle that turns them on.

No native change, no new permission, no new entitlement. The capability was in the reviewed binary the whole time. The freeze on the native layer protects against a build that grows new powers. It does nothing about a build that was over-powered on purpose from day one.

Most of the rulebook isn't about capabilities at all

This is the part that gets missed. The interpreted-code clause is one paragraph in a document that governs behaviour, not just permissions. A JS-only update can violate:

  • Payments (3.1.1). A new checkout screen taking card payments for digital content, bypassing In-App Purchase. Pure UI and networking. Every capability involved was already there.
  • Privacy. Your privacy label and privacy manifest describe what the app collects, declared at submission. Start harvesting contacts or precise location that you previously requested but barely used, and the declaration is now false.
  • Storefront. Serve a catalogue of JS "mini-apps" or plugins and you're distributing code for others to run. That condition is explicit in the agreement, and it's where the current enforcement wave against generated-app products is landing.
  • Primary purpose. The one condition in the clause that is intent rather than architecture. A guide app that becomes a casino is a JS-only change. Your build can't gain new powers; it can absolutely be pointed at an entirely different product.
  • Content and age rating. Ratings are declared per submission. Remote content that outgrows them is a violation nobody's binary prevents.

Same clause, opposite outcomes

Which brings us back to the myth.

Apple never banned hot patching. It drew a line through the middle of it, and then, three months later, made that line engine-agnostic. Run both mechanisms against the three conditions and they land on opposite sides:

Rollout and JSPatch. Rewrote the Objective-C runtime from a remote string. Reached private APIs the binary never linked and the review never saw. Changed what the app did after approval. Three conditions, three failures, and the third one, bypassing the sandbox, is the one no amount of good intentions could have fixed. The mechanism was the violation.

React Native and Expo updates. The payload is JavaScript executed against a module surface that was compiled in, reviewed, and signed. It can't add a permission, can't reach an API that isn't linked, can't touch the binary's signature. It isn't a store for other apps. The primary purpose is whatever you decide to build, exactly as it is with a native release.

Same clause. One mechanism fails it structurally; the other doesn't engage it at all.

That's the honest version, and it's more useful than "OTA is allowed." The framework guarantees your bundle can't exceed the capabilities of the reviewed binary. It guarantees nothing beyond that. What those capabilities get used for, who is allowed to publish an update, what the app becomes six months in: those are choices, not properties of the technology.

The trust boundary was never the JavaScript sandbox. It's you.