Handling Every onInstalled Reason
Handle chrome.runtime.onInstalled correctly in MV3: install, update, chrome_update and shared_module_update reasons, previousVersion, idempotent setup, recreating alarms and menus, and what not to do on browser updates.
Table of Contents
chrome.runtime.onInstalled is where most extensions do their setup — create context menus, schedule alarms, open a welcome page, migrate data. A common version checks only reason === "install", so after an update the alarms are gone (updates clear them) and the menu items are missing. Another version runs everything on every reason, so each browser update opens the welcome page again. The event fires for four distinct reasons, each needing a different response, and some setup belongs elsewhere entirely. This guide maps each reason to the right actions. It belongs to service worker fundamentals.
When onInstalled fires
The event fires with details.reason set to one of four values. install: the extension was installed for the first time in this profile. update: the extension was updated to a new version — or reloaded during development — with details.previousVersion set. chrome_update: the browser itself was updated. shared_module_update: a shared module the extension depends on was updated, with details.id identifying it (rare). The event does not fire on ordinary browser startup — that is runtime.onStartup — nor when a disabled extension is re-enabled. Because updates clear alarms and context menus belong to the extension’s registration, both must be (re)created on install and update.
Step-by-step: a complete handler
1. Register the listener at the top level and dispatch by reason
1// sw.js
2chrome.runtime.onInstalled.addListener((details) => {
3 handleInstalled(details).catch((err) => console.error("[installed]", details.reason, err));
4});
5
6async function handleInstalled({ reason, previousVersion, id }) {
7 switch (reason) {
8 case "install": await onFirstInstall(); break;
9 case "update": await onUpdate(previousVersion); break;
10 case "chrome_update": await onBrowserUpdate(); break;
11 case "shared_module_update": await onSharedModuleUpdate(id); break;
12 }
13 await ensureRuntimeSetup(); // idempotent, every reason
14}
Execution context: the service worker. The listener must exist after the first synchronous evaluation, or the event that starts a freshly installed worker is missed. Catching errors per reason ensures a failure in a migration does not prevent the idempotent setup at the end from running.
2. Make shared setup idempotent
1async function ensureRuntimeSetup() {
2 // Context menus: remove all, then create — safe to run any number of times
3 await chrome.contextMenus.removeAll();
4 chrome.contextMenus.create({ id: "save-selection", title: "Save to Readable", contexts: ["selection"] });
5
6 // Alarms: create only if missing, so an existing schedule is not reset
7 if (!(await chrome.alarms.get("sync"))) chrome.alarms.create("sync", { periodInMinutes: 60 });
8
9 // Dynamic rules / content scripts: recompute from current settings
10 await syncContentScriptRegistration();
11}
Execution context: the service worker. Setup that runs on several reasons must produce the same result however many times it runs. removeAll then create avoids duplicate-id errors for menus. Checking for an alarm before creating it preserves its existing schedule; after an update the alarm is gone, so it is created. Running the same function from onStartup too heals a profile where something went wrong. See rebuilding context menus after worker restarts.
3. Handle first install
1async function onFirstInstall() {
2 await chrome.storage.local.set({ installedAt: Date.now(), schemaVersion: CURRENT_SCHEMA });
3 await chrome.tabs.create({ url: chrome.runtime.getURL("welcome.html") });
4 await chrome.runtime.setUninstallURL("https://readable.example/goodbye");
5}
Execution context: the service worker. First install is the one moment to show onboarding. Record the install time (useful for install-age analytics and support) and the current schema version so future migrations know where to start. Skip the welcome tab for policy installs (chrome.management.getSelf() → installType: "admin"), where an administrator, not the user, installed the extension. See showing a first-run setup page after install.
4. Handle updates with migrations from previousVersion
1async function onUpdate(previousVersion) {
2 if (previousVersion === chrome.runtime.getManifest().version) return; // dev reload
3 await runMigrations(previousVersion);
4 await recordReleaseNotes(previousVersion);
5}
Execution context: the service worker. During development, reloading the unpacked extension fires update with the same version; skipping migrations and notes in that case keeps development quiet. Real updates run migrations keyed by the previous version, as described in running data migrations on onInstalled, and queue release notes as in showing a what’s new page after an update.
5. Handle browser updates lightly
1async function onBrowserUpdate() {
2 // Nothing user-visible. Optionally re-check capabilities that depend on browser version.
3 await chrome.storage.session.set({ capabilities: detectCapabilities() });
4}
Execution context: the service worker. Browser updates happen every few weeks for every user; opening pages or running migrations here is spam. At most, refresh cached capability detection — a new browser version may enable an API you feature-detect. Idempotent setup still runs afterwards and costs nothing.
6. Do not confuse onInstalled with onStartup
1chrome.runtime.onStartup.addListener(() => ensureRuntimeSetup());
Execution context: the service worker. onStartup fires when a browser profile starts with the extension installed — not on install, not on update. Use it for per-session work: re-arming retry alarms from stored records, clearing session caches, verifying context menus exist. Running ensureRuntimeSetup from both events makes the extension self-healing.
7. Test each reason
During development, reloading triggers update; removing and re-adding the unpacked extension triggers install. For chrome_update, there is no simple trigger — test the handler function directly. Write unit tests that call handleInstalled with each reason and assert the actions taken, including that chrome_update opens no tabs.
Common mistakes
- Setup only on
install. Updates clear alarms; menus need recreating. - Welcome page on every reason. Browser updates reopen it every few weeks.
- Migrations on dev reload. Same-version “updates” re-run them.
- Non-idempotent menu creation. Duplicate id errors on update.
- Expecting
onInstalledon browser start. That isonStartup.
Keep the handler fast
Everything in onInstalled runs while the user may be waiting for the extension to become usable — immediately after install or update. Do quick, essential setup inline (menus, alarms, the schema version) and push slow work — large migrations, initial syncs, index builds — into resumable background jobs triggered from here. A handler that takes a minute to finish leaves features half-configured for that minute, and one that exceeds the event’s time limit leaves them half-configured indefinitely.
Cross-browser variation
- Chrome / Edge: all four reasons; updates clear alarms; reloading an unpacked extension fires
update. - Firefox:
install,updateandbrowser_update(Firefox’s name for browser updates); temporary add-ons fireinstallon each load. Context menus persist differently — keep setup idempotent. - Safari:
installandupdatefire for the web extension; app updates through the App Store triggerupdate.
Verification
- Install fresh: welcome page opens, alarms and menus exist.
- Bump the version and reload: migrations run, no welcome page, alarms and menus present exactly once.
- Reload without bumping the version: no migrations or notes.
- Call
handleInstalled({ reason: "chrome_update" })in a test and assert no tabs were created.
FAQ
Do context menus survive updates?
They are tied to the extension’s registration and can be cleared on update; recreate them idempotently.
Is onInstalled guaranteed to fire after an update?
Yes, when the new version starts. If the worker fails to start, it cannot fire — another reason to test packaged builds.
What is previousVersion on install?
Undefined. Only update provides it.
What if two onInstalled listeners exist in different modules?
Both run, in registration order, and both receive the same details. That is fine as long as each is idempotent; a single dispatcher, as in step 1, makes the order and error handling explicit and is easier to test.
Does onInstalled fire for every profile?
Yes — each browser profile has its own installation of the extension and receives its own events, with its own storage and alarms.
Can I tell an update came from a staged rollout?
No. The event only reports the previous version. If you need cohort information, record it yourself when the update arrives.
Related
- Registering listeners at the top level — why the listener must be synchronous.
- Alarms that don’t fire after browser restart — related reconciliation.
- Running data migrations on onInstalled — the update path in depth.
- Service worker fundamentals — the parent topic.