Can a disabled extension skip its own uninstall URL? In Chrome, no — here's why
A disabled extension can't clear its uninstall URL (setUninstallURL) before removal — the service worker is already dead. Here's why, and the server-side fix.

Here's a question that sounds like it should have a clean answer: if a user disables your extension and only later removes it, can your extension be polite and not open its uninstall URL? The user already walked away. Re-opening a "sorry to see you go" survey on the way out feels presumptuous, and depending on what you stuff into that URL, a little invasive.
Good instinct. But you can't actually do it, at least not in Chrome and Edge. Once you see why, you also see why Firefox behaves differently and what the only workable mitigation is. Spoiler: it lives on your server, not in your extension.
First, who actually opens the uninstall URL
The relevant API is one line, set from your background service worker:
chrome.runtime.setUninstallURL("https://example.com/goodbye")When the user removes your extension, the browser opens that URL in a fresh tab. MDN's runtime.setUninstallURL reference is blunt about the use cases: "clean up server-side data, do analytics, or implement surveys." The URL can be up to 1023 characters (it used to be 255 — Firefox raised the cap in Firefox 116), and it has to be http/https. Passing an empty string clears it.
The detail that matters here: the browser opens the URL, not your code. That's why setUninstallURL needs no host permissions, and why you can't intercept or cancel the request through any normal extension API. By the time the tab opens, your extension no longer exists in the browser.
I covered the friendly, opt-in side of this in turning uninstalls into structured feedback. This post is about the awkward edge that one didn't touch: what happens when the user disables first.
The lifecycle wall: Chrome MV3 kills your worker the instant you're disabled
To "respect" the disabled state, your extension would need to do something like: detect that it's being disabled, then call setUninstallURL("") to wipe the URL so nothing opens later. Every link in that chain is broken under Manifest V3.
The service worker stops immediately on disable. Disable an extension and the browser tears down its background service worker at once, abandoning any pending tasks. As one of the Chromium Extensions maintainers put it in the community group, a disabled extension behaves "identical to an extension that's not installed at all" — the only difference from an outright uninstall is that disabling leaves chrome.storage and localStorage intact. The code stops running. Period.
There is no shutdown hook in MV3. Developers reach for chrome.runtime.onSuspend, but that event belongs to the old Manifest V2 event page — it never fires for an MV3 service worker. Oliver Dunk from the Chrome DevRel team confirmed this directly on the Chromium Extensions forum: onSuspend isn't supported by service workers, partly because it would offer "a false sense of security" given that a worker can also vanish on a crash. The underlying platform reason is just as final: the Worker and ServiceWorker interfaces have no equivalent of a window's beforeunload/unload event, so there's nothing to listen to.
Even in MV2, you couldn't rely on it. The old onSuspend documentation warned that "any asynchronous operations started while handling this event are not guaranteed to complete." Since setUninstallURL returns a promise, you could never be sure the clear actually landed before the context died. And MV2 is on its way out anyway — Chrome's Manifest V2 sunset means the service-worker model is the only one that matters going forward.
chrome.management.onDisabled doesn't fire for yourself. The management API is designed to report on other extensions. When your extension is the one being disabled, its worker is already gone, so a listener it registered for its own onDisabled event has nothing left to run it.
So the verdict is clean: in Chrome and Edge there is no moment where your code can observe "I'm being disabled" and react. Any tutorial that suggests onDisabled → setUninstallURL("") is selling something that can't work.
Why "just poll getSelf()" is a dead end
The next idea people float: have the extension periodically check its own state via chrome.management.getSelf() (which does return an enabled field, and conveniently needs no management permission) and clear the URL when it notices it's disabled.
This collapses on the same rock. While an extension is disabled, none of its code runs — not on a timer, not on an alarm, not on any event except being re-enabled. So it can never "notice" it's disabled, let alone act on it. Polling only works while you're enabled, which is exactly the state where you don't want to clear the URL.
You can detect disablement from outside — a web page can ping the extension via externally_connectable messaging or a content-script handshake and infer "disabled" from the silence. But that doesn't help here either: after a page reload chrome.runtime becomes undefined, so you can no longer tell "disabled" from "uninstalled," and in any case the cleanup has to come from the extension's own code, which isn't executing. The same service-worker-lifecycle quirks that bite here are the ones that made the Chromium service worker flaw so confusing — the worker's life is far less predictable than most developers assume.
So why does Firefox get this "right"?
Because of a storage difference, not a deliberate courtesy. The behavior splits by browser:
| Browser | Background context | Opens URL after disable→uninstall? | How the URL is stored |
|---|---|---|---|
| Chrome | Service worker | Yes | Persisted in the browser |
| Edge | Service worker (Chromium) | Yes (inherits Chromium) | Persisted in the browser |
| Firefox | Event page / background script | No | In memory, re-set on every background run |
| Safari | Event page / SW | No (API is a no-op) | n/a |
Firefox doesn't persist the uninstall URL. You have to call setUninstallURL on every run of the background script, so when the user disables the extension, the background script unloads, the URL is forgotten, and a later uninstall has nothing to open. The W3C WebExtensions issue tracker spells this out: "Firefox does not persist the uninstallURL and requires it on every run of the background script; Chrome/Edge persist the uninstallURL even after update." It's a side effect of the storage model, not a "respect the disabled user" feature anyone sat down and designed.
Safari sidesteps the whole question — runtime.setUninstallURL is present for cross-browser source compatibility but does nothing.
The standardization status (read the caveats)
The inconsistency is logged. W3C WebExtensions issue #970 — "Should disabled extensions have their runtime.setUninstallURL opened on uninstall?" — is open with no resolution, tagged as an inconsistency needing triage from both Chrome and Firefox. A separate issue, #981, documents the persistence gap and notes that the only Chrome-side workaround today is to keep calling setUninstallURL("") indefinitely in newer versions if a previously-set URL is no longer wanted.
Two things to not over-read here, because they get repeated as fact:
- A Chromium bug exists ("setUninstallURL is not cleared when the extension is manually disabled"), filed by Jeff Johnson of Lapcat Software, whose original write-up kicked off the discussion. But the Chromium issue tracker isn't publicly readable without auth, so its current status, priority, and any engineer comments are unverified. Don't claim Google "is fixing it" or "won't fix it."
- The line that "Chrome developers are open to changing this" comes from the author of the W3C issue paraphrasing, not a direct quote from a Chromium engineer. Treat it as reported intent, not a commitment.
If Chromium does eventually add an early bailout for disabled extensions, Chrome would start behaving like Firefox and the workaround below becomes unnecessary. Until then, plan for today's behavior.
The fix that actually works: move the logic to your server
You can't stop the browser from opening the tab. But you can control what your server does when that tab arrives. The pattern: have your extension drop a "last active" heartbeat while it's alive, encode it in the uninstall URL, and let the server decide whether this looks like an engaged user worth surveying — or someone who disabled the thing weeks ago and forgot it existed.
// service_worker.js (MV3)
// Set the uninstall URL once, when installed.
chrome.runtime.onInstalled.addListener(() => {
chrome.runtime.setUninstallURL(buildUrl())
})
// Refresh the heartbeat whenever the worker wakes on browser start.
chrome.runtime.onStartup.addListener(updateHeartbeat)
// The worker sleeps after ~30s idle, so a steady heartbeat needs alarms,
// not setInterval (which dies with the worker).
chrome.alarms.create("hb", { periodInMinutes: 30 })
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "hb") updateHeartbeat()
})
async function updateHeartbeat() {
const ts = Date.now()
await chrome.storage.local.set({ lastActive: ts }) // survives disable
await chrome.runtime.setUninstallURL(buildUrl(ts)) // re-stamp the URL
}
function buildUrl(ts = Date.now()) {
// Pass a coarse activity timestamp ONLY — no user IDs, no fingerprints.
return `https://example.com/uninstall?last=${ts}`
}On the server, compare last against the current time when the uninstall page loads. If the gap is large — the tell-tale signature of "disabled a while ago, then removed" — render a neutral, empty page and skip the survey and any tracking. If the user was active recently, show the exit interview. That emulates Firefox's behavior, decided on the only machine where your code still runs at uninstall time: your own.
One honest caveat: this does not prevent the HTTP request. The browser still opens the tab, your server still sees the hit (and the user's IP). All you're controlling is what happens after the request lands. Fully suppressing the request from the extension side in Chrome is not possible.
The privacy default you should reach for anyway
The reason this question comes up at all is that the uninstall URL is a privacy footgun. As Johnson's write-up points out, developers "can add whatever identifying information they like to the URL," and the browser will dutifully open it — no host permissions, no user prompt, for a user who explicitly decided to leave.
So treat the URL as a public, low-trust channel:
- Never embed a user UUID, email, or fingerprint. A coarse activity timestamp is enough to gate a survey; an identifier is just tracking dressed as feedback.
- If you don't truly need the survey, set
setUninstallURL("")and skip the whole thing. In Firefox you can simply not callsetUninstallURLunder conditions you consider sensitive, since it re-sets every run. In Chrome, opting out of a previously-set URL means callingsetUninstallURL("")in every new version, indefinitely. - Keep the page boring. No autoplaying redirects, no "are you sure?" dark patterns, no pre-checked re-install offers. They've left.
Takeaway
If you remember one thing: in Chrome and Edge, an extension can't detect or react to its own disablement, so it can't clear its uninstall URL on the way out — the service worker is already gone, onSuspend doesn't exist for MV3, and onDisabled never fires for yourself. Firefox's "polite" behavior is a storage accident, not a feature, and the spec question is unresolved. The only mitigation that survives all of this is server-side: stamp a coarse heartbeat, and let your backend decide what a long-dormant user gets to see. Build the survey itself opt-in and identifier-free — the way I walk through in turning uninstalls into feedback — and you respect the user as much as the platform lets you.
Don't want to build the server half?
The heartbeat-and-gate pattern above is maybe an hour of work, plus a page to host and a place to store responses. If you'd rather skip all of that, point your setUninstallURL at an Extenshi-hosted feedback page instead — it handles the back end: a branded survey, a fixed reason taxonomy, and responses that land in your developer cabinet. Build and brand it at dojo.extenshi.io/tools/uninstall-feedback, which generates the exact URL and snippet for your extension (setup walkthrough here).
Wiring it up takes a verified claim on your listing — the same one that unlocks free security scans (5 a month, more via prepaid packs) and install/uninstall analytics. It's worth seeing how your extension already looks to users in the public Extenshi catalog first, then claim your extension — and the next user who walks away tells you why.
Sources
- "runtime.setUninstallURL", MDN Web Docs (Mozilla), last updated 2025-07-17
- "chrome.runtime — setUninstallURL", Chrome for Developers (Google), 2024
- "Extension service worker lifecycle", Chrome for Developers (Google), 2024
- W3C WebExtensions Community Group, issue #970 "Should disabled extensions have their runtime.setUninstallURL opened on uninstall?", opened 2026-04-03 (status: open)
- W3C WebExtensions Community Group, issue #981 "Persist runtime.setUninstallURL() until update"
- Chromium Extensions Google Group threads: "How to detect my extension is disabled" and "chrome.runtime.onSuspend for MV3 service worker?" (incl. Oliver Dunk, Chrome DevRel)
- Jeff Johnson, "The browser extension API setUninstallURL violates user privacy", Lapcat Software, 2026-04-01
- Bugzilla bug 1835723 (Firefox uninstall URL length limit raised to 1023 in Firefox 116)
Related Articles

Turn extension uninstalls into feedback with one line of code
chrome.runtime.setUninstallURL opens a page after someone uninstalls your extension. Here's how to turn that uninstall moment into structured churn feedback.

The Chromium service worker flaw: how to protect your browser and extensions
Google accidentally exposed an unfixed Chromium service worker flaw that keeps background scripts running after you close the browser. Here's how to stay safe.

We counted what Chrome's Manifest V2 sunset actually removed — and it wasn't the ad blockers
Everyone said Chrome's Manifest V2 deadline would kill ad blockers. Here's what the sunset actually stranded — and why Firefox is now the MV2 refuge.