Rehoboth Builds

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

  1. Load the content script into a blank page with a stub for chrome.runtime.sendMessage that rejects the first get-settings request and answers every later one with real settings — here Alt+K bound to “new tab”.
  2. Wait one second and press the shortcut.
  3. Fire a sync storage change, as saving the options page would.
  4. Press the shortcut again, then count the get-settings requests 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 fixAfter the fix
Shortcut pressed one second after loadignoredhandled (sent tab.new)
Shortcut pressed after the settings changeignoredhandled
get-settings requests23

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 ready with 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.