One failed settings request can leave a content script dead until the tab reloads
If your content script asks the service worker for its settings once, when the page
loads, and swallows the error with .catch(() => {}), a single failed reply leaves it
doing nothing on that tab until the tab is reloaded. The fallback most of us add — fetch again
on storage.onChanged — may not revive it either, because it restores the settings but
not the flag that says they arrived. Retry on a schedule, retry again on the user’s next key
press, and never swallow a key before you know it is yours.
27 September 2026 · reproduced in headless Chrome 153.0.8010.36 by running our extension’s real content script from before and after the fix
1. What the user sees
A shortcut that works in most tabs and does nothing in one of them. There is no error on the page and none in the extension, because the one thing that failed was caught and thrown away. Reloading the tab fixes it, which makes it look random. Changing a setting in the options page — what people try when an extension misbehaves — does not.
2. The code that does this
Our extension’s content script before the fix, cut down to the parts that matter:
let settings = null;
let ready = false;
chrome.runtime.sendMessage({ type: 'get-settings' })
.then(s => { settings = s; ready = true; })
.catch(() => {});
chrome.storage.onChanged.addListener((_changes, area) => {
if (area !== 'sync') return;
chrome.runtime.sendMessage({ type: 'get-settings' })
.then(s => { settings = s; })
.catch(() => {});
});
function onKeyDown(e) {
if (!ready || e.isComposing) return;
// ... match the key against settings, preventDefault, run the action
}
window.addEventListener('keydown', onKeyDown, true);
ready is set in exactly one place: the first request, when it succeeds. If that
request fails there is no retry and nothing is logged. The onChanged handler looks like a
way back, and it does fetch fresh settings, but it never sets ready, so the key handler
keeps returning early.
3. Reproduce it
- Load the content script into a blank page with a stub for
chrome.runtime.sendMessagethat rejects the firstget-settingsrequest and answers every later one with real settings — here Alt+K bound to “new tab”. - Wait one second and press the shortcut.
- Fire a
syncstorage change, as saving the options page would. - Press the shortcut again, then count the
get-settingsrequests the stub received.
Rejecting only the first request matters: a stub that keeps failing would make the old code look broken for the wrong reason.
4. What we measured
| Before the fix | After the fix | |
|---|---|---|
| Shortcut pressed one second after load | ignored | handled (sent tab.new) |
| Shortcut pressed after the settings change | ignored | handled |
get-settings requests | 2 | 3 |
Before the fix, the request sent by the settings change succeeded and the tab still ignored the key, because the flag it checks was never set. After the fix, the first retry succeeded before the key was pressed, and the settings change added the third request.
5. The fix
Send every fetch through one function, and let only its success path set both values:
const RETRY_MS = [300, 1000, 2500];
let inFlight = false, refetch = false, lastTry = 0;
function fetchSettings() {
if (inFlight) { refetch = true; return Promise.resolve(false); }
inFlight = true;
lastTry = Date.now();
const done = (ok) => {
inFlight = false;
if (refetch) { refetch = false; fetchSettings(); }
return ok;
};
return chrome.runtime.sendMessage({ type: 'get-settings' })
.then(s => {
if (!s || !Array.isArray(s.bindings)) return done(false);
settings = s;
ready = true;
return done(true);
})
.catch(() => done(false));
}
(async () => {
if (await fetchSettings()) return;
for (const ms of RETRY_MS) {
await new Promise(r => setTimeout(r, ms));
if (await fetchSettings()) return;
}
})();
chrome.storage.onChanged.addListener((_changes, area) => {
if (area === 'sync') fetchSettings();
});
function onKeyDown(e) {
if (e.isComposing) return;
if (!ready) {
if (Date.now() - lastTry > 2000) fetchSettings();
return; // not armed yet: never preventDefault here
}
// ...
}
Four choices in it are deliberate:
- The user’s next key press is a retry. The timed retries cover the first few seconds. After they run out, a key press is the only event still arriving, so it triggers a fetch: at most one request every 2 seconds.
- No
preventDefault()before the settings arrive. Until then you do not know which keys are yours, and swallowing the page’s own shortcuts is worse than doing nothing. - A request that comes in mid-flight is queued, not dropped. Returning early would lose the fetch a settings change asked for, trading this bug for a quieter one.
- An empty reply counts as a failure. Setting
readywith no bindings would make the key handler throw on every press.
How we know, and what we did not test
The table comes from a script that runs the extension’s real content.js from the
commit before the fix and from today through the same stub in headless Chrome, and records the
browser version and date with each reading. The extension’s own test file has five checks for
this path, written with the fix. We broke the fix four ways on purpose — removed the timed
retry, removed the key-press retry, put the old onChanged handler back, removed the
area filter — and each break turned only its own check red.
A fifth break turned nothing red: replacing if (!ready) with if (false).
With no settings loaded, the binding lookup already finds nothing, so the two guards cover for each
other and an end-to-end check cannot see either one removed. The test file says so instead of
claiming to cover it.
What we did not test is how often the first request fails in real use. We wrote the fix for three situations: a browser restoring many tabs at start-up while the service worker is still starting, a copy of the script left behind by an extension update, and a worker stopped between the request and the reply. None of them was caught in the field. The second needs more than a retry, because a left-behind copy can never reach the extension again; injecting a fresh copy into the open tabs covers it, and brings a problem of its own.
Where this came from
From Custom Keyboard Shortcuts, a Chrome extension that binds key combinations to browser actions. Its content script has to know the user’s bindings before it can tell which key presses belong to it, so it asks the service worker for them on every page. The bug was there from the first release; the fix is in version 1.2.2. The extension is free on the Chrome Web Store.
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.
This note was drafted with an AI model from our own bug log and a reproduction we ran in Chrome, and automated checks ran before it was published.
More from the same build
Every one of these is something we hit while shipping the two extensions on this site, written up from our own code and measurements.
- The hidden attribute loses to display: flex, and a test that reads el.hidden will not notice
- Chrome Web Store API v1 to v2 — what actually changes
- Chrome Extension Ship Kit — everything between “it works” and “it is live”
- A new Chrome Web Store account can publish two extensions
- setUninstallURL ships inside the extension, so the page has to exist first
- chrome.runtime.id is a different string on Edge, and it breaks your store links silently