Detect a Chrome Extension's First Install vs. Update with chrome.runtime.onInstalled
5 min readModerok team
How to tell a first install from an update in a Chrome extension using chrome.runtime.onInstalled reason, and track version adoption.
To detect whether a Chrome extension is being installed for the first time or updated to a new version, listen to chrome.runtime.onInstalled and read details.reason. It is "install" on a brand-new install and "update" when an existing install moves to a new version. On an update you also get details.previousVersion, the version the user was running before. That single event is how you show a welcome page on first install, run a data migration on update, and count how fast a new version actually reaches your users.
Here is the smallest version that covers both cases:
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === "install") {
// First time this profile has the extension
chrome.tabs.create({ url: "welcome.html" });
} else if (details.reason === "update") {
const from = details.previousVersion; // e.g. "1.4.2"
const to = chrome.runtime.getManifest().version; // e.g. "1.5.0"
console.log(`Updated ${from} -> ${to}`);
}
});
The four reasons onInstalled can fire
details.reason is not a boolean. In Chrome it takes one of four string values, and treating them all as "the user did something" is the most common mistake:
reason | Fires when |
|---|---|
"install" | The extension is installed for the first time in this profile |
"update" | The extension is updated to a new version (includes previousVersion) |
"chrome_update" | The browser itself was updated, not your extension |
"shared_module_update" | A shared module your extension imports was updated |
The values that matter for almost every extension are "install" and "update". "chrome_update" is the one people forget: it fires when Chrome updates itself, and your extension code is exactly the same as before. If you run a migration or bump an "onboarding version" counter on every onInstalled, a chrome_update will trigger it wrongly. Always branch on the specific reason instead of running your logic in the listener body unconditionally.
previousVersion is only present when reason === "update". It is undefined for "install" and "chrome_update", so guard for it before comparing.
Register the listener synchronously (MV3 gotcha)
Under Manifest V3 your background context is a service worker that Chrome stops when idle and restarts on the next event. This changes how you have to register onInstalled.
onInstalled fires once, when the extension is installed or updated. It does not fire again every time the service worker wakes back up, and it does not fire on browser startup (that is chrome.runtime.onStartup). Because the worker can be torn down at any time, you have to add your listener at the top level of the service worker, before any await, so it is registered every time the worker boots. If you register it inside an async callback or after awaiting storage, the worker can be restarted by an install or update event that arrives before your listener exists, and you miss it.
// Good: top-level, runs on every worker start
chrome.runtime.onInstalled.addListener(handleInstalled);
async function handleInstalled(details) {
if (details.reason === "install") {
await chrome.storage.local.set({ installedAt: Date.now() });
}
}
// Bad: listener registers only after async work finishes
loadConfig().then(() => {
chrome.runtime.onInstalled.addListener(handleInstalled);
});
If you have run into the worker stopping mid-task, the same lifecycle rules are why. We covered the timing in why your service worker keeps stopping.
Reading the current version
onInstalled tells you the previous version on an update. To get the version the user is now on, read the manifest at runtime:
const current = chrome.runtime.getManifest().version;
getManifest() returns the parsed manifest.json, so version is whatever string you shipped, for example "1.5.0". Combined with previousVersion you have both ends of the jump, which is exactly what you need to decide whether a migration should run:
if (details.reason === "update") {
const prev = details.previousVersion;
const curr = chrome.runtime.getManifest().version;
if (prev && isOlderThan(prev, "1.5.0")) {
await migrateStorageToV150();
}
}
Write your own isOlderThan with a simple numeric split on .; there is no built-in semver compare in the extension APIs.
Firefox uses browser_update, not chrome_update
If you ship the same code to Firefox, note one naming difference. Firefox exposes the same event through browser.runtime.onInstalled, and the "the browser updated itself" reason is "browser_update" there, where Chromium browsers use "chrome_update". The "install", "update", and "shared_module_update" values are the same across both. If you explicitly check for the browser-update case, handle both strings:
if (details.reason === "chrome_update" || details.reason === "browser_update") {
return; // not our code changing; skip migrations
}
For a fuller look at the Chrome-vs-Firefox background differences, see the Firefox event page vs service worker post.
Tracking version adoption
Detecting an update locally is useful for migrations, but the reason most teams reach for this event is to answer a product question: how fast does a new release reach real users? The Chrome Web Store dashboard shows a total user count, not a breakdown by installed version, so it cannot tell you whether last week's release is on 20% or 90% of installs.
You get that breakdown by sending an event when onInstalled fires with reason === "update", tagged with both the old and new version, plus the current version on install:
chrome.runtime.onInstalled.addListener((details) => {
const version = chrome.runtime.getManifest().version;
if (details.reason === "install") {
sendAnalytics("install", { version });
} else if (details.reason === "update") {
sendAnalytics("update", {
version,
previousVersion: details.previousVersion,
});
}
});
Once those events land in a system that can group by version, "share of active users on 1.5.0" and "how long the 1.4 to 1.5 rollout took" become straightforward counts. Because onInstalled fires on an actual install or update rather than every worker restart, each browser emits a version transition event only for that lifecycle change.
How Moderok records this for you
If you use Moderok, you do not have to wire this listener yourself. After you install the SDK, add the storage permission, and initialize it, the SDK registers chrome.runtime.onInstalled synchronously and records lifecycle events automatically:
import { Moderok } from "@moderok/sdk";
Moderok.init({ appKey: "mk_your_app_key" });
On a first install it sends an __install event. On an update it sends an __update event, and when Chrome provides previousVersion the SDK attaches it as a previousVersion property. Every event also carries the extension's current version, read from chrome.runtime.getManifest().version, in its context. Moderok shows automatic install and update counts in the dashboard and preserves those version details in raw event context; the current dashboard does not calculate a version-adoption breakdown for you. The lifecycle events sit alongside the uninstall signal you can add with setUninstallUrl.
The SDK is 5.7 kB gzipped, has zero runtime dependencies, and was written for MV3 background contexts. If you want automatic update counts with version context in the raw events, read the SDK docs, add the storage permission, and initialize it in your service worker.