The Complete Guide to Cordova Hot Updates

Fundamentals, rollout strategy, versioning, and payload optimization in one place — with code examples you can ship today.

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
  2. OTA vs. Binary Release: When to Use Which
  3. Staying Compliant with Store Policies
  4. Setting Up Your First Hot Update End-to-End
  5. Versioning Your Bundles
  6. Phased Rollout Strategy
  7. Payload Optimization
  8. Putting It All Together

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:

What Happens at Runtime

A typical hot update flow looks like this:

  1. App launches and loads the current local bundle from www/.
  2. A background check hits your update server, sending the device's current bundle ID and native version.
  3. The server compares versions and responds with either "no update" or a download URL plus manifest metadata.
  4. The app downloads the new bundle (a zip or a set of diff patches), verifies its checksum, and extracts it to a staging directory.
  5. On the next app launch (or immediately, depending on your strategy), the WebView loads from the new bundle path.
  6. 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:

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:

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

# 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:

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:

  1. Build your web assets and produce a versioned zip with a semantic bundle ID (2.1.0.0003).
  2. Generate a manifest JSON with checksum, dependency requirements, rollout percentages, and audit metadata.
  3. Upload the zip and manifest to your CDN with immutable cache headers.
  4. Promote through cohorts: QA devices first, then dogfood, then beta ring, then general availability.
  5. Monitor download success, install success, crash delta, and funnel impact at every stage.
  6. Gate promotions automatically based on error budgets; block if thresholds are exceeded.
  7. 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."
Next steps: This guide covered the "what" and "how" of hot updates. For the operational side — security hardening with code signing, QA automation pipelines, detailed rollback procedures, and production metrics dashboards — continue to The Cordova OTA Operations Guide.

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.