I wanted an over-the-air update system that felt boring in the right places: take the output of npx expo export, put the bundle and assets somewhere durable, and answer the same protocol that expo-updates already knows how to speak.
The useful thing about Expo updates is that the client side is already solved. A production build with expo-updates installed can ask a server whether a newer update exists. If the server responds with the right manifest, the app downloads the JavaScript bundle and assets. On the next launch, that update becomes the app.
That means the hard part is not inventing a new client. The hard part is respecting the protocol closely enough that the existing client does not know or care that the update server is mine.
So I built a self-hosted Expo OTA server on Cloudflare Workers and R2. The Worker handles manifests, asset downloads, publishing, update listing, and deletion. R2 stores the actual files. A local publish script turns an Expo export into the manifest format the app expects, uploads every file, and leaves the mobile app pointing at a stable endpoint.
The result is not a replacement for every managed release workflow. It is a narrow system with a specific purpose: ship update bundles for many Expo apps without coupling each app to a managed update project.
Why I wanted this
I keep ending up with a lot of small mobile apps.
Some are experiments. Some are internal tools. Some are serious enough to submit to TestFlight. The common pattern is that I want the app binary to be stable while the React Native layer can move quickly.
Managed update services are good when their constraints match the project. The constraint for me was not that I needed a cheaper clone of that. The constraint was shape.
I wanted one update server that could serve many apps by slug:
/api/manifest?slug=demo-app
/api/manifest?slug=utility-app
/api/manifest?slug=internal-tool
Each app has its own update URL in app.json, but the infrastructure is the same. The separation happens in storage:
<app-slug>/<platform>/<runtime-version>/manifest.json
<app-slug>/<platform>/<runtime-version>/_expo/static/js/ios/index-<hash>.hbc
<app-slug>/<platform>/<runtime-version>/assets/<hash>
That storage shape is the product idea. The app slug separates products. The platform separates iOS from Android. The runtime version separates compatible binaries. Everything underneath it is the update payload.
Cloudflare Workers and R2 are a good fit for that. The Worker is a small HTTP protocol adapter. R2 is cheap object storage for bundle files, images, fonts, and manifests. There is no always-on server to babysit.
Expo owns the Metro export, bundle metadata, and asset list for the compatible runtime.
The boundary is small. It does not build the app binary, replace TestFlight, or decide whether native code changed. It only serves JavaScript updates that are compatible with an already-installed native runtime.
The runtime contract
The most important field in this system is runtimeVersion.
An OTA update can only work when the JavaScript bundle expects the same native runtime that the installed binary provides. If the update starts calling a native module that the binary does not have, the update is invalid no matter how cleanly the server delivered it.
That is why the app configuration uses runtimeVersion deliberately. In the setup I used, the app can derive it from the app version:
{
"expo": {
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"enabled": true,
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0,
"url": "https://expo-updates-server.example.workers.dev/api/manifest?slug=demo-app"
}
}
}
That means a binary at version 1.0.3 asks for updates under runtime version 1.0.3. The Worker reads the incoming expo-runtime-version header and looks for:
demo-app/ios/1.0.3/manifest.json
If that object exists, the Worker can return the manifest. If it does not, the Worker returns a no-update directive. If the client already has the update ID in the manifest, the Worker also returns no update.
Compatibility is not guessed from the latest file in a bucket. It is encoded into the path. A 1.0.3 binary does not accidentally receive a 1.0.4 update unless I publish it under the same runtime version.
That also makes rollback understandable. If a bad update exists under a given app, platform, and runtime version, I can delete that prefix. The next check returns no update, and a fresh install falls back to the embedded bundle.
Speaking Expo Updates Protocol v1
The Worker has one central job: when expo-updates asks for a manifest, answer in the protocol format it expects.
For protocol v1, the response is multipart/mixed, not just a plain JSON object. The successful response contains a manifest part and an extensions part. The no-update response contains a directive part with:
{
"type": "noUpdateAvailable"
}
The Worker sets protocol headers like expo-protocol-version, expo-sfv-version, expo-manifest-filters, and expo-server-defined-headers. It keeps manifest responses private because they depend on app state, runtime version, platform, and current update ID.
The manifest itself is the map the client uses to download the update:
{
"id": "00000000-0000-4000-8000-000000000000",
"createdAt": "2026-02-17T15:35:00.000Z",
"runtimeVersion": "1.0.3",
"launchAsset": {
"hash": "sha256-base64url",
"key": "md5-hex",
"contentType": "application/octet-stream",
"fileExtension": ".bundle",
"url": "https://example.workers.dev/api/assets/_expo%2Fstatic%2Fjs%2Fios%2Findex.hbc?slug=demo-app&platform=ios&runtimeVersion=1.0.3"
},
"assets": []
}
The launchAsset is the main bundle. For an Expo app using Hermes, that file is Hermes bytecode, usually a .hbc file generated inside the _expo/static/js/... export path. The rest of the assets are images, fonts, and other files from the export output.
Each asset has two identifiers. The hash is a SHA-256 base64url hash of the file contents. The key is an MD5 hex hash. The client uses those values for local caching and identity. The URL points back to the Worker asset endpoint, with the slug, platform, and runtime version included so the Worker can find the right R2 object.
There is not much magic in the Worker. It reads headers, computes an R2 key, loads manifest.json, compares expo-current-update-id, and returns either a manifest response or a no-update directive.
Publishing an update
The publish path starts with Expo, not with a custom compiler. I do not manually run hermesc. I let npx expo export create the update output because Metro and Expo already know which Hermes bytecode format the app binary expects.
The flow is:
npx expo export --platform ios --output-dir dist-ota
node scripts/publish-update.js \
--server "https://expo-updates-server.example.workers.dev" \
--slug "demo-app" \
--platform "ios" \
--runtime-version "1.0.3" \
--export-dir "dist-ota" \
--auth-token "$EXPO_UPDATES_AUTH_TOKEN"
The script reads metadata.json from the export directory. That file tells it where the platform bundle lives and which assets belong to the export. Then it hashes the bundle, detects whether it is Hermes bytecode, and builds the launchAsset entry.
For every asset, the script computes the SHA-256 and MD5 hashes, picks a content type from the extension, and builds an asset URL back to the Worker. It also includes the exported Expo config in extra.expoClient when available.
Then it creates a FormData body:
manifest, containing the protocol manifest as JSONfile_<bundle-path>, containing the Hermes bundlefile_<asset-path>, one entry for each exported asset
The Worker receives that multipart upload at:
POST /api/publish/:appSlug
It reads the expo-platform and expo-runtime-version headers, stores the manifest at the stable manifest key, and stores every file under its original export path. That original path matters because the URLs in the manifest point at those paths.
The storage model stays deliberately plain:
demo-app/
ios/
1.0.3/
manifest.json
_expo/static/js/ios/index-<content-hash>.hbc
assets/<asset-hash-a>
assets/<asset-hash-b>
There is no database in the middle. R2 is the database. The manifest object is the latest update for that app, platform, and runtime version.
Hermes was the sharp edge
The easiest way to break this kind of system is to treat the bundle as just another JavaScript file.
In a Hermes production app, the update payload is not ordinary source text. It is Hermes bytecode. That bytecode has to be compatible with the Hermes VM inside the app binary. If I compile it with the wrong toolchain, the server can still return a valid manifest, the app can still download the file, and then the app can crash when it tries to load the update.
That is why the publish script trusts the export output. If expo export emits .hbc, the script uploads that .hbc. It logs the bundle path, size, type, and hash.
This is also why I treat runtimeVersion as a release boundary. If the native app changes, that is a new runtime. If the JavaScript uses a native capability that was not in the old binary, that is not an OTA update. That needs a new build.
The operational rule is simple:
OTA updates are for JavaScript and assets that the existing binary can already run.
That rule prevents a lot of imagined flexibility from turning into real crashes.
Asset caching
Manifests should be fresh. Assets should be cached hard.
The Worker returns assets from R2 with:
cache-control: public, max-age=31536000, immutable
That works because exported asset names and bundle paths are content-addressed enough for this use case. The manifest changes when a new update is published, and the asset URLs include the app slug, platform, runtime version, and original export path. Once a client asks for a specific bundle or image, that object should never mutate under the same URL.
This is a good fit for Cloudflare. The Worker does the dynamic decision on the manifest request. The heavier files are served like static objects.
The manifest endpoint decides freshness while asset URLs stay cache-friendly and immutable.
That split keeps the server understandable. The manifest endpoint is stateful in the sense that it decides whether an update exists. The asset endpoint is almost static file serving.
Auth and the publishing surface
The public app has to read manifests and assets. The publish endpoint is different. It changes what every installed app may download next.
The publish script sends:
Authorization: Bearer <token>
The Worker has the publish and delete operations behind that bearer-token path. In a production version, that cannot stop at checking that the header starts with Bearer . It should compare the token to a Cloudflare Workers secret, reject mismatches, and keep the secret out of source control. The same applies to delete, because deleting a runtime prefix is effectively a rollback.
I think about this as two security levels:
- manifest and asset reads are public by design
- publish and delete are deploy operations
Public reads can stay fast and cache-friendly. Mutating operations can be stricter, rate limited, size limited, and audited. The repo already points at the next hardening steps: real Worker secrets, rate limiting, file size validation, and code signing support for clients that send expo-expect-signature.
Operating it
I make a JavaScript or asset change in an app. I export the app for a platform. I publish that export to the Worker with the app slug and runtime version. On launch, expo-updates checks the manifest endpoint. If the manifest ID is new, the app downloads the update in the background. I force close and reopen the app, and the new bundle runs.
There are a few practical checks around that loop. The health endpoint confirms the Worker is up. The updates endpoint lists objects grouped by platform and runtime version so I can see what is actually in R2. wrangler tail shows manifest requests, asset requests, current update IDs, embedded update IDs, and fatal error headers from previous crashes.
The fatal error header is especially useful. If a previous update crashed, the next manifest request can tell the server about it. The current Worker logs that value. A more mature version could use it as part of an automatic rollback or quarantine system.
The delete endpoint is the manual escape hatch:
DELETE /api/updates/:appSlug/:platform/:runtimeVersion
That removes all objects under a specific runtime prefix. It is blunt, but it matches the storage model. If a bad demo-app/ios/1.0.3 update exists, delete that prefix and the server stops offering it.
The other caveat is that OTA updates are not instant in the way people sometimes expect. The app checks on launch, downloads in the background, and normally runs the new update on the next launch. That behavior is a feature. It keeps startup predictable and avoids treating every launch as a blocking network install.
What this changed
The biggest change is psychological. I stopped treating OTA updates as a per-app service decision and started treating them as shared infrastructure.
For one app, this might be overkill. For many apps, the shape starts to pay off. A new app only needs expo-updates, a runtime version policy, and a URL with its slug. The publish script does the same work every time. The Worker does not care whether the slug is for a serious product, a prototype, or an internal tool.
That is the kind of infrastructure I like: small enough that I can hold the whole thing in my head, but real enough that it changes how quickly I can operate.
It keeps the responsibilities clean. App stores still distribute binaries. Expo still builds and exports the bundle correctly. Hermes still runs bytecode inside the app. Cloudflare serves the protocol and files. R2 stores the artifacts. The publish script connects local build output to remote storage.
No single part is very complex. The value comes from the contract between them.
The server answers the protocol. The script publishes the exact export output. The storage path encodes compatibility. The app only downloads updates for its own slug, platform, and runtime version. Assets are cached like immutable artifacts. Bad updates can be deleted by prefix.
That is enough to make the system useful.
Not a giant release platform. Not a managed dashboard.
A small update server that speaks the protocol, stores the right files, and lets a fleet of Expo apps move faster without pretending native compatibility stopped mattering.