• Chrome Extensions
  • Manifest V3
  • Analytics
  • Installs

chrome.runtime.onInstalled: detect installs and updates, run migrations and count installs in Manifest V3

How chrome.runtime.onInstalled works in Manifest V3: install vs update vs chrome_update, welcome pages, data migrations, testing, and counting installs.

Author
Extenify Team
Published
Reading time
12 min read
A stone arch with the words Welcome and Willkommen in Luzern in gold letters under a station clock and a cloudy sky

chrome.runtime.onInstalled is the one event every extension handles and few handle completely. It opens the welcome page on install, but it also fires on every update and on every Chrome update, it is the only safe place to migrate stored data between versions, and it is the closest thing an extension has to a reliable install signal. Get the reason checks wrong and you show the onboarding tab after every browser update, double-count installs, or lose a setting during a migration.

This guide covers how chrome.runtime.onInstalled behaves in a Manifest V3 service worker, a complete handler you can copy (welcome page, versioned migrations, context menus), how to test install and update locally, why the event sometimes "does not fire", the newer onEnabled and onExtensionLoaded events, and how to count installs from it without confusing your analytics. Behavior is taken from the Chrome and MDN references as checked in October 2026, and the handler below was exercised against a mocked chrome API.

Key takeaways

  • onInstalled fires with one of four reasons: install, update, chrome_update and shared_module_update. Branch on reason, every time.
  • Register the listener synchronously at the top level of the service worker, or it can miss the event.
  • Reloading an unpacked extension fires update, usually with the same version as previousVersion. Guard migrations and changelog tabs against that.
  • Run versioned migrations in update and record the schema version after each step, because the service worker can stop mid-way.
  • Count installs only on reason === 'install'. Chrome now ships a new version every two weeks, and every one of them fires chrome_update.

What chrome.runtime.onInstalled does

The chrome.runtime reference describes the event in one line: it is "Fired when the extension is first installed, when the extension is updated to a new version, and when Chrome is updated to a new version." The listener receives a details object:

Field Present when Meaning
reason Always install, update, chrome_update or shared_module_update
previousVersion reason is update The version the user had before this update
id reason is shared_module_update The ID of the shared module that updated
temporary (Firefox only) Always in Firefox true when loaded from about:debugging or web-ext run

The temporary flag comes from MDN's runtime.onInstalled page, which also notes that it "is not the same as management.onInstalled": the runtime event only fires for your own extension.

Where it sits in the service worker lifecycle

Chrome's service worker lifecycle guide lists three events on install or update, in order: the service worker install event, then runtime.onInstalled, then the service worker activate event. It recommends onInstalled "to set a state or for one-time initialization, such as a context menu." Notice the distinction in the same guide: the event fires when "the extension (not the service worker)" is installed or updated. Your service worker starting again after being idle does not trigger it.

For browser startup there is a separate event. runtime.onStartup is "Fired when a profile that has this extension installed first starts up", so code that must run on every launch, such as restoring an alarm you forgot to persist, belongs there and not in onInstalled.

A complete onInstalled handler for Manifest V3

Here is a handler that covers the jobs most extensions need: context menus on every reason, an install record and welcome page on install, versioned migrations and an optional changelog tab on update, and nothing on chrome_update.

// background.js: the Manifest V3 service worker.
const WELCOME_URL = 'https://example.com/welcome';
const UPDATED_URL = 'https://example.com/changelog';

// Registered at the top level, synchronously, before any await.
chrome.runtime.onInstalled.addListener((details) => {
  handleInstalled(details).catch((err) => console.error('onInstalled failed', err));
});

async function handleInstalled({ reason, previousVersion, temporary }) {
  const { version } = chrome.runtime.getManifest();

  // Runs for every reason: context menus are safe to recreate.
  await chrome.contextMenus.removeAll();
  chrome.contextMenus.create({ id: 'save-tab', title: 'Save this tab', contexts: ['page'] });

  if (reason === 'install') {
    await chrome.storage.local.set({
      installId: crypto.randomUUID(),
      installedAt: new Date().toISOString(),
      installVersion: version,
      schemaVersion: version,
    });
    // Firefox sets `temporary` for about:debugging and web-ext run.
    if (!temporary) await chrome.tabs.create({ url: WELCOME_URL });
    return;
  }

  if (reason === 'update') {
    // Reloading an unpacked extension also arrives here, often with the same version.
    if (previousVersion === version) return;
    await runMigrations(previousVersion);
    if (isMajorBump(previousVersion, version)) {
      await chrome.tabs.create({ url: `${UPDATED_URL}?from=${previousVersion}` });
    }
    return;
  }

  // 'chrome_update' and 'shared_module_update': nothing to set up, and
  // never count these as installs.
}

Three details in that code prevent most bug reports:

  1. The listener is synchronous; the work is not. The listener itself only calls an async function and attaches a .catch. An unhandled rejection inside an async listener is easy to miss in a service worker you rarely inspect.
  2. Context menus are recreated on every reason. removeAll() before create() avoids the "Cannot create item with duplicate id" error when the event fires again after an update or browser update.
  3. The changelog tab only opens on a major version bump. Opening a tab on every patch release is the fastest way to earn a one-star review that says "stop opening tabs". If you ship often, show a dot on the toolbar icon instead.

chrome.tabs.create does not need the tabs permission, and chrome.contextMenus needs the contextMenus permission in the manifest.

Run data migrations on update

Stored data outlives your code. When version 2.0 moves saved items from storage.sync to storage.local, or renames a setting, the move has to happen once, in order, for every user who skipped a few versions. onInstalled with reason === 'update' is the right trigger, and previousVersion tells you where the user came from.

function parseVersion(v) {
  return String(v).split('.').map((part) => Number.parseInt(part, 10) || 0);
}

function compareVersions(a, b) {
  const x = parseVersion(a);
  const y = parseVersion(b);
  for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
    const diff = (x[i] ?? 0) - (y[i] ?? 0);
    if (diff !== 0) return Math.sign(diff);
  }
  return 0;
}

function isMajorBump(from, to) {
  return parseVersion(to)[0] > parseVersion(from)[0];
}

// Each step runs once, in order, for users coming from an older version.
const MIGRATIONS = [
  {
    version: '2.0.0',
    async run() {
      const { savedTabs } = await chrome.storage.sync.get('savedTabs');
      if (savedTabs) {
        await chrome.storage.local.set({ savedTabs });
        await chrome.storage.sync.remove('savedTabs');
      }
    },
  },
  {
    version: '2.4.0',
    async run() {
      const { theme } = await chrome.storage.local.get('theme');
      if (theme === 'auto') await chrome.storage.local.set({ theme: 'system' });
    },
  },
];

async function runMigrations(previousVersion) {
  const stored = await chrome.storage.local.get('schemaVersion');
  let from = stored.schemaVersion ?? previousVersion;
  for (const step of MIGRATIONS) {
    if (compareVersions(from, step.version) < 0) {
      await step.run();
      from = step.version;
      await chrome.storage.local.set({ schemaVersion: from }); // resume here if the worker stops
    }
  }
  await chrome.storage.local.set({ schemaVersion: chrome.runtime.getManifest().version });
}

Why store a separate schemaVersion instead of trusting previousVersion alone? A Manifest V3 service worker can be stopped while a migration is still running. If that happens, the next onInstalled will not come until the next update, so the migration needs its own record of how far it got. Each step should also be safe to run twice, which is why the 2.0.0 step checks that savedTabs exists before moving it.

Chrome extension versions are one to four dot-separated integers, so a numeric comparison like compareVersions is enough. Never compare versions as strings: '2.10.0' < '2.9.0' is true in JavaScript.

Also remember which storage survives an update. The chrome.storage reference says storage.session "is cleared if the extension is disabled, reloaded, updated, and when the browser restarts". Anything a migration needs must live in storage.local or storage.sync.

Content scripts after an update

Updates have a side effect that onInstalled is often used to repair. Content scripts that were injected into open tabs by the old version keep running, but they lose their connection to the extension: calls to chrome.runtime from them fail, which surfaces as the familiar "Extension context invalidated" error. Manifest-declared content scripts for the new version are only injected when a page loads, so already-open tabs have no working copy until they are reloaded. This long-running Stack Overflow thread describes the same symptom.

Two fixes, used together:

  • In the content script, check chrome.runtime?.id before talking to the extension. When it is undefined, the context is gone: remove your UI and stop listeners instead of throwing.
  • In onInstalled, re-inject into open tabs with chrome.scripting.executeScript. This needs the scripting permission and host permissions for those sites, and every injection should be wrapped in a catch, because tabs such as chrome:// pages cannot be scripted.
async function reinjectContentScripts() {
  for (const script of chrome.runtime.getManifest().content_scripts ?? []) {
    const tabs = await chrome.tabs.query({ url: script.matches });
    for (const tab of tabs) {
      if (tab.discarded || !script.js?.length) continue;
      chrome.scripting
        .executeScript({ target: { tabId: tab.id, allFrames: !!script.all_frames }, files: script.js })
        .catch(() => {}); // restricted page, closed tab or missing access
    }
  }
}

Call it for install and update. Make the content script idempotent (check for an existing root element before adding one), since a tab can end up with both an old orphaned copy and a fresh one.

How to test onInstalled locally

You do not need to publish anything to test the install and update paths.

  • Install: remove the extension from chrome://extensions, then load the unpacked folder again. That fires install.
  • Update: the runtime reference states that "When an unpacked extension is reloaded, this is treated as an update", including reloads through chrome.runtime.reload(). To test a real migration, set the manifest version back to an older value, reload once, then set it to the new value and reload again, so previousVersion holds the older version.
  • Logs: the event fires the moment the worker starts, often before you can open its DevTools. Write a short debug entry to chrome.storage.local from the handler and read it afterwards, or open the service worker console first and then click reload.
  • Firefox: web-ext run and about:debugging set details.temporary to true, which the handler above uses to skip the welcome tab during development.

The chrome_update reason is harder to trigger on demand. Keep that branch trivial so there is little to test.

Why chrome.runtime.onInstalled does not fire

When a developer reports that the event "never fires", it is almost always one of these:

  1. The listener is registered asynchronously. Chrome's guide to extension service worker events says handlers "need to be declared in the global scope" so that "they are registered synchronously on initial script execution." A listener added after an await, inside a storage.get callback, or after a dynamic import can miss the event.
  2. The service worker failed to register. A syntax error or an exception at the top level of the worker, before addListener runs, means no listener exists. Chrome reports this as "Service worker registration failed" on the extension's card in chrome://extensions, a message with tens of thousands of views on Stack Overflow.
  3. You expected it on browser start. That is runtime.onStartup, not onInstalled.
  4. You expected it when the user re-enables the extension. Enabling is not an install or update. Chrome 155 added a separate event for it, covered next.
  5. You are looking in the wrong console. console.log from the handler goes to the service worker's DevTools, not the popup's or the page's.

The newer events: onEnabled and onExtensionLoaded

The runtime reference now lists two events that fill gaps onInstalled never covered:

  • runtime.onEnabled (Chrome 155 and later) is "Fired when an extension goes from being in a disabled state to an enabled state." That includes users who turn the extension back on after Chrome disabled it for new permissions, which our guide to why Chrome extension weekly users drop shows can remove a large share of users after an update.
  • runtime.onExtensionLoaded (marked "Pending") is fired "when the extension is added to the set of active extensions", with reasons install, update, browser_update, enable, startup and reload. One listener for every way the extension can come to life is cleaner than combining onInstalled, onStartup and onEnabled, but at the time of writing it has no Chrome version listed, so feature-detect it (if (chrome.runtime.onExtensionLoaded)) and keep onInstalled as the default.

Count installs from onInstalled without fooling yourself

reason === 'install' is the only in-extension signal that a new installation happened, which makes it the natural place for extension install tracking in your own analytics. A few rules keep those numbers honest:

  • Count install only. Never count update or chrome_update. Chrome, Edge and Firefox now release every two weeks, as our guide to the two-week browser release cycle explains, so a handler that counts chrome_update invents a wave of "installs" every fortnight.
  • Send one anonymous event, then stop. A random installId stored in storage.local is enough to connect install, activity and uninstall without personal data. Our guides to Google Analytics 4 in a Manifest V3 extension and to PostHog in a Chrome extension show how to send that event from the service worker, and the PostHog guide explains why creating the ID in onInstalled avoids two contexts racing to create it.
  • Pair it with the uninstall URL. Calling runtime.setUninstallURL on install (the runtime reference's own example does exactly that) lets you close the loop. Our guide to measuring extension retention and uninstall rate turns the two events into rates.
  • Expect it to differ from the store dashboard. A reinstall is a new installation, a second Chrome profile is another one, and installs that fail before the worker runs never reach your endpoint. Some users also block analytics requests. Treat your own count as a trend line and the store dashboard as the reference, as described in our guide to the Chrome Web Store conversion rate.

If you store those daily counts on your own backend, our guide to PostgreSQL time series for daily app and extension stats shows a schema that keeps a gap in the data from looking like a drop to zero.

See installs and users from the store side with Extenify

Your onInstalled events show what happens inside the extension. The store side, which is what users and competitors see, is the other half. Extenify is a chrome extension analytics dashboard that records users, ratings, reviews and versions for your extensions on the Chrome Web Store, Edge Add-ons and Firefox Add-ons every day, next to the competitors you track, and computes a 7-day install growth rate for each listing. When a release ships, it notices the new version and flags growth that turns negative or ratings that drop, in a daily digest by email, Slack, Discord or Telegram. Our checklist of what to watch in the week after an extension release pairs well with it. Every plan starts with a 7-day free trial, and the pricing page has the details.

Frequently asked questions

When does chrome.runtime.onInstalled fire?

When the extension is first installed, when it is updated to a new version, when Chrome is updated to a new version, and when a shared module it imports is updated. Reloading an unpacked extension during development also fires it, with the update reason.

How do I open a welcome page when my Chrome extension is installed?

Call chrome.tabs.create({ url }) inside an onInstalled listener when details.reason === 'install'. No tabs permission is needed for tabs.create. Do not open a tab for update or chrome_update, or users will see it after every update.

What is the difference between onInstalled and onStartup?

onInstalled runs once per install or update. onStartup runs when a browser profile with the extension starts. Use onInstalled for one-time setup and migrations, and onStartup for work that must happen at each launch.

Does onInstalled fire when the service worker restarts?

No. The service worker can stop and start many times a day, and none of those restarts fire onInstalled. That is why state you need later should be written to chrome.storage, not kept in variables set inside the handler.

How do I get the previous version of my extension after an update?

Read details.previousVersion in the onInstalled listener. It is only present when reason is update. Compare it with chrome.runtime.getManifest().version to decide which migrations to run.

Summary

chrome.runtime.onInstalled is the entry point for everything that should happen once: the welcome page, the install record, context menus and data migrations. Register it synchronously at the top level, branch on reason, ignore chrome_update for anything but cheap setup, and guard against unpacked reloads that report the same version. Make migrations ordered, resumable and idempotent, repair orphaned content scripts after updates, and use the new onEnabled event for re-enables on Chrome 155 and later. Count installs only on install, and compare that count with the store dashboard rather than expecting them to match.

Image credits

Put every store in one dashboard tonight.

Sign in with Google, paste one store link, and your unified view is live in under a minute. Then 7 days of Pro free, no credit card required, and you never touch your extension's code.

Start free trial