Cordova hot updates let you push HTML, CSS, JavaScript, and media asset changes directly to users without going through an app store review cycle. A critical checkout bug that would normally take a week to patch through Apple or Google can reach every device in minutes. But that speed is a double-edged sword: without proper guardrails, a bad OTA push can break your app faster than any store release ever could.
This guide consolidates everything you need to run a reliable Cordova hot update program — from the foundational rules about what you can and cannot change, through phased rollout mechanics, versioning discipline, and payload optimization. By the end you will have a concrete, end-to-end setup you can adapt to your own infrastructure.
This is Guide 1 of 2. The companion piece, The Cordova OTA Operations Guide, covers security hardening, QA automation, rollback procedures, and production metrics.
Table of Contents
1. Hot Update Fundamentals
Cordova apps are hybrid by design: a native shell (the binary you submit to the store) wraps a WebView that renders your actual application from a local www/ directory. Hot updating works by replacing the contents of that directory — or a subset of it — with a newer bundle downloaded from your server. The native shell stays the same; only the web layer changes.
This architecture means three things for your update program:
- Web assets are fair game. HTML templates, CSS stylesheets, JavaScript modules, images, fonts, JSON config files, and translation strings can all be swapped at runtime.
- Native code is off-limits. Plugin binaries, entitlements, permission declarations, native UI components, and anything compiled into the shell requires a store submission.
- Plugin JavaScript interfaces sit in between. You can update the JS wrapper that calls a plugin, but if the plugin itself expects a different native API version, the update will crash. Dependency tracking (covered in section 5) solves this.
What Happens at Runtime
A typical hot update flow looks like this:
- App launches and loads the current local bundle from
www/. - A background check hits your update server, sending the device's current bundle ID and native version.
- The server compares versions and responds with either "no update" or a download URL plus manifest metadata.
- The app downloads the new bundle (a zip or a set of diff patches), verifies its checksum, and extracts it to a staging directory.
- On the next app launch (or immediately, depending on your strategy), the WebView loads from the new bundle path.
- If the new bundle fails a health check (crash loop, white screen), the app falls back to the previous known-good bundle.
Every step in that flow is a decision point where things can go wrong. The rest of this guide gives you the tools to handle each one.
2. OTA vs. Binary Release: When to Use Which
Not everything should ship over the air. The following table clarifies the boundary:
| Change Type | OTA Safe? | Notes |
|---|---|---|
| JavaScript bug fix | Yes | The most common and highest-value OTA use case. |
| CSS / layout tweak | Yes | Safe as long as you test across OS versions. |
| Copy / translation update | Yes | Ship immediately without waiting for review. |
| New image or font asset | Yes | Watch payload size; use optimization from section 7. |
| Feature flag toggle | Yes | Ideal for A/B tests and gradual rollouts. |
| Analytics / telemetry instrumentation | Yes | Add event tracking without a release cycle. |
| New Cordova plugin | No | Requires native compilation and store submission. |
| New permission (camera, location, etc.) | No | Must be declared in the binary's manifest. |
| Push notification registration changes | No | Touches native entitlements on both platforms. |
| App Tracking Transparency prompt | No | Apple requires this in the binary review. |
| Payment flow changes (IAP) | No | Store policies mandate review for monetization changes. |
The hybrid approach: Many teams ship a minimal native shell with the bare essentials, then deliver the full UI and business logic via OTA on first launch. This lets you iterate on the experience daily while only touching the binary once per quarter (or when native capabilities genuinely change).
3. Staying Compliant with Store Policies
Apple's App Store Review Guidelines (section 3.3.2) and Google's Developer Program Policy both permit OTA updates of interpreted code, provided the updates do not change the app's primary purpose or create a storefront that bypasses their payment systems. In practice, this means:
- Do not fundamentally alter the app's functionality. A calculator app that hot-updates into a social network will be pulled.
- Do not introduce new native-level capabilities. Downloading executable binaries or calling private APIs through hot code is explicitly banned.
- Do not bypass in-app purchase requirements. If a feature should be gated behind IAP, it must go through the binary review flow.
- Keep a changelog. If Apple or Google asks what your OTA updates contained, you need receipts. The manifest system in section 5 gives you this for free.
A practical guardrail: before every OTA release, ask yourself "would this change surprise a reviewer who last saw the binary version?" If the answer is yes, it belongs in a store submission.
4. Setting Up Your First Hot Update End-to-End
This walkthrough assumes you have a working Cordova project and a server (even a static file host works for v1). We will use cordova-plugin-file and cordova-plugin-file-transfer for the download mechanics, though you can substitute fetch + the File API on newer WebView versions.
Step 1: Structure Your Bundle
Your build process should produce a versioned zip of the www/ directory:
# Build your Cordova web assets
cordova build browser --release
# Create a versioned bundle
BUNDLE_ID="2.1.0.0001"
cd platforms/browser/www
zip -r "../../../bundles/${BUNDLE_ID}.zip" . -x "*.map"
cd ../../..
# Generate a SHA-256 checksum
shasum -a 256 "bundles/${BUNDLE_ID}.zip" | awk '{print $1}' > "bundles/${BUNDLE_ID}.sha256"
Step 2: Create the Update Manifest
Host a JSON manifest at a stable URL (e.g., https://updates.yourapp.com/manifest.json):
{
"latest": {
"bundleId": "2.1.0.0001",
"minNativeVersion": "2.1.0",
"url": "https://cdn.yourapp.com/bundles/2.1.0.0001.zip",
"checksum": "sha256-a1b2c3d4e5f6...",
"size": 487392,
"createdAt": "2026-03-29T10:30:00Z",
"notes": "Fix payment form validation on Android 14",
"rollout": {
"qa": 100,
"beta": 100,
"prod": 10
}
},
"previous": {
"bundleId": "2.1.0.0000",
"url": "https://cdn.yourapp.com/bundles/2.1.0.0000.zip",
"checksum": "sha256-f6e5d4c3b2a1..."
}
}
Step 3: Implement the Update Check
In your app's startup JavaScript, add an update check that runs after the UI is interactive (never block first paint):
const UPDATE_MANIFEST_URL = 'https://updates.yourapp.com/manifest.json';
const CURRENT_BUNDLE_ID = localStorage.getItem('bundleId') || '2.1.0.0000';
const NATIVE_VERSION = '2.1.0'; // Injected at build time
const DEVICE_COHORT = localStorage.getItem('cohort') || 'prod';
async function checkForUpdate() {
try {
const res = await fetch(UPDATE_MANIFEST_URL, { cache: 'no-store' });
if (!res.ok) return null;
const manifest = await res.json();
const latest = manifest.latest;
// Skip if already current
if (latest.bundleId === CURRENT_BUNDLE_ID) return null;
// Skip if native version is too old
if (!isCompatible(NATIVE_VERSION, latest.minNativeVersion)) return null;
// Check rollout percentage for this cohort
const rolloutPct = latest.rollout[DEVICE_COHORT] ?? 0;
if (!isInRollout(rolloutPct)) return null;
return latest;
} catch (err) {
console.warn('Update check failed:', err.message);
return null;
}
}
function isCompatible(current, minimum) {
const cur = current.split('.').map(Number);
const min = minimum.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (cur[i] > min[i]) return true;
if (cur[i] < min[i]) return false;
}
return true;
}
function isInRollout(percentage) {
// Deterministic hash based on device ID so the same device
// consistently lands in or out of the rollout
const deviceId = localStorage.getItem('deviceId') || crypto.randomUUID();
localStorage.setItem('deviceId', deviceId);
const hash = [...deviceId].reduce((acc, c) => acc + c.charCodeAt(0), 0);
return (hash % 100) < percentage;
}
Step 4: Download, Verify, and Apply
async function applyUpdate(updateInfo) {
const res = await fetch(updateInfo.url);
const blob = await res.blob();
// Verify checksum before applying
const buffer = await blob.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashHex = [...new Uint8Array(hashBuffer)]
.map(b => b.toString(16).padStart(2, '0')).join('');
const expectedHash = updateInfo.checksum.replace('sha256-', '');
if (hashHex !== expectedHash) {
console.error('Checksum mismatch, aborting update');
return false;
}
// Store the bundle (implementation depends on your file plugin)
await saveBundleToDevice(blob, updateInfo.bundleId);
// Record the new version; the next app launch will load it
localStorage.setItem('bundleId', updateInfo.bundleId);
localStorage.setItem('previousBundleId', CURRENT_BUNDLE_ID);
return true;
}
// Trigger on startup, non-blocking
checkForUpdate().then(update => {
if (update) {
applyUpdate(update).then(success => {
if (success) {
console.log('Update staged. Will apply on next launch.');
// Optionally prompt the user to restart now
}
});
}
});
Step 5: Add a Fallback Mechanism
On every launch, run a lightweight health check. If the app crashes within the first 10 seconds three times in a row, revert to the previous bundle:
const LAUNCH_KEY = 'launchTimestamp';
const CRASH_COUNT_KEY = 'consecutiveCrashes';
const MAX_CRASHES = 3;
function recordSuccessfulLaunch() {
// Called after the app has been stable for 10 seconds
localStorage.setItem(CRASH_COUNT_KEY, '0');
}
function checkBootHealth() {
const crashes = parseInt(localStorage.getItem(CRASH_COUNT_KEY) || '0', 10);
if (crashes >= MAX_CRASHES) {
const previous = localStorage.getItem('previousBundleId');
if (previous) {
console.warn(`Rolling back to ${previous} after ${crashes} crashes`);
localStorage.setItem('bundleId', previous);
localStorage.removeItem('previousBundleId');
localStorage.setItem(CRASH_COUNT_KEY, '0');
window.location.reload();
return;
}
}
localStorage.setItem(CRASH_COUNT_KEY, String(crashes + 1));
setTimeout(() => recordSuccessfulLaunch(), 10000);
}
document.addEventListener('deviceready', checkBootHealth);
This five-step skeleton gives you a working hot update pipeline. The following sections explain how to make it production-grade.
5. Versioning Your Bundles
Sloppy versioning is the single most common cause of hot update incidents. When support cannot tell which bundle a user is running, when rollback scripts target the wrong artifact, when the CDN serves a stale zip because the filename was reused — all of these trace back to inadequate version discipline.
The Semantic Bundle ID Format
Use the pattern appVersion.hotUpdate where the first segment mirrors the store binary and the second segment increments with every OTA push:
Format: {major}.{minor}.{patch}.{hotUpdateSequence}
Examples:
2.1.0.0000 ← Initial binary release
2.1.0.0001 ← First OTA patch
2.1.0.0002 ← Second OTA patch
2.2.0.0000 ← New binary release, hot update counter resets
2.2.0.0001 ← First OTA patch against 2.2.0
This format instantly tells anyone in the organization: the first three numbers identify the required native shell, and the last number tracks OTA iterations. Support can look at 2.1.0.0003 and know the user needs binary 2.1.0 at minimum.
The Machine-Readable Manifest
Every bundle you produce should be accompanied by a manifest file. Store it alongside the zip in your artifact repository:
{
"bundleId": "2.1.0.0002",
"createdAt": "2026-03-29T14:22:00Z",
"minNativeVersion": "2.1.0",
"maxNativeVersion": "2.x.x",
"checksum": "sha256-9f86d081884c7d659a2feaa0c55ad015...",
"sizeBytes": 312847,
"notes": "Fix date picker locale on iOS 18, reduce checkout API calls",
"gitCommit": "a3f8bc2",
"gitBranch": "release/2.1.x",
"approvedBy": "[email protected]",
"qaEvidence": "https://ci.yourcompany.com/runs/4829",
"rollout": {
"qa": 100,
"beta": 50,
"prod": 0
},
"dependencies": {
"cordova-plugin-camera": ">=6.0.0",
"cordova-plugin-inappbrowser": ">=5.0.0"
}
}
Dependency Tracking
The dependencies field is critical. When your JavaScript calls navigator.camera.getPicture(), it assumes a specific plugin API. If the native binary ships with camera plugin 5.x but your OTA bundle calls a 6.x API, users get a runtime crash with no obvious cause.
Your update checker should compare the device's installed plugin versions against the manifest's dependency requirements before downloading. If there is a mismatch, the device should stay on its current bundle and report the incompatibility to your telemetry system.
Version Storage on Device
Persist the current bundle ID, the previous bundle ID (for rollback), and the native version in localStorage or a dedicated preferences file. Never rely on the filename alone — file systems can be corrupted, and CDN caches can serve unexpected content. The manifest checksum is your source of truth.
6. Phased Rollout Strategy
Shipping a hot update to 100% of users on the first push is the OTA equivalent of deploying to production on a Friday afternoon. Even with thorough QA, device fragmentation, network conditions, and OS-specific quirks mean that real-world behavior will diverge from your test environment. Phased rollouts contain the blast radius.
Define Your Cohorts
| Cohort | Size | Purpose | Promotion Criteria |
|---|---|---|---|
| QA Devices | 5-20 devices | Catch build/integration issues | All automated tests pass |
| Internal Dogfood | 50-200 users | Real usage by employees | 24 hours, no new crashes |
| Beta Ring | 5% of users | Diverse device/network testing | Error rate stays within budget |
| General Availability | 100% of users | Full production | Beta metrics stable for 48 hours |
Instrument and Define Success Metrics
Before you push anything, define what "healthy" looks like. At minimum, track these metrics per cohort:
- Download success rate: What percentage of devices that started the download finished it?
- Install success rate: Of those that downloaded, how many successfully applied the bundle?
- Crash delta: Did the crash rate increase compared to the previous bundle?
- Funnel impact: Are key conversion steps (login, checkout, etc.) still performing at baseline?
- Rollback rate: How many devices auto-reverted via the fallback mechanism?
Feed these into a dashboard that every stakeholder can access. When the beta ring shows a 2% increase in crash rate, the team needs to see it immediately — not discover it in a weekly report.
Automate Gating Logic
Manual promotion is fine for your first few releases, but it does not scale. Automate the gates:
# Example CI script: promote from beta to prod
BUNDLE_ID="2.1.0.0002"
BETA_CRASH_RATE=$(curl -s "https://metrics.yourapp.com/api/crash-rate?bundle=${BUNDLE_ID}&cohort=beta")
BASELINE_CRASH_RATE=$(curl -s "https://metrics.yourapp.com/api/crash-rate?bundle=2.1.0.0001&cohort=prod")
# Allow up to 10% increase over baseline
THRESHOLD=$(echo "${BASELINE_CRASH_RATE} * 1.1" | bc)
if (( $(echo "${BETA_CRASH_RATE} <= ${THRESHOLD}" | bc -l) )); then
echo "Promoting ${BUNDLE_ID} to prod at 25%"
curl -X PATCH "https://updates.yourapp.com/admin/manifest" \
-H "Authorization: Bearer ${DEPLOY_TOKEN}" \
-d "{\"bundleId\": \"${BUNDLE_ID}\", \"rollout\": {\"prod\": 25}}"
else
echo "BLOCKED: Beta crash rate ${BETA_CRASH_RATE} exceeds threshold ${THRESHOLD}"
exit 1
fi
The Kill Switch
Every rollout system needs an emergency brake. Implement a kill switch as a single API call or UI toggle that immediately sets the rollout percentage to 0 for all cohorts and points the manifest back to the last known-good bundle. The kill switch should be accessible to on-call engineers, product managers, and support leads — anyone who might be first to notice a production incident.
Document the kill switch procedure on a physical card (or a pinned Slack message) with the exact command:
# Emergency: halt rollout and revert to previous bundle
curl -X POST "https://updates.yourapp.com/admin/kill-switch" \
-H "Authorization: Bearer ${DEPLOY_TOKEN}" \
-d '{"revertTo": "2.1.0.0001", "reason": "Checkout crash on Android 13"}'
Time Gates and Manual Overrides
Two additional gating mechanisms prevent operational surprises. Time gates throttle the download rate to avoid CDN spikes — for example, promoting from 25% to 50% only if 6 hours have passed since the last promotion. Manual overrides let business stakeholders pause a rollout during a peak sales event or scheduled maintenance window, without needing engineering intervention.
7. Payload Optimization
A hot update that takes 30 seconds to download on a 3G connection is not "hot" — it is a frustration generator. Users on metered connections may skip it entirely, leaving your install base fragmented across versions. Every kilobyte you cut improves adoption rate, reduces CDN cost, and makes phased rollouts more predictable (smaller downloads complete faster, so your metrics stabilize sooner).
Delta Diffing: Ship Only What Changed
The single biggest optimization is to stop shipping the entire www/ directory. Generate a diff between the current production bundle and the new one, then ship only the changed files:
# Generate a file-level diff manifest
diff -rq www-previous/ www-current/ | grep "differ\|Only" > diff-report.txt
# Create a patch bundle containing only changed/new files
rsync -av --compare-dest=www-previous/ www-current/ patch-bundle/
# Zip the patch
PATCH_ID="2.1.0.0002-delta"
zip -r "bundles/${PATCH_ID}.zip" patch-bundle/
# Compare sizes
echo "Full bundle: $(du -sh www-current/ | cut -f1)"
echo "Patch bundle: $(du -sh patch-bundle/ | cut -f1)"
In practice, a JS bug fix that touches two files out of 200 produces a patch bundle that is 95% smaller than the full zip. Your manifest should offer both the full bundle URL (for fresh installs or devices that are multiple versions behind) and the delta URL (for incremental updates).
Image and Font Optimization
| Asset Type | Technique | Typical Savings |
|---|---|---|
| PNG/JPG images | Convert to WebP or AVIF | 40-70% smaller |
| Icon fonts | Subset to used glyphs only | 80-95% smaller |
| Web fonts | Use woff2 format exclusively |
30% smaller than woff |
| SVG icons | Run through SVGO, inline critical ones | 20-50% smaller |
Crucially, move large static assets (icon fonts, hero images, vendor libraries) out of the hot update bundle entirely. Host them on your CDN with immutable cache headers and long TTLs. Your hot update bundle should reference them by URL rather than bundling them inline. This way, a JS-only bug fix does not re-download 500KB of unchanged font files.
Lean JavaScript Delivery
- Tree shaking: Configure your bundler (webpack, Rollup, esbuild) to eliminate dead code. If you import one function from lodash, you should not ship the entire library.
- Code splitting: Lazy-load heavy modules (charting libraries, PDF renderers, admin panels) so they are not included in every update bundle. Use dynamic
import()to load them on demand. - Remove dev artifacts: Strip
console.logstatements, source maps, and debugging helpers from production bundles. A single source map can double your zip size. - Vendor caching: If you use third-party libraries, split them into a separate vendor chunk with a content-hash filename. This chunk rarely changes, so most updates skip it entirely.
# Example: esbuild production bundle with tree shaking
npx esbuild src/app.js \
--bundle \
--minify \
--tree-shaking=true \
--splitting \
--format=esm \
--outdir=www/js \
--drop:console \
--drop:debugger
Edge Caching Strategy
Your CDN configuration should use two caching tiers:
- Manifest endpoint: Short TTL (60-300 seconds) or
no-cachewithETagvalidation. Devices need to see new versions quickly. - Bundle zips: Immutable, content-addressed filenames (the bundle ID is in the URL). Set
Cache-Control: public, max-age=31536000, immutable. Once uploaded, these files never change.
Keep at least the two most recent bundles available on the CDN at all times. This ensures the rollback mechanism can fetch the previous version without requiring a rebuild or repackaging step. If your CDN supports edge purging, add a purge step to your kill switch script so stale manifests do not continue directing users to a broken bundle.
8. Putting It All Together
A production-ready Cordova hot update pipeline ties all four pillars together into a single, repeatable workflow:
- Build your web assets and produce a versioned zip with a semantic bundle ID (
2.1.0.0003). - Generate a manifest JSON with checksum, dependency requirements, rollout percentages, and audit metadata.
- Upload the zip and manifest to your CDN with immutable cache headers.
- Promote through cohorts: QA devices first, then dogfood, then beta ring, then general availability.
- Monitor download success, install success, crash delta, and funnel impact at every stage.
- Gate promotions automatically based on error budgets; block if thresholds are exceeded.
- Revert instantly via the kill switch if anything goes wrong, falling back to the previous known-good bundle.
Here is a condensed CI/CD script that implements steps 1 through 4:
#!/bin/bash
set -euo pipefail
BUNDLE_SEQ=$(cat .bundle-sequence)
BUNDLE_SEQ=$((BUNDLE_SEQ + 1))
echo "${BUNDLE_SEQ}" > .bundle-sequence
APP_VERSION="2.1.0"
BUNDLE_ID="${APP_VERSION}.$(printf '%04d' ${BUNDLE_SEQ})"
BUNDLE_DIR="bundles/${BUNDLE_ID}"
# 1. Build
npx esbuild src/app.js --bundle --minify --tree-shaking=true --outdir=www/js --drop:console
mkdir -p "${BUNDLE_DIR}"
cd www && zip -r "../${BUNDLE_DIR}/bundle.zip" . -x "*.map" && cd ..
# 2. Manifest
CHECKSUM=$(shasum -a 256 "${BUNDLE_DIR}/bundle.zip" | awk '{print $1}')
SIZE=$(stat -f%z "${BUNDLE_DIR}/bundle.zip" 2>/dev/null || stat -c%s "${BUNDLE_DIR}/bundle.zip")
cat > "${BUNDLE_DIR}/manifest.json" <<MANIFEST
{
"bundleId": "${BUNDLE_ID}",
"createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"minNativeVersion": "${APP_VERSION}",
"checksum": "sha256-${CHECKSUM}",
"sizeBytes": ${SIZE},
"gitCommit": "$(git rev-parse --short HEAD)",
"rollout": {"qa": 100, "beta": 0, "prod": 0}
}
MANIFEST
# 3. Upload
aws s3 cp "${BUNDLE_DIR}/bundle.zip" "s3://your-updates-bucket/${BUNDLE_ID}.zip" \
--cache-control "public, max-age=31536000, immutable"
aws s3 cp "${BUNDLE_DIR}/manifest.json" "s3://your-updates-bucket/manifest.json" \
--cache-control "no-cache"
# 4. Promote to QA (beta and prod happen after gating)
echo "Bundle ${BUNDLE_ID} deployed to QA. Monitor dashboard before promoting."
A well-executed hot update program turns Cordova from a "deploy and pray" platform into one that rivals native development velocity. The initial setup takes a weekend; the payoff is measured in months of faster iteration, fewer emergency store submissions, and happier users who get fixes the same day they are reported.