Rehoboth Builds

setUninstallURL ships inside the extension, so the page has to exist first

One line of code, and then four constraints that are not about code at all. The URL you pass is frozen into the version you publish — so it is the one page in your product that you can break permanently at submit time, and the one page you never see, because it opens after the person is already gone.

9 September 2026 · found while sequencing our own release, not from a user report

1. The page has to be live before the version is submitted

The API is exactly what it looks like:

chrome.runtime.setUninstallURL('https://example.com/goodbye');

What is not obvious is where that string lives afterwards. It is in your extension's code, which means it is in the version you uploaded, which means changing it requires publishing a new version and waiting for review. There is no dashboard field for it.

So if the page is not deployed when the version goes live, every person who removes the extension gets a 404 on your own domain, at the exact moment they have already decided you were not worth keeping — and you cannot fix it today.

We made that a blocking prerequisite on our own release rather than a note: before the version could be submitted, this had to return 200.

curl -s -o /dev/null -w '%{http_code}' https://example.com/goodbye

A checklist item you can run is worth more here than one you can agree with, because this particular mistake is invisible in a normal development loop. Nothing you do while building reaches that page. The one cheap way to actually exercise it: load the unpacked extension, then remove it from chrome://extensions — Chrome opens the URL for an unpacked extension the same way, so you see the 404 before your users do.

2. onInstalled alone is not enough

chrome.runtime.onInstalled fires on install and on update. It does not fire when the browser simply starts again, and the setting is not documented as surviving a browser restart. Under MV3 the service worker is torn down and rebuilt constantly, so "we set it once at install" is a claim about persistence that nobody promised you.

The fix costs nothing: make the call idempotent and register it twice.

async function syncUninstallUrl() {
  try { await chrome.runtime.setUninstallURL(URL); } catch {}
}
chrome.runtime.onInstalled.addListener(syncUninstallUrl);
chrome.runtime.onStartup.addListener(syncUninstallUrl);

We have not measured a case where the URL was actually lost across a restart, and we are not claiming one. We are pointing out the shape: the failure mode is silent — the page just stops opening, and nothing anywhere tells you — and the insurance is two lines.

3. Your privacy disclosure now contains a claim that has to be true

This is the part that nearly caught us. Our published policy page for the extension said, in as many words, that nothing happens on removal — that the extension does not open any page when it is uninstalled. That sentence was true when it was written. The moment the version with setUninstallURL went live, it would have become false.

That is not a copy nit. A disclosure that does not match what the extension does is a takedown-grade problem in both stores, and it is worse than a plain 404 because the 404 is honest about being broken.

Note where the fix lives: on a page you deploy, not in the extension. So it is a second sequencing constraint, independent of the first, and it also has to land before the version does. We gave it its own runnable check rather than a reviewer's eye:

curl -s https://example.com/policy | grep -c 'goodbye'

The general form, which is the reusable part: shipping a behaviour can invalidate a sentence you published months ago somewhere else. Grep your policy text for the behaviour you are adding, not the other way round.

4. Design the page after your failure modes, not after moods

The store back end gives you an uninstall count. It does not give you a reason. That is the entire reason this page is worth building — and it decides what belongs on it.

The tempting list is moods: too complicated, too expensive, did not like it. Ours is the list of things we already knew could go wrong, because for our product the three known failure modes look identical to the person using it — in all three, nothing happens and nothing reports an error:

  • it never made a sound
  • it spoke but nothing was highlighted
  • there were no voices to pick, or the ones there were did not work

Plus "I did not end up needing it" and "something else". A mood list would have collapsed all three of those into one indistinguishable bucket, and the count you already had would not have gotten any more useful.

5. Make the reasons paths, not query strings

If you count anything by pathname — and a minimal, cookie-free counter usually does — then /goodbye?reason=voice and /goodbye?reason=no-sound are the same row. Five separate paths give you five buckets, with no identifiers, no cookies, and no per-person record:

/goodbye
/goodbye/no-sound
/goodbye/no-highlight
/goodbye/voice
/goodbye/no-need
/goodbye/other

One more thing worth doing at the same time: if that page links back to your product page, tag the link. People who just uninstalled are the least representative traffic you have, and if the link is bare they get counted alongside everyone else in the arrival number you are using to judge whether a channel works.

How we know, and what we are not claiming

All of this came out of sequencing one release: the code change, the two prerequisites that had to be live before it could be submitted, and the page design. The ordering constraint in §1 and the disclosure conflict in §3 are facts about our own release that we caught before submitting, not incidents we survived.

We have not read a single reason back yet — the page went live with the extension on 8 September 2026, and we are describing a design, not results. We also did not measure what share of removals actually opens the page; Chrome does not promise it for every removal path, and someone who wipes the profile never gets there. Treat the number that comes out of a page like this as a lower bound on reasons, never as a denominator.

Where this came from

It came out of Highlight Reader, a Chrome extension that reads a page aloud and highlights each sentence as it speaks. It is free on the Chrome Web Store, needs no account, and uses the voices the browser already ships. A paid add-on — word-level highlighting and higher-quality voices — is described here, and it is an add-on, not the extension.

The API and the code are the easy half. The half that costs days is the store: the slot cap on a new account, the listing text going read-only mid-review, the uninstall URL you cannot change after you ship. The ten that caught us — three of them in full, no signup.