AbsoluteJS

Over-the-air updates

Ship fixes to installed apps without a store release. Updates are signed on your machine, served by your own server, rolled out in stages and rolled back automatically if a release fails to start. The same commands work for Capacitor and Expo.

#What can update

Every store build carries a fingerprint of its native side, computed for you from your config and the device features you use. An update reaches only apps with the same fingerprint. Change anything native and you ship a store build; an app never receives code it might not be able to run.

ChangeOver the airNeeds a store build
Page JavaScript and CSS
HTML, HTMX documents and static assets
Device features, plugins and permissions
Deep-link hosts and scheme
Auth client and offline Sync schema
Update endpoint, channel and public keys

#Store policy

Updates are for fixes and in-scope content, not for changing what the app does. Apple's App Review Guideline 2.5.2 does not allow downloaded code that introduces or changes features. Every update therefore carries a classification and your confirmation that it stays within the app's submitted purpose.

bug-fixFixes to behaviour the reviewed app already has.
securitySecurity fixes to the reviewed app.
contentNew content within the app’s reviewed purpose, such as copy, images or catalogue pages.
When in doubt
Ship a normal App Store and Google Play build. You are responsible for whether a change needs review.

#Signing keys

Updates are signed with an ECDSA P-256 key. The private key stays on the machine that builds updates; only the public key goes in your config and your app.

BASH
# Private key: keep it on the build machine, outside the project
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
  -out "$HOME/.config/absolutejs/mobile-update.pem"

# Public key: this value goes in absolute.config.ts
openssl pkey -in "$HOME/.config/absolutejs/mobile-update.pem" \
  -pubout -outform DER | openssl base64 -A
TS
mobile: {
  appId: 'com.example.shop',
  appName: 'Shop',
  server: { productionOrigin: 'https://shop.example.com' },
  updates: {
    publicKeys: {
      'production-2026': 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...'
    }
  }
}

publicKeys maps a key ID to a key. To rotate, add the new key, ship a store build, and start signing with the new ID; drop the old key once no installed version needs it.

Expo also verifies the update response with an RSA certificate. Generate it once; the private key must live outside your project, and the certificate is committed and built into the app.

BASH
bunx absolute mobile update signing generate \
  --private-key "$HOME/.config/absolutejs/shop-expo-update.pem"
TS
updates: {
  publicKeys: { 'production-2026': '...' },
  expoCodeSigning: {
    certificatePath: 'mobile/code-signing/expo-update-certificate.pem',
    keyId: 'main'
  }
}

#Update server

Your AbsoluteJS server hosts the updates itself, at /__absolute/mobile/updates/production/update.json on your production origin. Provisioning writes mobile.update.ts, the module that decides where releases are stored.

BASH
# Local storage for development and single-machine testing
bunx absolute mobile update provision --storage local --yes

# S3-compatible storage, required in production
bunx absolute mobile update provision --storage s3 --force --yes

Local storage is refused in production. With S3, the server writes, reads and deletes a test object at startup, so a wrong bucket or missing permission fails before any app asks for an update. Grant it GetObject, PutObject and DeleteObject, and set these on the server only:

VariablePurpose
ABSOLUTE_MOBILE_UPDATE_S3_BUCKETBucket for releases and receipts
ABSOLUTE_MOBILE_UPDATE_S3_REGIONBucket region; auto for Cloudflare R2
ABSOLUTE_MOBILE_UPDATE_S3_ENDPOINTEndpoint for R2, MinIO, Backblaze B2 and other S3-compatible services
ABSOLUTE_MOBILE_UPDATE_S3_FORCE_PATH_STYLESet to 1 for services that need path-style URLs, such as MinIO
ABSOLUTE_MOBILE_UPDATE_HEALTH_SECRETAt least 32 random characters; signs health receipts
ABSOLUTE_EXPO_UPDATE_PRIVATE_KEYExpo only: the RSA private key PEM

#Ship an update

1
Build and sign
Builds your pages from unchanged app code, records the native fingerprint, and signs the manifest with your private key. The result is an immutable amu_ release directory.
absolute mobile update build
2
Publish to a fraction of installs
Uploads the release. Files are stored by their SHA-256, so bytes an earlier release already uploaded are not stored again.
absolute mobile update publish
3
Promote
Widens the rollout. Each installation falls in a stable cohort, so the same devices stay in as the fraction grows.
absolute mobile update promote
BASH
bunx absolute mobile update build src/backend/server.ts \
  --classification bug-fix \
  --key-id production-2026 \
  --signing-key "$HOME/.config/absolutejs/mobile-update.pem" \
  --within-submitted-purpose

bunx absolute mobile update publish .absolutejs/mobile/updates/amu_RELEASE --rollout 0.05
bunx absolute mobile update promote --release amu_RELEASE --rollout 0.25
bunx absolute mobile update promote --release amu_RELEASE --rollout 1

Apps check for an update after they start. A verified update is applied as soon as it finishes downloading, and Sync data and the signed-in session carry over.

#Staged rollout

Configure stages and each promotion moves through them: 5% for an hour with 20 reports, then 25% for six hours with 100 reports, then everyone, as long as no more than 5% of installs roll back. With automatic: true the server advances qualifying stages itself; otherwise run update advance.

TS
updates: {
  publicKeys: { 'production-2026': '...' },
  server: {
    health: { failureRate: 0.2, minimumReports: 20 },
    rollout: {
      automatic: true,
      stages: [
        { rollout: 0.05, minimumReports: 20, observationMinutes: 60 },
        { rollout: 0.25, minimumReports: 100, observationMinutes: 360 },
        { rollout: 1 }
      ]
    }
  }
}

Stages must increase and end at 1. A stage's failure ceiling must sit below the health failureRate, and staged rollout needs health reporting on.

#Automatic rollback

Verified before useApps check the manifest signature against the public keys built into them, then check every file’s SHA-256 before using it.
Efficient downloadsOnly changed files are downloaded, six at a time on Wi-Fi and 4G, two on 3G and one on 2G or with data saver. Interrupted downloads resume where they stopped.
Boot watchdogA new release must render its first page within bootTimeoutMs (20 seconds by default, 5 to 120). If it does not, the app restores the previous release and quarantines the new one.
Fleet healthInstalls report downloaded, activated, rolled-back and quarantined anonymously. When 20% of at least 20 installs roll back, the rollout pauses and new checks get the previous release.

Health reports carry an anonymous installation ID and the outcome, nothing about the user, their session or their data.

#Commands

BASH
bunx absolute mobile update status
bunx absolute mobile update advance
bunx absolute mobile update pause
bunx absolute mobile update resume
bunx absolute mobile update cancel
bunx absolute mobile update rollback                       # back to the build in the store
bunx absolute mobile update rollback --release amu_RELEASE # back to an earlier update
absolute mobile …What it does
update provisionCreate mobile.update.ts with local or S3 storage
update signing generateExpo: create the RSA key and certificate
update buildBuild and sign a release from your server entry
update publish <dir>Upload a release, optionally with --rollout
update promotePoint the channel at a release, at a rollout fraction
update advanceMove to the next rollout stage
update pause · resume · cancelControl the current rollout
update reconcileBring the rollout state up to date
update rollbackReturn to an earlier release or the store build
update statusShow the active release, rollout and health
update storageReport storage use and what could be pruned
update gcDelete unreferenced releases; dry run unless --apply

#Events

Pages can follow updates through the absolute:mobile-update event. Its kind is download-progress, downloaded, activated, rolled-back, quarantined or failed. Transfer details are counts and sizes only, never URLs or paths.

TS
window.addEventListener('absolute:mobile-update', (event) => {
  if (!(event instanceof CustomEvent)) return;
  const { detail } = event;

  if (detail.kind === 'download-progress')
    progressBar.value = detail.completedFiles / detail.totalFiles;
  if (detail.kind === 'rolled-back')
    console.warn('Update rolled back', detail.releaseId);
});

Updates that need new native code go through a store release.