---
description: The complete guide to Capacitor Live Updates — how OTA updates work, update strategies, code signing, best practices, and a real-world end-to-end example.
title: Capacitor Live Updates: A Complete Guide to OTA Updates - Capawesome
image: https://capawesome.io/docs/assets/images/social/blog/capacitor-live-updates-guide.png
---

[ Skip to content](#capacitor-live-updates-a-complete-guide-to-ota-updates) 

[ 🎉 Introducing **Capawesome Platform** — one platform for Live Updates, Native Builds, App Store Publishing, and Insider SDKs.](https://capawesome.io) 

* [  Formbricks ](/docs/plugins/formbricks/)
* [  Geocoder ](/docs/plugins/geocoder/)
* [  Google Sign-In ](/docs/plugins/google-sign-in/)
* [  libSQL ](/docs/plugins/libsql/)
* [  Live Update ](/docs/plugins/live-update/)
* [  Managed Configurations ](/docs/plugins/managed-configurations/)
* [  Media Session ](/docs/plugins/media-session/)
* [  ML Kit ](/docs/plugins/mlkit/)
* [  NFC ](/docs/plugins/nfc/)
* [  OAuth ](/docs/plugins/oauth/)
* [  Pedometer ](/docs/plugins/pedometer/)
* [  Photo Editor ](/docs/plugins/photo-editor/)
* [  PostHog ](/docs/plugins/posthog/)
* [  Printer ](/docs/plugins/printer/)
* [  Purchases ](/docs/plugins/purchases/)
* [  RealtimeKit ](/docs/plugins/realtimekit/)
* [  Screen Orientation ](/docs/plugins/screen-orientation/)
* [  Screenshot ](/docs/plugins/screenshot/)
* [  Secure Preferences ](/docs/plugins/secure-preferences/)
* [  Speech Recognition ](/docs/plugins/speech-recognition/)
* [  Speech Synthesis ](/docs/plugins/speech-synthesis/)
* [  Share Target ](/docs/plugins/share-target/)
* [  Square Mobile Payments ](/docs/plugins/square-mobile-payments/)
* [  SQLite ](/docs/plugins/sqlite/)
* [  Superwall ](/docs/plugins/superwall/)
* [  Torch ](/docs/plugins/torch/)
* [  Wifi ](/docs/plugins/wifi/)
* [  Zip ](/docs/plugins/zip/)
* [  Cloud ](/docs/cloud/)
* [  Live Updates ](/docs/cloud/live-updates/)
* Advanced
* Integrations
* [  Native Builds ](/docs/cloud/native-builds/)
* [  Configuration ](/docs/cloud/native-builds/configuration/)
* [  Environments ](/docs/cloud/native-builds/environments/)
* Guides
* [  Sample Projects ](/docs/cloud/native-builds/sample-projects/)
* [  Troubleshooting ](/docs/cloud/native-builds/troubleshooting/)
* [  Automations ](/docs/cloud/automations/)
* Account
* Organizations
* [  Organization and User Management ](/docs/cloud/organizations/memberships/)
* [  Single Sign-On (SSO) ](/docs/cloud/organizations/sso/)
* [  Teams ](/docs/cloud/organizations/teams/)
* [  Two-Factor Authentication ](/docs/cloud/organizations/two-factor-authentication/)
* [  Integrations ](/docs/cloud/integrations/)
* [  License Keys ](/docs/cloud/license-keys/)
* [  Webhooks ](/docs/cloud/webhooks/)
* [  Pricing ](https://capawesome.io/pricing/)
* [  FAQ ](/docs/cloud/faq/)
* [  Support ](/docs/cloud/support/)
* [  Contributing ](/docs/contributing/)
* [  LLMs ](/docs/llms/)
* [  Insiders ](/docs/insiders/)
* [  License ](https://capawesome.io/legal/eula/)
* [  Support ](/docs/insiders/support/)
* [  FAQ ](/docs/insiders/faq/)
* [  Blog ](/blog/)
* Categories

* [  Wiring It Up ](#wiring-it-up)
* [  Three Choices to Make ](#three-choices-to-make)
* [  Decision 2: Versioning Strategy ](#decision-2-versioning-strategy)
* [  Decision 3: Update Delivery Method ](#decision-3-update-delivery-method)
* [  Security: Code Signing ](#security-code-signing)
* [  Best Practices ](#best-practices)
* [  End-to-End Example: A Real-World Setup ](#end-to-end-example-a-real-world-setup)
* [  Bonus: Video Walkthrough ](#bonus-video-walkthrough)
* [  Get Started ](#get-started)
* [  Conclusion ](#conclusion)

* Related links

# Capacitor Live Updates: A Complete Guide to OTA Updates[¶](#capacitor-live-updates-a-complete-guide-to-ota-updates "Permanent link")

Shipping a fix used to mean a bundled binary, an app store review, and a few days of hoping users would tap "update". With Capacitor Live Updates that loop shrinks to minutes. You push a new web bundle, devices pick it up on the next launch, and the next version of your app is already in users' hands.

This guide is the long version. We'll walk through how live updates actually work under the hood, the three choices every team has to make, the security model, the production best practices that keep you out of trouble, and a complete end-to-end example based on a real-world app. Whether you're evaluating live updates for the first time or already shipping them, you should leave with a clear picture of the recommended path and the tradeoffs behind it.

## What Are Live Updates?[¶](#what-are-live-updates "Permanent link")

A Capacitor app has two layers. The **native layer** is the compiled binary that's installed from the App Store or Google Play — it contains the WebView, the native plugins, and the platform glue. The **web layer** is everything that runs inside that WebView: your HTML, CSS, JavaScript, and static assets.

![Layers](/docs/assets/images/diagrams/live-update-layers-light.png#only-light) ![Layers](/docs/assets/images/diagrams/live-update-layers-dark.png#only-dark) 

Capacitor App Layers

A live update is an Over-the-Air (OTA) update of the web layer only. The native binary stays untouched. Instead of pushing a new build to the stores, you upload a new web bundle to a delivery service, and the [Live Update](/docs/plugins/live-update/) plugin in your app downloads it, swaps it in, and reloads.

That's the whole idea. The constraint is also the feature: because nothing native changes, you don't need an app store review and you don't need users to take any action.

### Why Use Live Updates?[¶](#why-use-live-updates "Permanent link")

A few reasons teams reach for them:

* **Hotfixes in minutes, not days.** A critical bug — broken login, wrong API URL, regression in checkout — can be patched before most users notice.
* **Faster feature iteration.** Ship small improvements as soon as they're ready instead of batching them into a monthly native release.
* **Staged rollouts and A/B testing.** Roll a new bundle out to 5% of users, watch the dashboards, then expand. Run different bundles on different channels for beta testers, internal builds, or geographic segments.
* **Smaller user friction.** Users don't have to manually tap "update" in the store, and you're not waiting on whoever postpones store updates for weeks.

### Binary-Compatible vs. Non-Binary-Compatible Changes[¶](#binary-compatible-vs-non-binary-compatible-changes "Permanent link")

The single most important rule with live updates: **you can only update what already exists in the native binary.** Changes that don't touch native code are called _binary-compatible_. Anything else needs a real app store release.

| Change                                            | Binary-compatible? | Where it ships |
| ------------------------------------------------- | ------------------ | -------------- |
| HTML, CSS, JavaScript                             | Yes                | Live update    |
| Images, fonts, JSON, web-only assets              | Yes                | Live update    |
| Web framework upgrade (Angular, React, Vue, etc.) | Yes                | Live update    |
| Capacitor or Cordova plugin added or removed      | No                 | App store      |
| Capacitor or Cordova plugin major version bump    | Usually no         | App store      |
| Native code (Java/Kotlin/Swift/Objective-C)       | No                 | App store      |
| AndroidManifest.xml, Info.plist, entitlements     | No                 | App store      |
| App icon, splash screen                           | No                 | App store      |

A useful mental model: if your change only modifies files inside your web project — your `src/`, your build output — and doesn't add, remove, or upgrade a Capacitor plugin or any native code or config, it's binary-compatible. We'll come back to this when we talk about versioning, because the most common production bug with live updates is delivering a bundle that depends on a native feature the installed binary doesn't have yet.

### How It Works Under the Hood[¶](#how-it-works-under-the-hood "Permanent link")

The Capacitor WebView doesn't load your HTML from `https://example.com`. It loads it from a local path on disk — Capacitor calls this the _server base path_. By default, that path points to the `public/` folder bundled inside the native app, which is exactly the output of `npm run build` at the time you shipped the binary.

A live update is, at its core, swapping that path:

1. The plugin downloads a new bundle and writes it to a local directory inside the app's sandbox.
2. It updates two values that Capacitor reads: the _current_ server path (the one the WebView is using right now) and the _next_ server path (the one Capacitor will use on the next app start).
3. On the next launch — or immediately, if you call `reload()` — the WebView loads from the new path instead of the bundled `public/` folder.

On Android, the _next_ path lives in `SharedPreferences` under the `CapWebViewSettings` group. On iOS, it's stored in `UserDefaults` via Capacitor's `KeyValueStore` class, with one quirk: only paths under `/Library/NoCloud/ionic_built_snapshots` are valid, and only the last path component matters.

If you want the full deep dive, including how the plugin uses the Capacitor Bridge's `setServerBasePath()` to swap paths at runtime, see [How Live Updates for Capacitor Work Under the Hood](/blog/how-live-updates-for-capacitor-work/).

### Are Live Updates Compliant With App Store Policies?[¶](#are-live-updates-compliant-with-app-store-policies "Permanent link")

Yes. Both Apple and Google explicitly allow downloading and executing code inside a WebView or scripting runtime, which is exactly what live updates do.

**Apple's App Store Review Guidelines** (section 3.3.2) allow downloading interpreted code as long as it (a) doesn't change the primary purpose of the app, (b) doesn't create a store or storefront for other code, and (c) doesn't bypass the system's signing, sandbox, or security features. Web code running in a `WKWebView` checks all three boxes.

**Google Play's Device and Network Abuse policy** forbids apps from modifying or replacing themselves outside of Play — with an explicit exception for "code that is interpreted in a virtual machine or runtime (like JavaScript in a webview or browser)."

In other words, app stores draw the line at _native_ self-modification. Live updates only touch the web layer, so they stay safely on the right side of that line.

## Wiring It Up[¶](#wiring-it-up "Permanent link")

Before any choices, the SDK needs to be installed and connected to Capawesome Cloud. Install the plugin, create a Cloud app, and add it to your Capacitor config.

AI-Assisted Setup

For a more guided experience, add the [Capawesome skills](https://github.com/capawesome-team/skills) to your project with `npx skills add capawesome-team/skills --skill capawesome-live-updates` and use the following prompt with your preferred AI coding assistant:

`` [](#%5F%5Fcodelineno-0-1)Use the `capawesome-live-updates` skill from `capawesome-team/skills` to help me set up live updates in my Capacitor app.
 ``

If you'd rather do it by hand, install the plugin and sync the native projects:

`[](#%5F%5Fcodelineno-1-1)npm install @capawesome/capacitor-live-update
[](#%5F%5Fcodelineno-1-2)npx cap sync
`

Then create an app in Capawesome Cloud — the CLI prompts for the organization and app name, and prints the resulting app ID:

`[](#%5F%5Fcodelineno-2-1)npx @capawesome/cli apps:create
`

Finally, add the plugin to your Capacitor config with the app ID you just got back:

capacitor.config.ts

`[](#%5F%5Fcodelineno-3-1)const config: CapacitorConfig = {
[](#%5F%5Fcodelineno-3-2)  plugins: {
[](#%5F%5Fcodelineno-3-3)    LiveUpdate: {
[](#%5F%5Fcodelineno-3-4)      appId: '00000000-0000-0000-0000-000000000000',
[](#%5F%5Fcodelineno-3-5)      autoUpdateStrategy: 'background',
[](#%5F%5Fcodelineno-3-6)      autoBlockRolledBackBundles: true,
[](#%5F%5Fcodelineno-3-7)      readyTimeout: 10000
[](#%5F%5Fcodelineno-3-8)    }
[](#%5F%5Fcodelineno-3-9)  }
[](#%5F%5Fcodelineno-3-10)};
`

For full setup instructions including the Capacitor 6 and 7 variants, see the [Live Updates setup guide](/docs/cloud/live-updates/setup/).

## Three Choices to Make[¶](#three-choices-to-make "Permanent link")

With the SDK installed, three choices shape how live updates behave in your app. Pick a default for each, and you have a production setup.

### Decision 1: Update Strategy[¶](#decision-1-update-strategy "Permanent link")

The _strategy_ decides **when** an update is downloaded, **when** it's applied, and **whether the user is involved.** It's as much a UX decision as a technical one — each option trades freshness against friction differently, and the right choice depends on how important it is that users are on the latest code versus how much you're willing to interrupt them to get them there.

The plugin supports four patterns, ranging from "set it once and forget about it" to "fire an update across the install base on demand":

| Strategy                          | User-visible delay        | Network behavior       | Use case                                   |
| --------------------------------- | ------------------------- | ---------------------- | ------------------------------------------ |
| **Background**                    | Next cold start           | Lazy, opportunistic    | Silent updates between sessions            |
| **Always Latest** _(recommended)_ | One prompt after download | Lazy, opportunistic    | User is informed and chooses when to apply |
| **Force Update**                  | Blocks app start          | Eager, on every launch | Apps where stale code is genuinely unsafe  |
| **Instant**                       | Fires on push             | Reactive, on demand    | Critical hotfixes for a live incident      |

The sections below walk through each one in turn, with the config and code to wire it up.

#### Background[¶](#background "Permanent link")

The simplest option, with zero app-side code. You set it once in your config:

capacitor.config.ts

`[](#%5F%5Fcodelineno-4-1)plugins: {
[](#%5F%5Fcodelineno-4-2)  LiveUpdate: {
[](#%5F%5Fcodelineno-4-3)    appId: '...',
[](#%5F%5Fcodelineno-4-4)    autoUpdateStrategy: 'background'
[](#%5F%5Fcodelineno-4-5)  }
[](#%5F%5Fcodelineno-4-6)}
`

The plugin automatically checks for updates on app start and on resume, downloads new bundles in the background, and applies them the next time the app launches. Users get the new version on their next cold start — no prompts, no waiting. There's a built-in 15-minute minimum between checks so the plugin can't be accidentally turned into a polling loop that drains the battery.

The tradeoff is that the update is invisible. Users won't know a new version exists until the app silently behaves differently on their next session, which can be confusing if something visibly changed. Reach for this strategy when the update doesn't need user attention.

#### Always Latest (Recommended)[¶](#always-latest-recommended "Permanent link")

Our recommended strategy. You still get the _background_ strategy's silent download, but instead of making the user wait for the next cold start, you prompt them to apply the update as soon as it's ready. Users find out when something new is available, and they decide whether to apply it now or keep working with the current version.

You opt in by listening for the `nextBundleSet` event:

`[](#%5F%5Fcodelineno-5-1)import { LiveUpdate } from '@capawesome/capacitor-live-update';
[](#%5F%5Fcodelineno-5-2)
[](#%5F%5Fcodelineno-5-3)LiveUpdate.addListener('nextBundleSet', async ({ bundleId }) => {
[](#%5F%5Fcodelineno-5-4)  if (!bundleId) {
[](#%5F%5Fcodelineno-5-5)    return;
[](#%5F%5Fcodelineno-5-6)  }
[](#%5F%5Fcodelineno-5-7)  const shouldReload = confirm('A new version is available. Install it now?');
[](#%5F%5Fcodelineno-5-8)  if (shouldReload) {
[](#%5F%5Fcodelineno-5-9)    await LiveUpdate.reload();
[](#%5F%5Fcodelineno-5-10)  }
[](#%5F%5Fcodelineno-5-11)});
`

The download still happens in the background, so the prompt only appears once the bundle is fully on disk — there's no waiting on a network call when the user taps "install". This is the strategy the real-world example later in this guide uses.

#### Force Update[¶](#force-update "Permanent link")

The "user must be on the latest version before they can use the app" strategy. First, disable the Splash Screen's auto-hide so it stays visible while the update check runs:

capacitor.config.ts

`[](#%5F%5Fcodelineno-6-1)plugins: {
[](#%5F%5Fcodelineno-6-2)  SplashScreen: {
[](#%5F%5Fcodelineno-6-3)    launchAutoHide: false
[](#%5F%5Fcodelineno-6-4)  },
[](#%5F%5Fcodelineno-6-5)  LiveUpdate: {
[](#%5F%5Fcodelineno-6-6)    appId: '...',
[](#%5F%5Fcodelineno-6-7)    autoUpdateStrategy: 'none'
[](#%5F%5Fcodelineno-6-8)  }
[](#%5F%5Fcodelineno-6-9)}
`

Then on app start, sync, and either reload (if there's a new bundle) or hide the splash (if there isn't):

`[](#%5F%5Fcodelineno-7-1)import { LiveUpdate } from '@capawesome/capacitor-live-update';
[](#%5F%5Fcodelineno-7-2)import { SplashScreen } from '@capacitor/splash-screen';
[](#%5F%5Fcodelineno-7-3)
[](#%5F%5Fcodelineno-7-4)const { nextBundleId } = await LiveUpdate.sync();
[](#%5F%5Fcodelineno-7-5)if (nextBundleId) {
[](#%5F%5Fcodelineno-7-6)  await LiveUpdate.reload();
[](#%5F%5Fcodelineno-7-7)} else {
[](#%5F%5Fcodelineno-7-8)  await SplashScreen.hide();
[](#%5F%5Fcodelineno-7-9)}
`

This guarantees freshness, but it costs you. Every cold start on a slow connection means the user stares at the splash screen while a multi-megabyte bundle downloads. Reserve it for cases where running a stale version is genuinely unsafe (e.g. some kinds of regulated apps). Always set `autoUpdateStrategy: 'none'` to avoid two update mechanisms running at once.

#### Instant[¶](#instant "Permanent link")

For genuinely critical updates — a bad bug already in production, a security patch — the fastest path is a silent push notification that tells the app "go check for an update _now_":

`[](#%5F%5Fcodelineno-8-1)import { FirebaseMessaging } from '@capacitor-firebase/messaging';
[](#%5F%5Fcodelineno-8-2)import { LiveUpdate } from '@capawesome/capacitor-live-update';
[](#%5F%5Fcodelineno-8-3)
[](#%5F%5Fcodelineno-8-4)FirebaseMessaging.addListener('notificationReceived', async ({ data }) => {
[](#%5F%5Fcodelineno-8-5)  if (data?.type !== 'live-update') {
[](#%5F%5Fcodelineno-8-6)    return;
[](#%5F%5Fcodelineno-8-7)  }
[](#%5F%5Fcodelineno-8-8)  const { nextBundleId } = await LiveUpdate.sync();
[](#%5F%5Fcodelineno-8-9)  if (nextBundleId) {
[](#%5F%5Fcodelineno-8-10)    const shouldReload = confirm('Critical update available. Install now?');
[](#%5F%5Fcodelineno-8-11)    if (shouldReload) {
[](#%5F%5Fcodelineno-8-12)      await LiveUpdate.reload();
[](#%5F%5Fcodelineno-8-13)    }
[](#%5F%5Fcodelineno-8-14)  }
[](#%5F%5Fcodelineno-8-15)});
`

This requires push infrastructure (typically [Firebase Cloud Messaging](/docs/plugins/firebase/cloud-messaging/)) and is meant for the long tail of "someone has the broken bundle open right now, and we need to fix it before they touch checkout." Set `autoUpdateStrategy: 'none'` and don't use this as your default strategy — it's a _break-glass_ tool, not an everyday flow.

### Decision 2: Versioning Strategy[¶](#decision-2-versioning-strategy "Permanent link")

This is the decision that prevents the most common production failure: **shipping a web bundle that doesn't match the installed native binary.**

Picture a user with version 5 of your native app. You ship a new web bundle that calls a method on a plugin you only added in version 6\. If that bundle reaches the v5 user, their app crashes on launch — and the only way out is reinstalling from the store.

You prevent this by binding each web bundle to the range of native versions it's compatible with. There are two ways to do it.

#### Versioned Bundles[¶](#versioned-bundles "Permanent link")

You attach min/max/equals version ranges to each upload:

`[](#%5F%5Fcodelineno-9-1)npx @capawesome/cli apps:liveupdates:upload \
[](#%5F%5Fcodelineno-9-2)  --android-min 10 --android-max 12 \
[](#%5F%5Fcodelineno-9-3)  --ios-min 10 --ios-max 12
`

The Cloud only serves the bundle to devices inside the range. Simple, but error-prone: you have to remember the right flags on every upload, and rollouts get awkward because the rollout percentage is calculated over _all_ devices in the channel, including ones that can't install the bundle.

#### Versioned Channels (Recommended)[¶](#versioned-channels-recommended "Permanent link")

A cleaner pattern: one channel per native version. Bundle uploaded to `production-10` only reaches devices on native version 10, and so on.

The cleanest way to wire this up is **at build time**, in the native configuration. Then your app code doesn't have to know anything about channels — Capacitor reads the channel directly from the platform config.

On Android, add a resource value in `android/app/build.gradle`:

`[](#%5F%5Fcodelineno-10-1)android {
[](#%5F%5Fcodelineno-10-2)    defaultConfig {
[](#%5F%5Fcodelineno-10-3)        versionCode 60003
[](#%5F%5Fcodelineno-10-4)        resValue "string", "capawesome_live_update_default_channel",
[](#%5F%5Fcodelineno-10-5)                 "production-" + defaultConfig.versionCode
[](#%5F%5Fcodelineno-10-6)    }
[](#%5F%5Fcodelineno-10-7)}
`

On iOS, add a key to `ios/App/App/Info.plist`:

`[](#%5F%5Fcodelineno-11-1)<key>CapawesomeLiveUpdateDefaultChannel</key>
[](#%5F%5Fcodelineno-11-2)<string>production-$(CURRENT_PROJECT_VERSION)</string>
`

Every native release ships pinned to its own channel. Bumping the native version automatically opens a fresh channel for compatible web bundles. No JavaScript-side state to manage.

If you can't use the native config (e.g. you want to switch channels at runtime based on something other than the version code), you can pass the channel inline on every sync — pass it directly to `sync()` or `fetchLatestBundle()` rather than calling `setChannel()` separately, so the channel selection is always explicit at the call site:

`` [](#%5F%5Fcodelineno-12-1)import { LiveUpdate } from '@capawesome/capacitor-live-update';
[](#%5F%5Fcodelineno-12-2)
[](#%5F%5Fcodelineno-12-3)const { versionCode } = await LiveUpdate.getVersionCode();
[](#%5F%5Fcodelineno-12-4)await LiveUpdate.sync({ channel: `production-${versionCode}` });
 ``

The real win is safety. Every deployment is addressed to a specific native version by name, so it's much harder to accidentally ship a bundle to a binary that can't run it. There are no version-range flags to remember on each upload, and no way for a typo to push a bundle to devices outside the intended set. For most teams, channels are the right answer.

For the long version, see [How to Restrict Capacitor Live Updates to Native Versions](/blog/how-to-restrict-capacitor-live-updates-to-native-versions/).

### Decision 3: Update Delivery Method[¶](#decision-3-update-delivery-method "Permanent link")

How is the bundle packaged for transport?

#### Zip (Recommended)[¶](#zip-recommended "Permanent link")

The whole web folder is compressed into a single `.zip` and uploaded:

`[](#%5F%5Fcodelineno-13-1)npx @capawesome/cli apps:liveupdates:upload --artifact-type zip
`

The device downloads one compressed file and extracts it. This is the default and almost always the right choice — compression typically halves the size, the download is a single HTTP request, and it works regardless of how your build tool names files.

#### Manifest (Delta Updates)[¶](#manifest-delta-updates "Permanent link")

The alternative is a per-file manifest: each file is uploaded individually, and the device only downloads files whose hash changed since its current bundle.

In theory, this is great for apps that mostly change one or two files between releases. In practice, modern bundlers (Vite, Angular CLI, Webpack 5+, Nuxt, Next-to-static) all use _content-hashed filenames_ — `main.7f3a91c2.js`, `chunk-3a7f2b.js`, and so on. The whole point of content hashing is that filenames change whenever content changes, which defeats delta comparison: every file looks new, so every file gets downloaded, and you lose the single-zip compression on top.

We've seen this in the wild. One team's bundle was 20 MB on `manifest` and 9 MB on `zip` for the exact same web output — just because the manifest mode couldn't take advantage of compression while their content-hashed filenames defeated the delta comparison. Another team got from 48 MB to 9 MB by switching to zip and dropping source maps. The data is consistent: start on zip, and only consider manifest if you have specific, stable-named, large static assets you know don't change between builds.

For the full breakdown, see [How to Reduce the Bundle Size of Capacitor Live Updates](/blog/how-to-reduce-the-bundle-size-of-capacitor-live-updates/).

## Security: Code Signing[¶](#security-code-signing "Permanent link")

Live updates run on the same trust model as your native binary: whatever code you ship will execute in your app's context. If someone can replace a bundle in transit — say, on a hostile Wi-Fi network with a forged certificate, or by compromising your delivery server — they get code execution inside your app. Even though the Cloud uses HTTPS, defense in depth matters here.

Code signing closes that gap with two guarantees:

* **Authenticity** — the bundle was produced by someone holding your signing key.
* **Integrity** — the bundle wasn't modified between upload and install.

The mechanism is a standard RSA keypair. You generate a private key and a public key. The private key is used to sign each bundle when you upload it. The public key is embedded in your app config and used to verify the signature of every downloaded bundle before it's applied. If the signature doesn't match, the bundle is rejected.

Generate the keypair with the CLI:

`[](#%5F%5Fcodelineno-14-1)npx @capawesome/cli apps:liveupdates:generatesigningkey
`

This produces `private.pem` and `public.pem`. Add `private.pem` to your `.gitignore`. Sign every upload by passing the private key:

`[](#%5F%5Fcodelineno-15-1)npx @capawesome/cli apps:liveupdates:upload --private-key private.pem
`

And add the public key to your Capacitor config (the CLI prints it preformatted, with the line breaks stripped, so you can paste it directly):

`[](#%5F%5Fcodelineno-16-1){
[](#%5F%5Fcodelineno-16-2)  "plugins": {
[](#%5F%5Fcodelineno-16-3)    "LiveUpdate": {
[](#%5F%5Fcodelineno-16-4)      "publicKey": "-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4G...IDAQAB-----END PUBLIC KEY-----"
[](#%5F%5Fcodelineno-16-5)    }
[](#%5F%5Fcodelineno-16-6)  }
[](#%5F%5Fcodelineno-16-7)}
`

From that point on, your app verifies every downloaded bundle and refuses to apply anything that isn't signed by your private key. We strongly recommend enabling code signing in production, especially if you operate multiple channels or self-host your bundles. For the full setup including key rotation considerations, see [Code Signing](/docs/cloud/live-updates/advanced/code-signing/).

## Best Practices[¶](#best-practices "Permanent link")

Five patterns that consistently separate a smooth live update setup from a painful one.

### Automatic Rollbacks[¶](#automatic-rollbacks "Permanent link")

This is the single most important safety net. The pattern: tell the plugin to roll back to the previous bundle if the new one fails to start, and tell it not to retry a bundle that's already failed once.

Two config options do the work:

`[](#%5F%5Fcodelineno-17-1){
[](#%5F%5Fcodelineno-17-2)  "plugins": {
[](#%5F%5Fcodelineno-17-3)    "LiveUpdate": {
[](#%5F%5Fcodelineno-17-4)      "readyTimeout": 10000,
[](#%5F%5Fcodelineno-17-5)      "autoBlockRolledBackBundles": true
[](#%5F%5Fcodelineno-17-6)    }
[](#%5F%5Fcodelineno-17-7)  }
[](#%5F%5Fcodelineno-17-8)}
`

`readyTimeout` is the deadline: if your app doesn't call `LiveUpdate.ready()` within this many milliseconds of starting, the plugin assumes the new bundle is broken and reverts to the previous one on the next launch. `autoBlockRolledBackBundles` prevents the rollback loop where a broken bundle keeps getting re-downloaded — once a bundle has triggered a rollback, the device blocks it from ever installing again.

On the app side, call `ready()` as early as you can — typically in your root component's initialization. This is the "I started successfully" signal:

`[](#%5F%5Fcodelineno-18-1)import { LiveUpdate } from '@capawesome/capacitor-live-update';
[](#%5F%5Fcodelineno-18-2)
[](#%5F%5Fcodelineno-18-3)await LiveUpdate.ready();
`

You don't need to wait until your app is fully hydrated. As soon as your bootstrap code has executed without crashing, call `ready()`. The whole mechanism only works if you do.

### Bundle Size Optimization[¶](#bundle-size-optimization "Permanent link")

Bundle size is a user-experience problem: every megabyte is bandwidth on someone's mobile plan and seconds of waiting. It's also a deliverability problem — the bigger the bundle, the more updates fail on flaky connections.

Three optimizations cover most of the wins:

1. **Drop source maps.** They're often 75% of bundle size and they don't belong in production downloads. In Vite, set `build.sourcemap: false`. In Angular, `--source-map=false`. If you need source maps for error tracking, upload them to Sentry or your APM separately — don't ship them to devices.
2. **Stick with `zip`.** Unless you have specific evidence that delta updates will work for your bundle layout (see Decision 3), zip is smaller in practice.
3. **Move heavy static assets out of the bundle.** Hero images, marketing media, downloadable PDFs — anything large that doesn't _have_ to live in the bundle should be served from a CDN and loaded on demand.

Tree-shaking and minification matter too, but most modern build tools handle them by default. Audit with `rollup-plugin-visualizer` or `webpack-bundle-analyzer` if your bundle is mysteriously large.

For a real-world case study going from 48 MB to 9 MB, see [How to Reduce the Bundle Size of Capacitor Live Updates](/blog/how-to-reduce-the-bundle-size-of-capacitor-live-updates/).

### Reasonable Update Checks[¶](#reasonable-update-checks "Permanent link")

A pattern we see surprisingly often:

`[](#%5F%5Fcodelineno-19-1)// Don't do this.
[](#%5F%5Fcodelineno-19-2)setInterval(() => {
[](#%5F%5Fcodelineno-19-3)  LiveUpdate.sync();
[](#%5F%5Fcodelineno-19-4)}, 60_000);
`

There's no reason to poll. The built-in `autoUpdateStrategy: 'background'` already does the right thing — it checks on app start and on resume, with a 15-minute minimum between checks. That's enough freshness for almost every app, and it doesn't burn battery, data, or rate limit.

If you genuinely need to push out an update faster than that, use the **Instant** strategy with a silent push notification. That's the right tool — polling isn't.

### Gradual Rollouts[¶](#gradual-rollouts "Permanent link")

You don't have to release to 100% of users at once. The plugin supports rollouts at the channel level:

`[](#%5F%5Fcodelineno-20-1)npx @capawesome/cli apps:liveupdates:upload --rollout-percentage 10
`

That bundle reaches 10% of devices on the channel. You watch your error tracking, your crash dashboards, your support inbox — and if things look good, you bump it up:

`[](#%5F%5Fcodelineno-21-1)npx @capawesome/cli apps:liveupdates:rollout --channel production --percentage 50
`

For high-stakes releases, an even safer pattern is **multi-channel rollouts**: a dedicated `production-rollout` channel for the new bundle, with the app checking the rollout channel first and falling back to the stable channel:

`` [](#%5F%5Fcodelineno-22-1)import { LiveUpdate } from '@capawesome/capacitor-live-update';
[](#%5F%5Fcodelineno-22-2)
[](#%5F%5Fcodelineno-22-3)const { versionCode } = await LiveUpdate.getVersionCode();
[](#%5F%5Fcodelineno-22-4)
[](#%5F%5Fcodelineno-22-5)const rolloutResult = await LiveUpdate.sync({
[](#%5F%5Fcodelineno-22-6)  channel: `production-rollout-${versionCode}`
[](#%5F%5Fcodelineno-22-7)});
[](#%5F%5Fcodelineno-22-8)if (rolloutResult.nextBundleId) {
[](#%5F%5Fcodelineno-22-9)  return;
[](#%5F%5Fcodelineno-22-10)}
[](#%5F%5Fcodelineno-22-11)await LiveUpdate.sync({ channel: `production-${versionCode}` });
 ``

As confidence grows, you copy the same bundle to the stable channel. The plugin recognizes it's the same bundle ID and doesn't re-download anything.

For the deeper dive, see [How to Gradually Roll Out Capacitor Live Updates](/blog/how-to-gradually-roll-out-capacitor-live-updates/).

### Channel Hygiene[¶](#channel-hygiene "Permanent link")

Channels are cheap, but a few habits keep them tidy:

* **Set bundle limits.** Configure a max number of bundles per channel; older ones get garbage-collected automatically.
* **Set expirations on short-lived bundles.** If you're shipping a test bundle for a single QA cycle, use `--expires-in-days` to clean it up automatically.
* **Use protected channels for sensitive deploys.** Protected channels reject any bundle that isn't code-signed.
* **Watch the logs.** The Cloud Console exposes a [logs view](/docs/cloud/live-updates/logs/) showing every device-to-cloud request — invaluable when "why isn't user X getting the update?" turns up in your support inbox.

## End-to-End Example: A Real-World Setup[¶](#end-to-end-example-a-real-world-setup "Permanent link")

Let's put all of this together with a real example. The [DHBW VS app](https://github.com/dhbw-vs/app) is the official student app for a German university — a Capacitor app shipping to thousands of students across iOS and Android, maintained by a small team. It uses the full recommended stack:

* **Strategy:** Always Latest — background download, plus a confirm prompt before applying.
* **Versioning:** Versioned channels via native config, no JS-side state.
* **Delivery:** Zip.
* **Synchronized version codes:** [commit-and-tag-version](https://www.npmjs.com/package/commit-and-tag-version) bumps `package.json` from conventional commits, and [Capver](/blog/introducing-capver/) mirrors that version into Android and iOS.
* **Safety:** `autoBlockRolledBackBundles: true`, `readyTimeout: 10000`, `ready()` called at app start.
* **CI/CD:** Two GitHub Actions workflows — one for native releases, one for live updates — both triggered manually via `workflow_dispatch` to keep release timing in the team's hands.

If Capver is new to you, it's a [small open-source CLI from Capawesome](/blog/introducing-capver/) that mirrors a single semantic version into iOS, Android, and `package.json` so they stay aligned across every release. The mapping is the slightly opaque part: a release tagged `6.0.3` becomes Android `versionCode 60003` and iOS `CURRENT_PROJECT_VERSION 60003` — three two-digit groups for major, minor, and patch. That deterministic mapping is what makes the build-time channel pinning below work without any manual bookkeeping.

The patterns below are excerpts from that codebase. They'd look nearly identical in any other framework — the Angular wrapper is incidental.

### Capacitor Config[¶](#capacitor-config "Permanent link")

The plugin is configured in `capacitor.config.json` with the app ID, the update strategy, and the automatic rollback settings:

capacitor.config.json

`[](#%5F%5Fcodelineno-23-1){
[](#%5F%5Fcodelineno-23-2)  "appId": "de.dhbw.vs.standortapp",
[](#%5F%5Fcodelineno-23-3)  "appName": "DHBW VS",
[](#%5F%5Fcodelineno-23-4)  "webDir": "www",
[](#%5F%5Fcodelineno-23-5)  "plugins": {
[](#%5F%5Fcodelineno-23-6)    "LiveUpdate": {
[](#%5F%5Fcodelineno-23-7)      "appId": "70922e93-0944-48cc-a560-61135ab291ad",
[](#%5F%5Fcodelineno-23-8)      "autoBlockRolledBackBundles": true,
[](#%5F%5Fcodelineno-23-9)      "autoUpdateStrategy": "background",
[](#%5F%5Fcodelineno-23-10)      "readyTimeout": 10000
[](#%5F%5Fcodelineno-23-11)    }
[](#%5F%5Fcodelineno-23-12)  }
[](#%5F%5Fcodelineno-23-13)}
`

`autoUpdateStrategy: 'background'` does the heavy lifting. `autoBlockRolledBackBundles: true` and `readyTimeout: 10000` together implement automatic rollback. No `defaultChannel` here — the channel is set natively, per platform.

### Android[¶](#android "Permanent link")

On Android, the channel name is baked into a string resource at build time, derived from the `versionCode`:

android/app/build.gradle

`[](#%5F%5Fcodelineno-24-1)android {
[](#%5F%5Fcodelineno-24-2)    defaultConfig {
[](#%5F%5Fcodelineno-24-3)        versionCode 60003
[](#%5F%5Fcodelineno-24-4)        versionName "6.0.3"
[](#%5F%5Fcodelineno-24-5)        resValue "string", "capawesome_live_update_default_channel",
[](#%5F%5Fcodelineno-24-6)                 "production-" + defaultConfig.versionCode
[](#%5F%5Fcodelineno-24-7)        // ...
[](#%5F%5Fcodelineno-24-8)    }
[](#%5F%5Fcodelineno-24-9)}
`

Every Android build pins itself to `production-60003`. When `capver` bumps the version code to `60004`, the channel name changes automatically — there's no JS code to update.

### iOS[¶](#ios "Permanent link")

On iOS, the equivalent is a single `Info.plist` key that interpolates the project version:

ios/App/App/Info.plist

`[](#%5F%5Fcodelineno-25-1)<key>CapawesomeLiveUpdateDefaultChannel</key>
[](#%5F%5Fcodelineno-25-2)<string>production-$(CURRENT_PROJECT_VERSION)</string>
`

`$(CURRENT_PROJECT_VERSION)` is the iOS equivalent of `versionCode`, and `capver` keeps it in sync with the Android version on every release.

### App Initialization[¶](#app-initialization "Permanent link")

At startup, the root component calls `ready()` immediately and registers a listener for `nextBundleSet` to prompt the user when a new bundle has been staged. In Angular this lives in the root component's constructor; in React it would be a `useEffect`, in Vue an `onMounted`:

app.component.ts

`[](#%5F%5Fcodelineno-26-1)private initializeLiveUpdate(): void {
[](#%5F%5Fcodelineno-26-2)  if (!Capacitor.isNativePlatform()) {
[](#%5F%5Fcodelineno-26-3)    return;
[](#%5F%5Fcodelineno-26-4)  }
[](#%5F%5Fcodelineno-26-5)  void LiveUpdate.ready();
[](#%5F%5Fcodelineno-26-6)  LiveUpdate.addListener('nextBundleSet', async event => {
[](#%5F%5Fcodelineno-26-7)    if (!event.bundleId) {
[](#%5F%5Fcodelineno-26-8)      return;
[](#%5F%5Fcodelineno-26-9)    }
[](#%5F%5Fcodelineno-26-10)    const confirmed = confirm('A new version is available. Install it now?');
[](#%5F%5Fcodelineno-26-11)    if (confirmed) {
[](#%5F%5Fcodelineno-26-12)      await LiveUpdate.reload();
[](#%5F%5Fcodelineno-26-13)    }
[](#%5F%5Fcodelineno-26-14)  });
[](#%5F%5Fcodelineno-26-15)}
`

Two things to notice. First, `ready()` is called unconditionally on app start, before anything else can fail — this is what makes automatic rollback work. Second, the prompt only fires when `event.bundleId` is non-null, which happens once the bundle has been fully downloaded and staged. The user never waits for a network call.

### GitHub Actions[¶](#github-actions "Permanent link")

The live update workflow is intentionally minimal. One input — the channel name — and one job that builds the web bundle in the cloud and deploys it to that channel.

.github/workflows/live-update.yml

`[](#%5F%5Fcodelineno-27-1)on:
[](#%5F%5Fcodelineno-27-2)  workflow_dispatch:
[](#%5F%5Fcodelineno-27-3)    inputs:
[](#%5F%5Fcodelineno-27-4)      channel:
[](#%5F%5Fcodelineno-27-5)        description: "Channel name"
[](#%5F%5Fcodelineno-27-6)        required: true
[](#%5F%5Fcodelineno-27-7)        type: string
[](#%5F%5Fcodelineno-27-8)
[](#%5F%5Fcodelineno-27-9)env:
[](#%5F%5Fcodelineno-27-10)  CAPAWESOME_CLI_VERSION: 4.9.3
[](#%5F%5Fcodelineno-27-11)  CAPAWESOME_CLOUD_APP_ID: 00000000-0000-0000-0000-000000000000
[](#%5F%5Fcodelineno-27-12)  NODE_VERSION: 24
[](#%5F%5Fcodelineno-27-13)
[](#%5F%5Fcodelineno-27-14)jobs:
[](#%5F%5Fcodelineno-27-15)  deploy-live-update:
[](#%5F%5Fcodelineno-27-16)    name: Deploy Live Update
[](#%5F%5Fcodelineno-27-17)    runs-on: ubuntu-latest
[](#%5F%5Fcodelineno-27-18)    steps:
[](#%5F%5Fcodelineno-27-19)      - uses: actions/setup-node@v4
[](#%5F%5Fcodelineno-27-20)        with:
[](#%5F%5Fcodelineno-27-21)          node-version: ${{ env.NODE_VERSION }}
[](#%5F%5Fcodelineno-27-22)      - name: Authenticate with Capawesome Cloud
[](#%5F%5Fcodelineno-27-23)        run: npx @capawesome/cli@${{ env.CAPAWESOME_CLI_VERSION }} login --token ${{ secrets.CAPAWESOME_CLOUD_TOKEN }}
[](#%5F%5Fcodelineno-27-24)      - name: Create channel
[](#%5F%5Fcodelineno-27-25)        env:
[](#%5F%5Fcodelineno-27-26)          CHANNEL: ${{ inputs.channel }}
[](#%5F%5Fcodelineno-27-27)        run: |
[](#%5F%5Fcodelineno-27-28)          npx @capawesome/cli@${{ env.CAPAWESOME_CLI_VERSION }} apps:channels:create \
[](#%5F%5Fcodelineno-27-29)            --app-id ${{ env.CAPAWESOME_CLOUD_APP_ID }} \
[](#%5F%5Fcodelineno-27-30)            --name "$CHANNEL" \
[](#%5F%5Fcodelineno-27-31)            --ignore-errors
[](#%5F%5Fcodelineno-27-32)      - name: Build and deploy live update
[](#%5F%5Fcodelineno-27-33)        env:
[](#%5F%5Fcodelineno-27-34)          CHANNEL: ${{ inputs.channel }}
[](#%5F%5Fcodelineno-27-35)        run: |
[](#%5F%5Fcodelineno-27-36)          npx @capawesome/cli@${{ env.CAPAWESOME_CLI_VERSION }} apps:builds:create \
[](#%5F%5Fcodelineno-27-37)            --app-id ${{ env.CAPAWESOME_CLOUD_APP_ID }} \
[](#%5F%5Fcodelineno-27-38)            --platform web \
[](#%5F%5Fcodelineno-27-39)            --channel "$CHANNEL" \
[](#%5F%5Fcodelineno-27-40)            --git-ref ${{ github.sha }} \
[](#%5F%5Fcodelineno-27-41)            --yes
`

A few details worth calling out:

* **`workflow_dispatch` instead of auto-on-merge.** Live updates ship when a human says they do. Merging to `main` doesn't trigger a release — the team triggers the workflow when they're ready, with the channel name they want to deploy to. The same dispatch model is used for native releases.
* **`apps:channels:create --ignore-errors` is idempotent.** First deploy to a brand-new native version creates the channel; every deploy after that is a no-op. No conditional logic needed, and unused channels get cleaned up automatically by Capawesome Cloud — there's nothing to garbage-collect on your side.
* **`apps:builds:create --platform web` builds in the cloud, not on the runner.** The web bundle is built inside Capawesome Cloud, so every deploy uses the same environment, the same Node version, and the same secrets. No "works on my machine," no drift between local builds and CI builds.
* **Pinning the CLI version.** `@capawesome/cli@4.9.3` is pinned. Workflows are reproducible, and CLI upgrades happen on purpose, not silently.

To deploy a live update for native version 60003, the team triggers the workflow with `production-60003` as the channel. The Cloud builds the bundle from the current `main`, uploads it, and devices on native version 60003 pick it up on their next launch.

For the matching native release workflow (which builds and submits binaries to TestFlight and the Play Store), the [release.yml](https://github.com/dhbw-vs/app/blob/main/.github/workflows/release.yml) in the same repo is a good companion read.

## Bonus: Video Walkthrough[¶](#bonus-video-walkthrough "Permanent link")

If you'd rather see the full Capawesome Cloud setup walked through end-to-end, this companion video covers the first deployment, channel setup, and runtime behavior in a real Capacitor project:

## Get Started[¶](#get-started "Permanent link")

You have two paths.

**With an AI agent.** Add the [Capawesome agent skills](/blog/announcing-open-source-ai-agent-skills-for-capacitor/) to your project and ask Claude Code or Cursor to set up live updates. The `capawesome-live-updates` skill walks the agent through everything: creating the Cloud app, installing the SDK, wiring up channels, generating signing keys, and integrating with CI/CD.

**By hand.** Follow the [Live Updates setup guide](/docs/cloud/live-updates/setup/). Start with `autoUpdateStrategy: 'background'` plus a `nextBundleSet` listener for the "Always Latest" prompt, versioned channels in your native config, and the rollback options on. That's a production-ready setup in under an hour.

[Try Capawesome Cloud Free](https://capawesome.io)

## Conclusion[¶](#conclusion "Permanent link")

Live updates are the difference between releasing fixes in minutes and releasing them in days. The recommended path is the same for almost every team: the **Always Latest** strategy so users are informed when an update is ready, **versioned channels** configured natively so version compatibility is automatic, **zip** delivery for predictable bundle sizes, **automatic rollback** so a broken bundle is self-healing, and **code signing** when you ship to production. Everything else — rollouts, force updates, push-driven hotfixes, self-hosting — sits on top of that core.

If you want to go deeper from here:

* [Exploring the Capacitor Live Update API](/blog/exploring-the-capacitor-live-update-api/) — a tour of every method the plugin exposes, with code samples for each.
* [How Live Updates for Capacitor Work Under the Hood](/blog/how-live-updates-for-capacitor-work/) — the internals: server paths, platform-specific storage, code signing mechanics, and the App Store compliance story in detail.
* [How to Reduce the Bundle Size of Capacitor Live Updates](/blog/how-to-reduce-the-bundle-size-of-capacitor-live-updates/) — the optimization playbook with a real before-and-after.
* [Capacitor App Updates: The Complete Guide](/blog/capacitor-app-update-guide/) — pairing live updates with native app store updates for a complete update strategy.

Questions, feedback, or war stories from your own live update rollout? Drop into the [Capawesome Discord server](https://discord.gg/VCXxSVjefW) — that's where we're always happy to chat. And if you'd like the next deep-dive in your inbox, subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter).

May 16, 2026 

 Back to top 