Electron OTA Updates (Beta)

An Electron app usually ships every change through a new installer and an auto-updater. With Zephyr, you can move part of the renderer into a Module Federation remote, deploy that remote to Zephyr Cloud, and have the installed app pick up new releases from a Zephyr environment. Users get the update in seconds, without a download or a restart, and a rollback is just as fast.

This guide builds that flow with Vite. One feature of the app (a settings page, a dashboard, a usage view) becomes a remote. The app loads it from an environment URL, watches that URL for new releases, and offers an in-place update when one appears.

Beta

Electron OTA support is currently in beta. APIs and recommended wiring may change while the Electron integration continues to stabilize.

Renderer code only

OTA updates replace JavaScript and CSS that the renderer loads through Module Federation. Changes to the main process, preload scripts, native modules, the Electron version, and the app shell still need a normal release through your installer or auto-updater.

How It Works

  1. The feature lives in its own Vite app that exposes a component through Module Federation.
  2. A release build sets ZE_ENV=production. Zephyr uploads it as a new immutable version labeled with that environment.
  3. A tag with an Environment condition matches the newest version labeled production. The production environment follows that tag, so every release build goes live automatically.
  4. The Electron renderer loads remoteEntry.js from the production environment URL with the Module Federation runtime.
  5. While the app runs, it polls the environment's remoteEntry.js. When the file changes, a new release is live, and the app shows an update prompt.
  6. Applying the update loads the new release and swaps the component in place. No reload, no restart.

Rolling back works the same way: when the environment serves an older version, the app sees a different entry and offers it as the next update.

Prerequisites

  • An Electron app whose renderer is built with Vite
  • A Zephyr account and the Prerequisites setup
  • A feature you can render as a single React component

Create The Remote

Create a Vite app for the feature, next to the Electron app. Install the Module Federation and Zephyr plugins:

npm
yarn
pnpm
bun
deno
npm add --dev @module-federation/vite vite-plugin-zephyr cross-env

Expose the feature's component and share React as a singleton, so the remote renders with the app's React instead of its own copy:

apps/usage-remote/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import {
  federation,
  type ModuleFederationOptions,
} from '@module-federation/vite';
import { withZephyr } from 'vite-plugin-zephyr';

const mfConfig: ModuleFederationOptions = {
  name: 'usage',
  filename: 'remoteEntry.js',
  exposes: {
    './UsagePage': './src/UsagePage.tsx',
  },
  shared: {
    react: { singleton: true },
    'react-dom': { singleton: true },
  },
};

export default defineConfig({
  plugins: [react(), federation(mfConfig), withZephyr()],
  build: { target: 'esnext' },
});

Pin the Zephyr organization and project with a declarative configuration file, so release builds land in the same place no matter which Git remote the checkout uses:

apps/usage-remote/zephyr.config.ts
export default {
  org: 'acme',
  project: 'desktop',
};

Add a release script. ZE_ENV labels the build with the environment it is meant for. On its own, it doesn't deploy anything; the tag in the next step turns that label into a release.

apps/usage-remote/package.json
{
  "name": "usage",
  "scripts": {
    "build": "vite build",
    "release": "cross-env ZE_ENV=production vite build"
  }
}

Run release once now. Zephyr needs one version of the app before you can create its tag and environment.

Release Through A Tag

In the Zephyr dashboard, open the remote's application and set up two things:

  1. A tag named, for example, env-production, with one condition: Environment is production. It resolves to the newest version built with ZE_ENV=production.
  2. An environment named production that follows the env-production tag. See Environment Configuration for tag-based environments.

Copy the environment URL from the environment's page. It has the form https://production-{app-uid}-{hash}.{domain}, and the remote entry is at /remoteEntry.js under it.

From now on, pnpm release is the whole release process: the build uploads, the tag picks it up, and the environment serves it. Builds without ZE_ENV=production, such as local or preview builds, still upload as versions but never reach users.

Gate releases on CI

To release only from CI, add a CI condition to the tag next to the Environment condition. Local builds labeled production then stay out of the environment. See Tag Conditions.

Load The Remote In The App

The Electron renderer is the Module Federation host. It shares React with the same singleton settings as the remote and registers the remote at runtime, because the URL it loads changes with every release:

npm
yarn
pnpm
bun
deno
npm add --dev @module-federation/vite
npm
yarn
pnpm
bun
deno
npm add @module-federation/runtime
apps/desktop/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'desktop',
      remotes: {},
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
      },
    }),
  ],
  build: { target: 'esnext' },
});

Two small functions cover the Zephyr side. loadRelease loads whatever the environment serves now, and hasNewRelease tells you when that has changed:

apps/desktop/src/ota/usage-remote.ts
import { loadRemote, registerRemotes } from '@module-federation/runtime';
import type { ComponentType } from 'react';

const REMOTE_ENTRY =
  'https://production-usage-desktop-acme-ze.zephyrcloud.app/remoteEntry.js';

let loadedEntry: string | undefined;

async function fetchEntry() {
  const response = await fetch(REMOTE_ENTRY, { cache: 'no-store' });
  if (!response.ok)
    throw new Error(`remoteEntry.js returned ${response.status}`);
  return response.text();
}

/** Loads the release the environment serves now. Call it again to apply an update. */
export async function loadRelease() {
  const entry = await fetchEntry();
  const url = new URL(REMOTE_ENTRY);
  url.searchParams.set('release', String(Date.now()));
  registerRemotes([{ name: 'usage', entry: url.href, type: 'module' }], {
    force: true,
  });
  const module = await loadRemote<{ UsagePage: ComponentType }>(
    'usage/UsagePage',
  );
  if (!module) throw new Error('usage/UsagePage did not load');
  loadedEntry = entry;
  return module.UsagePage;
}

/** True when the environment serves a different release than the one loaded. */
export async function hasNewRelease() {
  const entry = await fetchEntry().catch(() => undefined);
  // Nothing loaded yet (for example, the app started offline) counts as new.
  return entry !== undefined && entry !== loadedEntry;
}

Two details make the in-place update work:

  • The query string. Browsers cache ES modules by URL, and the environment URL never changes. Without a new URL per load, the browser returns the module it already evaluated.
  • force: true. It replaces the registered remote and clears the runtime's cached module, so loadRemote evaluates the new release instead of returning the old one.

Polling compares the text of remoteEntry.js. Each release lists different chunk hashes, so a new release always changes the file.

Render The Feature And Offer Updates

Keep the loaded component in your app's state, and render it where the feature used to be. Keep a copy of the feature bundled with the app as the fallback for when the remote can't load: the user is offline, the edge is unreachable, or a release throws while rendering.

apps/desktop/src/routes/UsageRoute.tsx
import type { ComponentType } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { BundledUsagePage } from '../usage/UsagePage';

// `Page` comes from loadRelease(), or is undefined if it failed.
export function UsageRoute({ Page }: { Page?: ComponentType }) {
  if (!Page) return <BundledUsagePage />;
  return (
    <ErrorBoundary resetKeys={[Page]} fallback={<BundledUsagePage />}>
      <Page />
    </ErrorBoundary>
  );
}

resetKeys clears the error boundary when a new release is applied, so a fixed release replaces the fallback. The example uses react-error-boundary, but any error boundary that supports resetting works.

Call hasNewRelease() on an interval from the app shell, so the update can be offered on any screen, not only the federated one. Show it wherever your app already reports updates, such as a status bar item or the auto-updater's indicator. When the user applies it, call loadRelease() again and store the new component. If the federated screen is open, it re-renders with the new release. If it isn't, the user sees the new release the next time they open it. Nothing has to navigate or reload.

Allow The Remote In The Content Security Policy

Electron apps usually ship a strict Content Security Policy. Allow the origin of the environment URL you copied from the dashboard for scripts (the remote entry and its chunks) and for fetch (polling). Zephyr environment URLs end in zephyrcloud.app or zephyr-cloud.io, so use whichever domain yours ends in:

script-src 'self' https://*.zephyrcloud.app;
connect-src 'self' https://*.zephyrcloud.app;

If the remote ships CSS, Vite loads it with a <link rel="stylesheet"> tag, so add the same origin to style-src. Add it to font-src and img-src too if the remote ships fonts or images.

Set it wherever your app defines its policy: a <meta http-equiv="Content-Security-Policy"> tag in index.html, the headers of a custom protocol handler, or session.webRequest.onHeadersReceived. If you serve Zephyr from a custom domain or your own cloud integration, allow that origin instead.

Ship And Roll Back

With the app installed and running:

  1. Change the feature and run pnpm release in the remote.
  2. Within one poll interval, the update prompt appears in the app.
  3. Apply it. The feature re-renders with the new release, and app state outside it is untouched.

To roll back, deploy the previous version to the production environment from the dashboard (Instant Rollbacks), or run pnpm release again from the previous commit. Either way, the environment serves a different entry, and the app offers it as the next update.

Lock production during an incident

Lock the environment after a rollback so a new release build can't replace it until you unlock it.

Troubleshooting

The update applies, but the old release keeps rendering. The runtime is reusing the module it already evaluated. Register the remote with a URL that changes per release (the release query string above) and pass force: true to registerRemotes.

The remote's styles break the app layout. If the remote ships its own utility CSS (for example, Tailwind), its rules can land after the host's and override them. A remote .hidden that comes after the host's md:block hides host elements at every width. Put the remote's utilities in a lower cascade layer than the host's, such as @import "tailwindcss/utilities" layer(components), or scope them to the remote's root element.

The app retries a broken release on every poll. hasNewRelease() keeps returning true after loadRelease() fails, because the loaded release hasn't changed. Remember the remoteEntry.js text of the release that failed, and offer the update again only when the environment serves a different one.

React hooks throw "Invalid hook call". The remote is running its own copy of React. Make sure both the host and the remote declare react and react-dom as singleton shared dependencies.

Nothing updates after a release build. Check that the build ran with ZE_ENV=production, that the new version appears under the env-production tag, and that the production environment follows that tag rather than a fixed version.