Update Lifecycle

How a Capacitor bundle moves from download to active, what happens on every app launch, and how rollback protection works.


What the DeployYourApp Capacitor plugin does with a bundle: when it downloads, when it activates, and what happens when a bundle turns out to be broken.

The pieces

Your app binary ships a built-in bundle — the web assets Capacitor copied at build time. It is always present and can never be deleted. Everything the plugin downloads is stored beside it, one directory per bundle, and the plugin keeps four pointers into that storage:

Pointer Meaning
current The bundle being served right now. Absent means the built-in bundle.
next A downloaded bundle staged to become current on the next launch.
previous The bundle to restore if the current one fails.
pending A bundle held back until its delayUntil passes, or until the next launch.

A bundle also carries a status: downloaded when stored, pending once staged or served, active once notifyAppReady() confirms it, failed once rolled back.

What happens on every launch

The plugin runs this sequence when Capacitor loads it, before your web code executes.

  1. Native version check. With resetWhenUpdate enabled (the default), the plugin compares the app's native version against the one it recorded last time. If they differ — a store update installed new native code — every downloaded bundle is deleted, the built-in bundle is served, and appVersionChange is emitted. Stale web code never runs against new native code.
  2. Promote a pending bundle. If a bundle was held as pending and its delay has elapsed, it becomes the staged next bundle.
  3. Promote the staged bundle. If a next bundle exists and its files are on disk, it becomes the current bundle with status pending, and the launch-attempt counter resets. A staged bundle whose files have vanished is discarded.
  4. Serve the current bundle. The plugin resolves the directory containing index.html and points the Capacitor webview at it. It also writes Capacitor's own serverBasePath setting, so the next cold start serves that bundle before plugins even load.
  5. Arm rollback protection. If the current bundle has not yet been confirmed, the plugin increments a persisted launch counter and starts the app-ready timer. See Rollback protection.
  6. Start background work. The analytics flush timer starts when analyticsEnabled is on, and the automatic update loop starts when autoUpdate is on.

If the current bundle's files are missing at step 4 — cleared storage, a partial delete — the plugin rolls back immediately and serves whatever the rollback restored.

The update cycle

With autoUpdate: true, the plugin runs this once at launch and then every checkInterval seconds. With autoUpdate: false, you run the same steps yourself.

1. Check

The plugin posts the app ID, device ID, platform, channel, native version, and current bundle version to {updateUrl}/api/update. The server answers 204 No Content when there is nothing to do, which becomes { available: false } and a noUpdateAvailable event. A 200 carries the version, download URL, checksum, and delivery instructions, and emits updateAvailable.

The automatic loop skips the download when the offered version is already the current bundle, or is already staged and on disk.

Every bundle the server can offer already passed an upload-time check, but that check is metadata-only and defense-in-depth, not the guarantee: dya deploy uploads are rejected with HTTP 400 unless the ZIP contains a .dya-manifest.json (by name — the server never parses it) and every entry's name carries an allowed extension. The server does not decompress entries to inspect their content, so a native binary renamed to look like a web asset passes upload. The actual content check — verifying an entry's leading bytes do not match a known native-executable format (Mach-O, ELF, Android DEX, or PE) — happens only on device, during extraction (step 3 below). A rejected upload never reaches storage, so it can never be offered to a device, but an accepted upload is not yet proven safe until the device checks it.

2. Download and verify

The bundle ZIP is fetched with downloadProgress events throttled to whole-percent changes. Only one download runs at a time, and the version currently being served can never be re-downloaded over itself. Then, in order:

  1. The SHA-256 checksum of the downloaded bytes is compared against the expected value. A mismatch aborts with CHECKSUM_MISMATCH.
  2. The RSA-SHA256 signature over those same bytes is verified. This is mandatory: no publicKey configured, or no signature supplied, aborts with SIGNATURE_REQUIRED; a signature that does not verify aborts with SIGNATURE_INVALID. There is no path that applies an unverified bundle.

Both cover the bytes exactly as transferred, and the archive is not opened until both have passed. Bundles are not encrypted, so those bytes are the bundle — anyone holding the download URL can unzip it and read every file, App Review included. The server no longer issues a sessionKey, and /api/update responses carry no such field.

3. Extract and store

Extraction runs into a private staging directory. Entries with absolute paths or .. traversal are rejected, and extraction stops if any single file exceeds 100 MB or the total exceeds 500 MB.

Symlink entries are skipped on iOS. Android's zip API cannot identify them — symlink-ness lives in the central directory's external attributes, which java.util.zip.ZipEntry does not expose — so there such an entry becomes an ordinary file whose content is the link target path, never a link, and it is then caught by the checks below. See Bundle asset policy.

Every file entry is additionally held to the bundle asset policy:

  • Its final extension must be on the web-asset allowlist, checked before a byte is written — otherwise ASSET_TYPE_REJECTED. Directory entries are exempt from this rule; they have no extension.
  • Its first four bytes must not match a native executable magic (Mach-O, ELF, DEX, PE, Java class), whatever the file is named — otherwise ASSET_TYPE_REJECTED.
  • It must appear in the bundle's .dya-manifest.json with a matching SHA-256, computed as the file streams to disk. The comparison is bidirectional: a manifest entry with no file, and a file with no manifest entry, both abort with MANIFEST_MISMATCH. A bundle with no readable manifest, or one whose manifestVersion is not 1, aborts with MANIFEST_MISSING.

The staging directory is only renamed into place after all of that passes and the plugin confirms an index.html, either at the root or inside a single top-level folder. A bundle that fails any check never becomes visible, and the live bundle directory is replaced by a rename rather than a delete.

Once stored, downloadComplete fires with the bundle id and version.

4. Activate

When the bundle activates depends on the resolved apply mode. A mandatory update always resolves to immediate. Otherwise the server's applyMode wins if present, then your configured applyMode, defaulting to whenIdle. A delayUntil in the future overrides all of them and holds the bundle as pending; so does a delayUntil the plugin cannot parse, which is held as pending rather than applied immediately.

Resolved mode What happens
whenIdle Staged as the next bundle. Activates at step 3 of the next launch.
onLaunch Held as pending. Staged and activated during the next launch.
background Nothing. The bundle sits on disk until your app calls apply().
immediate The webview is repointed at the new bundle and reloads now, mid-session. updateApplied fires.

Activation itself is the same operation in every mode: the current pointer moves to the new bundle, the old current becomes previous, the bundle's status becomes pending, and the webview is served from the new directory.

5. Confirm

A newly activated bundle is on trial. Your app calls notifyAppReady() once it has booted:

import { DeployYourApp } from '@deploy-your-app/capacitor-update-manager';

await DeployYourApp.notifyAppReady();

That cancels the rollback timer, marks the bundle active, resets the launch counter, and — with autoDeletePrevious enabled — deletes the previous bundle. Call it as early as your app can honestly claim it works, but no earlier: everything before this call is inside the rollback window, which is the point.

Rollback protection

There is no rollback() method. Rollback is a safety net the plugin triggers itself, through two independent mechanisms.

The app-ready timer. When an unconfirmed bundle is served, the plugin starts a timer for appReadyTimeout milliseconds (10000 by default). If notifyAppReady() has not arrived by then, it rolls back with reason: 'appReadyTimeout'.

The launch counter. A bundle that crashes the app before the timeout can never trip that timer. So the plugin also increments a persisted counter every launch that serves an unconfirmed bundle. On the third such launch it rolls back with reason: 'crashLoop' and an attempts field, without serving the bundle again.

A rollback marks the failing bundle failed, deletes it when autoDeleteFailed is enabled, restores the previous bundle — or the built-in bundle when there is no previous one — clears any staged bundle, resets the launch counter, and serves the restored bundle. A rollback event carries from, to, and reason:

DeployYourApp.addListener('rollback', ({ from, to, reason, attempts }) => {
  console.log(`Rolled back ${from} -> ${to} (${reason})`, attempts);
});

Because the confirmed status is persisted, an already-active bundle starts no timer on later launches. notifyAppReady() on a confirmed bundle is a cheap no-op, so call it unconditionally.

Driving updates yourself

Set autoUpdate: false and run the cycle by hand — for example to show a prompt before applying:

import { DeployYourApp } from '@deploy-your-app/capacitor-update-manager';

DeployYourApp.addListener('downloadProgress', ({ percent }) => {
  showProgress(percent);
});

const update = await DeployYourApp.checkForUpdate();
if (update.available) {
  const bundle = await DeployYourApp.download({
    url: update.url,
    version: update.version,
    checksum: update.checksum,
    signature: update.signature,
  });

  if (update.mandatory || (await askUser(update.message))) {
    await DeployYourApp.apply({ id: bundle.id }); // reloads the webview now
  }
}

apply() always activates immediately, whatever applyMode says. To stage a bundle for the next launch instead, leave autoUpdate on and use applyMode: 'whenIdle'.

Every failure carries a stable code — see error codes.

Reverting from the server

dya rollback changes which bundle a channel serves:

dya rollback --channel production

That does not reach out to devices. Each device receives the earlier version on its next update check and installs it like any other update. To revert a single device from inside your app, call reset() for the built-in bundle, or apply({ id }) with a bundle still listed by listBundles().

Practices worth adopting

  • Call notifyAppReady() on every launch, after your app has verified it can actually work — not in the first line of your bootstrap.
  • Show download progress for immediate updates, so a mid-session reload is not a surprise.
  • Test on a non-production channel first: deploy with dya deploy --channel staging, then move a test device with setChannel({ channel: 'staging' }).
  • Watch rollback events in the dashboard. A rising rollback rate means a bundle is failing on real devices even if it passed review.
  • App Store review — why OTA updates are permitted under the Apple Developer Program License Agreement, and how to answer a Guideline 2.5.2 rejection.

Previous
API Reference
Next
App Store Review