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.

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. When publicKey is configured and the server sent a signature, the RSA-SHA256 signature over those same bytes is verified. A failure aborts with SIGNATURE_INVALID. A signature that arrives with no publicKey configured cannot be checked: the bundle is applied unverified and a warning is logged.
  3. When the server sent a sessionKey, the AES-256-GCM payload is decrypted with the key unwrapped by encryptionPrivateKey. GCM is authenticated, so tampering fails here too.

Checksum and signature cover the bytes as transferred, which for an encrypted deploy means the encrypted bytes — the CLI encrypts before it hashes and signs.

3. Extract and store

Extraction runs into a private staging directory. Entries with absolute paths or .. traversal are rejected, symlink entries are skipped on iOS, and extraction stops if any single file exceeds 100 MB or the total exceeds 500 MB. The staging directory is only renamed into place after the plugin confirms it contains an index.html, either at the root or inside a single top-level folder. A bundle that fails any of this 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,
    sessionKey: update.sessionKey,
  });

  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.

Previous
API Reference
Next
Setup