Checking for Updates

Detect available updates, download them, and install them in your Tauri v2 application using the updater plugin with React and Vite

When you ship a desktop application, the version users install on day one will eventually become outdated. Bugs get fixed, features get added, and security patches need to reach every machine running your software. The updater plugin lets your app check a remote server or a static JSON file, determine if a newer version exists, download the update, and apply it—all without the user hunting down an installer on a website.

The updater plugin is not a background service that polls on a timer by default. It gives you the check() function. You decide when to call it: on app startup, when the user clicks a "Check for Updates" button, or on a schedule you build yourself.

How the Updater Knows an Update Is Available

The updater compares the version embedded in your application with the version advertised by a remote JSON file. The JSON file lives at one or more URLs you configure in tauri.conf.json. Tauri's updater follows a simple rule: if the remote version is greater than the current version, an update is available. The version comparison respects semantic versioning, so 1.2.0 is newer than 1.1.0, 2.0.0 is newer than 1.9.9, and pre-release identifiers are handled correctly.

The remote JSON file must contain a version field, optional notes for release notes, a pub_date, and a platforms object with signatures and download URLs for each platform you support. The signatures are generated with a private key during your build process, and the app verifies them against a public key you embed. This guarantees the update package has not been tampered with between your build machine and the user's device.

Prerequisites

Before you write any checking logic, you need the updater plugin installed, keys generated, and permissions granted. The following steps are a quick setup reference; for full installation details, see the introduction to the updater plugin.

Install the Rust crate:

[dependencies]
tauri-plugin-updater = "2"

Add it to your Tauri builder in lib.rs:

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_updater::Builder::new().build())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Install the JavaScript bindings:

npm install @tauri-apps/plugin-updater

You will also need the dialog and process plugins for a complete user experience—they let you ask the user for confirmation and restart the app after the update installs. Install them the same way.

Generate a signing key pair:

npm run tauri signer generate -- -w ~/.tauri/myapp.key

This creates a private key (myapp.key) and a public key (myapp.key.pub). Store the private key and its password securely. The public key goes into your Tauri configuration.

Add the required permissions to your capabilities file:

{
  "identifier": "default",
  "description": "Default capabilities for the app",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "updater:default",
    "updater:allow-check",
    "updater:allow-download-and-install",
    "dialog:default",
    "dialog:allow-ask",
    "dialog:allow-message",
    "process:default",
    "process:allow-restart"
  ]
}

Missing Permissions:

If you forget updater:allow-check or updater:allow-download-and-install, the JavaScript API calls will fail silently or throw a permissions error. The app will appear to work until the moment it tries to contact the update server.

Configuring Endpoints and the Public Key

The updater configuration sits inside tauri.conf.json under plugins.updater. You need at least one endpoint URL and the public key content.

{
  "bundle": {
    "createUpdaterArtifacts": true
  },
  "plugins": {
    "updater": {
      "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUzR...",
      "endpoints": [
        "https://releases.myapp.com/{{target}}/{{arch}}/{{current_version}}",
        "https://cdn.myapp.com/latest.json"
      ]
    }
  }
}

The pubkey field takes the entire content of the .pub file as a string, not a file path. The endpoints array contains URLs that the updater will try in order. Tauri automatically substitutes {{target}} (e.g., windows, linux, darwin), {{arch}} (e.g., x86_64, aarch64), and {{current_version}} into the URLs, which lets your server respond differently per platform and version.

TLS in Production:

In production builds, Tauri enforces HTTPS for endpoints. If you need to test with HTTP locally, set "dangerousInsecureTransportProtocol": true—but never leave that enabled in a release build.

The bundle.createUpdaterArtifacts field must be set to true (or "v1Compatible" for migrating from Tauri v1). This tells the Tauri CLI to produce the .sig signature files and archive bundles during the build. Without this, your build will not generate the artifacts that the updater expects.

Checking for Updates from the Frontend

The core API is the check() function from @tauri-apps/plugin-updater. It contacts the configured endpoints, compares versions, and returns either an Update object or null.

import { check } from "@tauri-apps/plugin-updater";
const update = await check();
if (update === null) {
  console.log("No update available");
} else {
  console.log(`Update available: ${update.version}`);
  console.log(`Release notes: ${update.body}`);
  console.log(`Current version: ${update.currentVersion}`);
}

When check() returns null, the app is already on the latest version. When it returns an Update, the version property contains the new version string and body contains the release notes from the remote JSON notes field. The currentVersion property holds the version the app is currently running.

The available Property Is Deprecated:

Older code might check update.available. In Tauri v2, this property is always true when the object exists. The correct way to check for updates is to test whether check() returns null. If you see tutorials that use if (update?.available), they are working by accident—the check succeeds only because the object is not null.

The check() function accepts an optional CheckOptions object. You can pass custom headers, a proxy URL, a timeout in milliseconds, or a specific target string to override the auto-detected platform.

const update = await check({
  timeout: 10000,
  headers: { "X-Custom-Header": "value" },
});

This is useful when your update server requires authentication or when you need to route traffic through a corporate proxy.

Downloading and Installing Updates

Once you have an Update object, you can download and install the new version. The simplest approach is downloadAndInstall(), which handles both steps in one call.

if (update) {
  await update.downloadAndInstall();
}

This downloads the archive to a temporary location, verifies its signature against the embedded public key, extracts it, and prepares the installer. On Windows, the installer runs in passive mode by default—a small progress window appears without requiring user input. On macOS and Linux, the new application bundle replaces the old one.

If you need more control, you can call download() and install() separately. The download() function accepts a progress callback that receives DownloadEvent objects.

await update.download((event) => {
  switch (event.event) {
    case "Started":
      console.log("Download started", event.data.contentLength);
      break;
    case "Progress":
      console.log(
        `Downloaded ${event.data.chunkLength} of ${event.data.contentLength}`
      );
      break;
    case "Finished":
      console.log("Download finished");
      break;
  }
});
await update.install();

The DownloadEvent can be one of three shapes: Started with the total content length, Progress with the cumulative downloaded bytes, or Finished indicating completion. Use this to display a progress bar or percentage in your UI.

Do Not Forget to Close the Update Resource:

The Update object holds a system resource. If you do not call downloadAndInstall(), download(), or explicitly close(), the resource will leak. Tauri's garbage collection will eventually clean it up, but in a long-running app, call update.close() if the user declines the update.

Building the User-Facing Flow with React

An update check is not a background task that should surprise the user. You need to show a dialog asking whether they want to install the update, and after installation you need to restart the application. The dialog and process plugins provide these capabilities.

import { check } from "@tauri-apps/plugin-updater";
import { ask, message } from "@tauri-apps/plugin-dialog";
import { relaunch } from "@tauri-apps/plugin-process";
export async function checkForUpdates(onUserClick: boolean = false) {
  const update = await check();
  if (update === null) {
    if (onUserClick) {
      await message("You are on the latest version.", {
        title: "No Update Available",
        kind: "info",
      });
    }
    return;
  }
  const userWantsUpdate = await ask(
    `Version ${update.version} is available.\n\n${update.body ?? ""}`,
    {
      title: "Update Available",
      kind: "info",
      okLabel: "Update",
      cancelLabel: "Later",
    }
  );
  if (userWantsUpdate) {
    await update.downloadAndInstall();
    await relaunch();
  } else {
    await update.close();
  }
}

This function does three things: checks for updates, prompts the user if one is found, and restarts the app after installation. The onUserClick parameter distinguishes between an automatic check on startup (where you probably don't want to bother the user with a "no update" message) and a manual check triggered by a button click.

Now wire it into a React component that checks on mount and also exposes a manual button.

import { useEffect } from "react";
import { checkForUpdates } from "./utils/updater";
function App() {
  useEffect(() => {
    checkForUpdates(false);
  }, []);
  return (
    <div>
      <h1>My Tauri App</h1>
      <button onClick={() => checkForUpdates(true)}>
        Check for Updates
      </button>
    </div>
  );
}
export default App;

The useEffect with an empty dependency array runs once when the component mounts. It passes false for onUserClick, so no "no update" dialog appears if the app is current. The button click passes true, giving the user feedback even when there is nothing to download.

Everything Is Working If:

After you build an older version, bump the version in Cargo.toml and tauri.conf.json, rebuild, and push the new release artifacts and latest.json to your server, the older app should display the update dialog when you launch it. If you see the dialog, your endpoint configuration, signing keys, and frontend logic are all correct.

Common Mistakes When Checking for Updates

Several mistakes trip up developers new to the updater plugin. Knowing them in advance saves hours of debugging.

Testing in development mode without setting an endpoint. The dev build mode does not enforce TLS, but if you have no endpoint configured at all, check() will have nowhere to send the request. Always set at least one endpoint, even during development, and consider using a local static JSON file for testing.

Forgetting to bump the version in all three places. The app version lives in tauri.conf.json (top-level version), Cargo.toml (package.version), and package.json (version). If tauri.conf.json still says 0.1.0 while the remote JSON says 0.2.0, the updater will see a new version. But your app metadata will be inconsistent, and the install might not behave as expected.

Not regenerating signatures after a build change. The .sig files must match the exact binary they accompany. If you rebuild without regenerating the updater artifacts, the signatures will be stale and the updater will reject the download. Always run a full tauri build with the signing environment variables set when producing a release.

The Most Consequential Mistake:

Checking update.available instead of testing for null. Code like if (update?.available) will never fail, because update is either an object with available always true, or null. The correct check is if (update !== null). If you use the wrong pattern, your "no update available" logic will never execute.

Not calling relaunch() after installation. The downloadAndInstall() function prepares the new version, but the user is still running the old binary until the process restarts. Call relaunch() from @tauri-apps/plugin-process to shut down the current instance and start the updated one.

Missing dialog permissions when prompting the user. The ask() and message() functions need dialog:allow-ask and dialog:allow-message permissions. Without them, the app will crash or hang when it tries to show the confirmation dialog.

Summary

Checking for updates with Tauri v2 means calling check(), interpreting its return value, and deciding what to do next. The updater plugin handles version comparison, signature verification, and platform-specific installation mechanics. Your job is to decide when to check, how to inform the user, and what to do after the update installs.

The most important insight from this section: an update check is a conversation, not a surprise. Always let the user decide whether to install now or later. Always restart the app after installation. And always handle the case where check() returns null gracefully—because for most of your users, most of the time, there is no update.