An Electron live update replaces the web assets your BrowserWindow loads. The native binary, Chromium, and Node stay exactly as installed. This page describes each stage and what is persisted between them.
The flow
main process starts
|
v
initialize() — load state, crash-loop check, register IPC, start timers
|
v
win.loadFile(getBundlePath()) — active bundle, or built-in
|
v
checkForUpdate() (3s after initialize, then every checkInterval)
|
+--- 204 / 404 / available:false ---> `noUpdateAvailable`, done
|
v
download({ url, version, checksum, signature, sessionKey })
|
+--- HTTP error ---> `downloadFailed`
|
v
Verify SHA-256 checksum, then RSA signature (when publicKey is set)
|
+--- mismatch ---> `downloadFailed`, staging discarded
|
v
Decrypt (when sessionKey is present) -> extract to staging -> validate -> swap into place
|
v
resolveApplyMode() -> whenIdle | immediate | onLaunch | background
|
v
apply({ id }) — record as current, mark unconfirmed
|
v
win.loadFile(new bundle) (immediate mode only)
|
v
notifyAppReady() from the renderer?
|
+--- YES ---> bundle confirmed
|
+--- NO, within appReadyTimeout ---> rollback
1. Check
The main process posts to the update endpoint with the app ID, device ID, the current bundle version, the native app version from app.getVersion(), the Electron version, the CPU architecture, and the channel.
The server decides eligibility. It returns nothing when the device is already on the active bundle, when the channel has no active bundle, when the bundle was deployed for a different target, when the deployment is scheduled for the future, when the device falls outside the rollout percentage, or when the bundle's minElectronVersion / maxElectronVersion window excludes the running Electron version. Bundles deployed with dya deploy --target electron are the only ones an Electron client is offered.
checkForUpdate() never rejects. A failed check emits updateFailed with code CHECK_FAILED and resolves to { available: false }, so a flaky network cannot break your boot path.
When a bundle is offered, the response also carries the delivery controls: applyMode, mandatory, and delayUntil.
2. Download
Before any byte is fetched, the download URL must be https and its host must be
one this app talks to — the updateUrl and statsUrl hosts, plus anything in
allowedDownloadHosts. Plain http is accepted only from localhost, for a
local MinIO in development. Signature verification is the real defence against a
swapped bundle, but it is optional and fails open when no publicKey is set, so
the transport is pinned independently.
The bundle is then fetched as a single archive and buffered in memory, emitting downloadProgress as chunks arrive. If the response has no content-length, percent stays 0 while bytesDownloaded still climbs.
Downloads are capped at 200 MB. A declared content-length over the cap is refused up front; the limit is also enforced against the bytes actually arriving, so a server that understates its size is aborted mid-stream rather than after it has exhausted memory.
Only one download runs at a time. A second call while one is in flight rejects rather than racing into the same directory. Re-downloading the currently active bundle is also rejected, because that would delete the web root out from under the running window.
Bundle ids become directory names, so download(), apply(), deleteBundle() and setPendingBundle() all validate against ^[0-9A-Za-z][0-9A-Za-z.\-+]{0,63}$ before anything touches the filesystem. The leading character must be alphanumeric — a pattern that merely allowed dots also matched .., which resolves to the userData directory itself, and deleteBundle() removes recursively.
3. Verify
Two checks run over the exact bytes that were downloaded, in this order:
- Checksum. SHA-256 of the downloaded bytes must equal the
checksumfrom the check response. This always runs. - Signature. The
signaturefrom the check response is verified with RSA-SHA256 against your configuredpublicKey. This step is skipped entirely whenpublicKeyis not set. Configure it — without it, a checksum is only an integrity check against corruption, not against substitution.
Because the CLI encrypts before hashing and signing, both values cover the encrypted bytes as delivered. See Encryption.
4. Decrypt
When the check response includes a sessionKey, the bundle is encrypted. The updater unwraps the AES session key from that field using your encryptionPrivateKey with RSA-OAEP and SHA-256, then decrypts the payload with AES-256-GCM.
The payload uses the DYA1 wire format: a 4-byte magic string, a 12-byte GCM nonce, the ciphertext, and a 16-byte authentication tag. A payload that does not start with DYA1 is rejected. Decryption fails closed — a wrong key or a tampered payload fails the GCM tag check and the bundle is discarded.
Decryption is skipped when there is no sessionKey. An encrypted bundle with no encryptionPrivateKey configured fails.
5. Extract and validate
Extraction runs in-process, straight from the decrypted buffer, into a hidden staging directory under dya-bundles/. No external archive tool is involved, so extraction is unaffected by a missing unzip, PowerShell execution policy, or a sandbox that forbids spawning helpers. The packed archive is never written to disk.
Every entry name is checked before any byte of it is written. An entry is rejected if it is an absolute path, contains a .. segment, carries a drive letter or backslash, or is a symbolic link. One bad entry rejects the whole bundle, so a hostile archive never reaches the filesystem at all.
Sizes are capped from the archive's own headers, before anything is decompressed: 200 MB for any single entry and 1 GB across the archive. A decompression bomb — a small archive that expands to fill the disk — is rejected rather than extracted. The mobile plugins use the same caps at half the size, since desktop bundles legitimately carry more.
The staging directory must then yield an index.html, either at its root or inside a single wrapper folder — the same resolution the Capacitor plugin performs. Without one the bundle is rejected before it can be activated.
Only after both pass is the resolved directory renamed over dya-bundles/{version}/. A failure part-way through leaves previously extracted bundles untouched.
downloadComplete fires here.
6. Activate
This is where Electron differs most from Capacitor. There is no native webview to re-point and no local HTTP server. Activation is a BrowserWindow.loadFile() call against the new bundle's index.html on disk.
That means the updater needs a window reference. Pass it with setWindow(win) or initialize({ window: win }). Without one, apply() still records the bundle and emits reload, but nothing loads — your app must call win.loadFile(updater.getBundlePath()) in response.
When activation happens depends on the resolved mode:
| Mode | What apply() does |
|---|---|
immediate |
Records the bundle, reloads the window, starts the ready-check timer |
whenIdle (default) |
Records the bundle. The running session is untouched; the next launch loads it. |
onLaunch |
Holds the bundle as pending in dya-state.json. The next check applies it. |
background |
Nothing. The bundle sits on disk until you call apply(). |
A mandatory: true update forces immediate and ignores delayUntil. Otherwise a delayUntil in the future holds the bundle as pending regardless of mode. Because pending state is persisted, an onLaunch or delayed update survives a restart.
7. Confirm
Applying a bundle marks it unconfirmed. The renderer clears that by calling notifyAppReady(), which also cancels the rollback timer, resets the crash-loop counter, and records an update_app_ready event with the time since activation.
In immediate mode the appReadyTimeout timer (10 seconds by default) is running, and missing the deadline triggers a rollback in-session. In the other modes the bundle is confirmed on the launch that first serves it.
What happens on restart
initialize() runs before the window exists and does three things that depend on persisted state.
Bundle selection. getBundlePath() returns the recorded bundle's index.html if that file still exists, and the built-in bundle otherwise. A bundle deleted off disk degrades to the built-in one rather than a blank window.
Crash-loop guard. If the recorded bundle is still unconfirmed, a persisted launch counter increments. After three consecutive launches without notifyAppReady(), the updater rolls back with reason crashLoop. This is what protects against a bundle that crashes before the in-memory ready timer can ever fire. State files written before this counter existed are treated as confirmed, so upgrading the updater does not look like a crash loop.
Native version reset. With resetWhenUpdate: true (the default), the updater compares app.getVersion() against the value stored at last launch. When they differ — the user installed a new build of the native app — the current bundle is dropped and the app falls back to the built-in assets that shipped with that build, and appVersionChange is emitted. This stops web code targeted at the old binary from running against the new one.
Then, if there is a pending bundle whose delay has elapsed, the first checkForUpdate() applies it before checking the server.
Rollback
Rollback is automatic and has no public method. It fires when notifyAppReady() is not called within appReadyTimeout after an immediate apply, or when the crash-loop counter reaches three launches.
A rollback restores the previous bundle as current, or the built-in bundle when there is no previous one. The restored bundle is marked confirmed with a fresh launch budget, the window is reloaded, and rollback is emitted with from, to, and reason — either appReadyTimeout or crashLoop.
api.onRollback(({ from, to, reason }) => {
console.warn(`Rolled back from ${from} to ${to}: ${reason}`);
});
The rolled-back version is then quarantined on that device. The server has no way to know a bundle failed on one machine, so it keeps offering it; without the quarantine the updater would reinstall the same broken version on the next check and roll back again on every launch. Quarantined versions are recorded in dya-state.json, survive restarts, and are readable through getFailedVersions(). The list holds the 20 most recent, and reset() clears it.
With autoDeleteFailed (the default), the failed bundle's files are also removed to reclaim disk space. Quarantine is what prevents reinstallation; deleting the files does not.
Quarantine only protects the device that experienced the failure. Everyone else still gets the bad version until you change what the channel serves:
dya rollback --channel production
That changes the channel's active deployment, so devices receive the previous version on their next check. It does not reach out to running installs.
To revert a single install programmatically, call reset() for the built-in bundle, or apply({ id }) with a version from listBundles().
How this differs from Capacitor
| Electron | Capacitor | |
|---|---|---|
| Activation | BrowserWindow.loadFile() against a file path |
Native bridge re-points the webview's server base path |
| Where the logic runs | Main process, in Node | Native Swift and Kotlin code |
| Window reference | You must supply it via setWindow() |
The plugin owns the webview |
| Verification primitives | node:crypto |
Platform crypto (CryptoKit, javax.crypto) |
| Extraction | In-process, from the download buffer | Native archive libraries |
| Renderer access | Explicit preload bridge over IPC | Direct plugin import |
| Native version gate | minElectronVersion / maxElectronVersion on the bundle |
minNativeVersion / maxNativeVersion on the bundle |
| Failure reporting | updateFailed carries CHECK_FAILED or LOAD_FAILED; other failures reject with a message only |
Stable error.code on every rejection |
| Asset paths | Must be relative — bundles are served over file:// |
Served through the native bridge; either form works |
The verification and delivery model is otherwise identical: the same SHA-256 plus RSA signature checks, the same AES-256-GCM DYA1 payload, the same 32-byte session-key assertion, the same rollback reasons, the same wrapper-folder resolution, the same channels, rollout percentages, and apply modes. The Capacitor equivalent of this page is Update lifecycle.
Practical guidance
- Call
notifyAppReady()as early as your app can honestly claim it works. Everything downstream of confirmation depends on it. - Set
publicKey. Signature verification is silently skipped without it. - Keep the built-in bundle in the app package current. It is the floor every rollback and native-version reset lands on.
- Test on a non-production channel first:
dya deploy --target electron --channel staging, withchannel: 'staging'in the updater config orsetChannel('staging')at runtime. - Watch rollback rates in the dashboard. A rising
update_rollbackcount means a bundle is failing its ready check in the field.