On iOS, every page of native executable code in a process must come from a signed image that shipped with the app. You cannot download a dylib and load it; the failure happens at the kernel, before any policy discussion starts. And yet CodePush, Expo's EAS Update, Shorebird, and Patch all ship code updates to installed iOS apps every day, openly, without an App Store release.
They all do it with the same one trick. This post is about that trick — what it looks like in each system, what each can and can't update, and where the hard limits are.
The short version: every OTA update system on iOS ships an interpreter inside the signed app binary, then downloads data for it to run — a JavaScript bundle (CodePush, EAS Update), a Dart patch (Shorebird), or a WebAssembly module (Patch). The signed native code never changes; only the interpreted payload does.
Why iOS apps can't download native code (code signing, no JIT)
Three facts decide the entire design space:
Code signing is enforced at the kernel. Native executable pages must come from signed images in the bundle or the OS.
dlopenon a downloaded dylib fails before it's a rules question.Apps get no JIT. Third-party apps can't create memory that is both writable and executable, so they can't generate machine code at runtime. (The exception proves the rule: browser engines need a special Apple entitlement for it — Safari's everywhere, and since iOS 17.4, authorized alternative browser engines in the EU under BrowserEngineKit.) This is why every JavaScript engine running inside an app's own process — JavaScriptCore via
JSContext, Hermes — runs in interpreter mode.Policy mirrors the mechanism. App Review Guideline 2.5.2:
"Apps should be self-contained in their bundles, and may not read or write data outside the designated container area, nor may they download, install, or execute code which introduces or changes features or functionality of the app, including other apps…"
The carve-out lives in Apple's Developer Program License Agreement: interpreted code may be downloaded and run, under conditions. The rules, their history, and the 2017 enforcement that shaped them deserve their own write-up — here it is. Staying inside them is the developer's responsibility, whatever tool is involved.
Put those together and one shape survives: an interpreter in the signed binary, downloaded data on top. The differences between OTA systems are which language sits above the interpreter line, and how much of the app can live up there.
CodePush and EAS Update: swap the JavaScript bundle
React Native made OTA almost free, architecturally. An RN app is already a native shell (the RN runtime, native modules, a JS engine) plus a JavaScript bundle loaded at startup. The bundle was always just data — so host newer bundles on a server, check at launch, download, verify, load. That's CodePush (Microsoft, 2015 — retired with App Center on March 31, 2025), and it's EAS Update today: the same idea done thoroughly, with channels, staged rollouts, code-signed updates, and an openly specified protocol (the Expo Updates protocol, implemented by the expo-updates library).
The detail worth understanding is runtime version gating. A downloaded bundle can only call native modules that already exist in the installed binary — add a new native dependency and an old binary would crash calling it. So every update declares a compatible runtime version, and the server only serves updates to binaries that match.
What it can't do: anything outside JavaScript. Native modules, UI primitives, the navigation shell — frozen until the next store release. And if the app isn't React Native, none of this machinery exists for it.
Shorebird: the same trick for Flutter
Flutter looked impossible: Dart compiles ahead-of-time to native machine code, which fact #1 says you can't replace. Shorebird's answer is a modified Dart runtime that can fall back to interpretation (its docs explain the design): tooling diffs the new code against what shipped, unchanged code keeps running as the original AOT machine code, and changed code runs in an interpreter. Only the functions that actually changed pay the interpreter cost — and the architecture is the same one: an interpreter in the signed binary, a downloaded payload on top. What it can't do mirrors React Native's boundary: native plugins and platform code are frozen until a store release.
Server-driven UI: the no-code dodge
Worth naming because it's the right answer for some teams: don't ship code at all. Ship components in the binary, then send JSON describing which components to render in what order — the Airbnb/banking-app pattern. It's configuration rather than code, so the policy questions mostly evaporate; the ceiling is that you can only express what the pre-shipped components already do. If the real need is "rearrange screens weekly," server-driven UI is honestly the simpler tool. If the need is "fix the bug in the discount calculation," it does nothing. (Even here the shape rhymes: the component renderer is the interpreter, and the JSON is its program.)
Why Apple banned JSPatch — and what it taught everyone
One approach got an entire category banned. JSPatch (and commercial cousins like Rollout.io) downloaded JavaScript that reached into the Objective-C runtime and redefined arbitrary native methods of the live app. Wildly popular around 2016 — and in March 2017 Apple mass-emailed developers and began rejecting apps that contained it.
The distinction Apple drew is instructive: JavaScript running an app's logic inside a sandboxed interpreter kept operating publicly (CodePush ran through the purge and after it). Downloaded instructions that rewrite the behavior of reviewed native code died in a week — partly review-integrity, partly security, since a man-in-the-middle on a patch channel got something close to remote code execution on every install (FireEye had documented exactly that risk a year earlier).
The lesson isn't "Apple hates OTA." It's that the interpreter line is load-bearing — and that what the payload is allowed to touch matters as much as how it executes.
Native Swift: Patch
An app written in Swift and SwiftUI has no JS bundle and no Dart VM — Swift is AOT-compiled like Dart. Patch applies the same architecture to it, with WebAssembly as the interpreted layer. Its CLI analyzes a Swift module and partitions it: code that's safe to ship as an update (logic, model types, async/await, opt-in SwiftUI views) versus code that must stay native — per Patch's documentation, anything touching an OS framework, or anything the analysis can't prove safe, stays in the binary. The eligible changed code is compiled with the swift.org compiler to a WebAssembly module, and the on-device SDK downloads it over TLS, hash-verifies it, and runs it in WasmKit, a WebAssembly interpreter written in Swift. No JIT, which is exactly the constraint iOS imposes.
SwiftUI is the boundary case: a view's body is a declarative description, so it can run in WASM while the resulting view tree is handed to native SwiftUI to render — Patch claims roughly 98% of SwiftUI view elements render this way. Raw UIKit has no such seam, so it stays native. And the failure story mirrors EAS's runtime versioning: updates are gated on a fingerprint of the native shell, and if an active module traps or fails to verify, the SDK walks a fallback chain — current → previous → bundled native code.
What it can't do: this is not "the whole app, OTA" — and the eligible fraction is smallest here of the three ecosystems, since a native Swift app has proportionally more OS-framework code than a React Native app has native modules. A pure interpreter also runs code one to two orders of magnitude slower than native: irrelevant for event-driven logic, disqualifying for per-frame work, which is part of why per-frame work stays native everywhere in this category.
CodePush vs EAS Update vs Shorebird vs Patch: what each can update
| System | Payload | Interpreter in the binary | Compatibility gate | Can never update |
|---|---|---|---|---|
| CodePush / EAS Update | JS bundle / Hermes bytecode | JavaScriptCore / Hermes | runtime version | native modules |
| Shorebird | Dart patch | modified Dart runtime | release version | native plugins |
| Server-driven UI | JSON layout | (your component renderer) | app version | any new behavior |
| Patch | WebAssembly module | WasmKit | native-shell fingerprint | OS-API-touching code |
One system, four payloads: a signed binary with an interpreter inside; a downloaded, verified, versioned payload on top; a gate that keeps old binaries from receiving updates they can't run; and a hard boundary around what the payload may touch.
OTA on iOS isn't a hack that happens to work — it's an architectural pattern built on the interpreted-code allowance in Apple's own rules. Those rules come with conditions, the conditions bind the developer rather than the tooling, and App Review always has the final word. The engineering, in every ecosystem, is in how much of the app can be lifted above the interpreter line — and how safely.
Related: What Apple actually allows · Compiling Swift to WebAssembly