I ship JavaScript over the air for a living. The failure mode that still keeps me honest is not a bad button label. It is a bundle that never boots.
When we designed @capgo/capacitor-updater, I did not want a cloud dashboard to be the only undo button. The phone has to decide, by itself, that the new folder of HTML and JS is dead and put the last good folder back. That decision is a handshake. If JavaScript does not complete it, native code rolls the app back. The default window is ten seconds.
This is how that handshake actually works, what a crash looks like versus a hang, and how I test a deliberately broken bundle on a channel before I ever touch production.
The only signal that matters: notifyAppReady()
On every launch the native plugin serves a bundle — either the one that shipped in the binary (builtin) or a zip we downloaded earlier. Serving files is not the same as the app working. The WebView can open index.html and still die in the first module.
So the plugin starts a timer and waits for one method:
import { CapacitorUpdater } from '@capgo/capacitor-updater'
const { bundle } = await CapacitorUpdater.notifyAppReady()
console.log('native accepted', bundle.id, bundle.version)
I put that call in the entry file (main.ts, main.tsx, the first line of app.component.ts). Not after a router guard. Not after auth. The call confirms the bundle loaded and the JS runtime started. That is all it checks.
Call it on every launch. The timer does not remember yesterday. Do not delay it until the home feed loaded. A flaky API should not rewind a good update.
What the 10-second timer is (and is not)
The wait is appReadyTimeout in capacitor.config. Default is 10000 milliseconds:
/// <reference types="@capgo/capacitor-updater" />
import type { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
plugins: {
CapacitorUpdater: {
appReadyTimeout: 10000,
autoDeleteFailed: true,
},
},
}
export default config
After you edit this file you still need npx cap sync and a store binary. Leave the default at 10 seconds unless boot cannot reach the entry file that fast.
Crash versus hang: same native verdict
Crash before the handshake (throw at the top of main.ts) and hang (never call notifyAppReady) get the same verdict: timer expires, previous bundle comes back.
A bug after the handshake does not roll back.
import { CapacitorUpdater } from '@capgo/capacitor-updater'
CapacitorUpdater.addListener('updateFailed', (state) => {
console.log('rolled back from', state.bundle?.id, state.bundle?.version)
})
const failed = await CapacitorUpdater.getFailedUpdate()
if (failed) {
console.log('last failed bundle', failed.bundle)
}
autoDeleteFailed defaults to true.
How I test a broken bundle (on a channel, not on production)
npx @capgo/cli@latest channel add rollback-test com.example.app
npx @capgo/cli@latest channel set rollback-test com.example.app --self-assign
npx @capgo/cli@latest bundle upload --channel rollback-test
For a QA binary set defaultChannel: 'rollback-test' then npx cap sync. Ship a bundle that throws at the top of main.ts, or comment out notifyAppReady. One test device. About ten seconds later you should be on the previous bundle.
The full rollback surface is in the rollbacks docs.
What I tell my own team
Treat notifyAppReady() like main() returning. Keep appReadyTimeout at 10 seconds unless you have measured a boot that cannot hit the entry file in time. Practice on a named channel with one device, then delete the channel when you are done.
The 10 seconds are not a marketing number. They are the default native timer. Miss the handshake, and that is how long a user stares at a dead update before we put their app back.
Top comments (0)