Moderok

Blog

How to Track Chrome Extension Uninstalls with setUninstallURL

Moderok team

How to track Chrome extension uninstalls using chrome.runtime.setUninstallURL, what it can and cannot catch, and the Firefox differences.

To track when someone uninstalls your Chrome extension, call chrome.runtime.setUninstallURL() with a URL you control. When the user removes the extension through Chrome's UI, the browser opens that URL in a new tab. Your server sees the request and logs the uninstall. That is the entire mechanism, and it is the only hook Chrome gives you for this, because there is no chrome.runtime.onUninstalled event you can listen for from inside your own extension. Once it is gone, its code is gone, so the only signal that survives is an outbound page load the browser fires on your behalf.

Here is the minimal version:

chrome.runtime.onInstalled.addListener(() => {
  chrome.runtime.setUninstallURL("https://yoursite.com/uninstalled");
});

Set it once and Chrome remembers it. You do not need to re-set it on every service worker startup, but doing so is harmless and often simpler in MV3, where the worker restarts constantly.

Why there is no onUninstalled event

Every other lifecycle transition has an event. Install and update fire chrome.runtime.onInstalled. Startup fires chrome.runtime.onStartup. Uninstall has nothing, and the reason is mechanical: your service worker cannot run code after the thing it lives in has been deleted. The browser handles the removal, then, as a courtesy, opens the URL you registered ahead of time. That means the "uninstall event" is really a plain HTTP request hitting a server you own, not a callback inside your extension.

Because it is just a page load, the payload has to be baked into the URL itself. There is no request body, no headers you set, nothing but the query string. Whatever you want to know about the uninstall (which user, which version, which install date) has to be encoded into the URL at the moment you call setUninstallURL.

The rules Chrome enforces

Two constraints matter in practice:

  • The URL must use an http: or https: scheme. A chrome-extension:// page or a mailto: link will not work. Setting an empty string clears the URL so no tab opens on uninstall.
  • The maximum length is 1023 characters. If you build the URL dynamically and stuff too much into the query string, Chrome rejects it. Firefox historically capped this at 255 characters and later raised it to match Chrome's 1023, so if you support both, treat 1023 as the ceiling and stay well under it for older Firefox versions.

The length limit is easy to blow past if you naively append a JSON blob. Encode only what you need: a stable user identifier and maybe a version string. Keep the rest of the analytics on the server, keyed off that identifier.

What setUninstallURL cannot catch

This is the part most guides skip, and it is why raw uninstall counts always run low. The uninstall URL only fires when the browser is running and actively processes a user-initiated removal through its own UI. Several common ways an extension "goes away" produce no ping at all:

  • The browser itself is uninstalled, or the OS profile is wiped. No browser process means no tab to open. Ephemeral Chromebook profiles that reset at logout fall into this bucket: the extension vanishes with the profile and the URL never fires.
  • Enterprise policy removal. When an admin pushes a policy that pulls an extension, that is not a user clicking "Remove."
  • Disable is not uninstall. A disabled extension is still installed. No ping fires until it is actually removed.

There is also a documented cross-browser wrinkle around disabling. Chrome opens the uninstall URL even if the user disables the extension first and then removes it. Firefox does not: if a Firefox user disables the add-on and then removes it, setUninstallURL does not fire. So the same user action produces a ping in one browser and silence in the other.

The takeaway: setUninstallURL gives you a real, useful signal for voluntary in-browser removals, which is the churn you most want to understand, but it is a floor, not a census. Your true uninstall count is always at least what the URL reports, usually more. This is the mirror image of the install-count gap covered in the Moderok docs, where installs tend to over-count; uninstalls tend to under-count, and for related reasons.

Firefox and cross-browser code

The API is part of the WebExtensions standard, so Firefox exposes it as browser.runtime.setUninstallURL (and Chrome accepts chrome.runtime.setUninstallURL). If you ship to both stores from one codebase, guard the call so it does not throw on a runtime that lacks it:

const runtime = globalThis.browser?.runtime ?? globalThis.chrome?.runtime;
if (runtime?.setUninstallURL) {
  runtime.setUninstallURL("https://yoursite.com/uninstalled?uid=" + userId);
}

No special permission is required in either browser. setUninstallURL lives on runtime, which every extension has access to without declaring anything in the manifest. In particular you do not need host_permissions for the domain you point at, because the browser opens the tab, not your extension.

Making the ping actually useful

A bare page load tells you an uninstall happened but not whose, or when they installed, or what version they were on. To make it actionable, attribute it to the same anonymous identity you used for the install, and encode that identity into the URL:

chrome.runtime.setUninstallURL(
  "https://yoursite.com/uninstalled?uid=" + encodeURIComponent(userId)
);

Now the server can join the uninstall against the install record for that uid and compute tenure (how long they kept it), retention curves, and which release preceded the most churn. This is also where an uninstall survey fits: redirect the opened tab to a short "why did you leave?" form. Keep it optional and anonymous; users who just removed your extension did not opt into a wall of questions.

How Moderok handles it

If you use the Moderok SDK, uninstall attribution is a one-line config flag:

Moderok.init({
  appKey: "mk_your_app_key",
  trackUninstalls: true,
  uninstallUrl: "https://yoursite.com/goodbye", // optional redirect
});

When trackUninstalls is on, the SDK builds an uninstall URL that points at Moderok's endpoint and carries the same anonymous user id it already assigned at install, then registers it with chrome.runtime.setUninstallURL. Moderok records the uninstall server-side and attributes it to that user, so the dashboard can show tenure and net churn rather than just a running total. If you set the optional uninstallUrl, Moderok redirects the opened tab there after logging the ping, which is where your own goodbye page or survey goes.

The SDK also handles the two failure modes Chrome imposes. It checks that the constructed URL is a valid endpoint and that it stays under the 1023-character limit; if either check fails, it logs a warning when you run with debug: true and skips the call rather than letting Chrome reject it silently. That mirrors the general MV3 lesson that failed platform calls tend to disappear without a trace, the same trap covered in catching chrome.runtime.lastError before it silently breaks your extension.

You can wire setUninstallURL up by hand in a few lines, and for a lot of extensions that is the right call. The moment you want the uninstall joined to an install date, a version, and a retention curve without building that pipeline yourself, that is what trackUninstalls is for. Either way, register the URL early, keep it short, and remember that the number it produces is a floor.