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.
- Native version check. With
resetWhenUpdateenabled (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, andappVersionChangeis emitted. Stale web code never runs against new native code. - Promote a pending bundle. If a bundle was held as pending and its delay has elapsed, it becomes the staged next bundle.
- 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. - Serve the current bundle. The plugin resolves the directory containing
index.htmland points the Capacitor webview at it. It also writes Capacitor's ownserverBasePathsetting, so the next cold start serves that bundle before plugins even load. - 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.
- Start background work. The analytics flush timer starts when
analyticsEnabledis on, and the automatic update loop starts whenautoUpdateis 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:
- The SHA-256 checksum of the downloaded bytes is compared against the expected value. A mismatch aborts with
CHECKSUM_MISMATCH. - When
publicKeyis configured and the server sent a signature, the RSA-SHA256 signature over those same bytes is verified. A failure aborts withSIGNATURE_INVALID. A signature that arrives with nopublicKeyconfigured cannot be checked: the bundle is applied unverified and a warning is logged. - When the server sent a
sessionKey, the AES-256-GCM payload is decrypted with the key unwrapped byencryptionPrivateKey. 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
immediateupdates, 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 withsetChannel({ 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.