Moderok

Blog

How to Catch chrome.runtime.lastError Before It Silently Breaks Your Extension

Moderok team

Why chrome.runtime.lastError doesn't throw or reject, how it silently swallows failures in MV3 service workers, and how to catch it.

If you've ever shipped an extension update and watched a Chrome API call quietly stop working (no crash, no console error, just a feature that stopped doing anything), there's a good chance chrome.runtime.lastError is the culprit.

Most of the extension platform's older APIs don't throw exceptions or reject promises when something goes wrong. Instead, they set chrome.runtime.lastError on the global chrome.runtime object and expect you to check it yourself, inside the callback, before the current microtask ends. If you don't check it, the error vanishes. Chrome will print an "Unchecked runtime.lastError" warning to the service worker console, but only if the extension's DevTools happen to be open at that exact moment. For a background service worker that most users never open, that is almost never.

Why this bites MV3 extensions specifically

In Manifest V2, background pages stayed alive, so a stray console warning would eventually get noticed by whoever had DevTools open during development. In MV3, the service worker is ephemeral: it can spin up, run a callback, and terminate in the time it takes a human to open chrome://extensions and click "service worker." Errors that only surface as unchecked-lastError console warnings are invisible in production because nobody's DevTools are attached when they happen.

This is different from an uncaught exception or an unhandled promise rejection, both of which fire browser-level events (error and unhandledrejection) that a global listener can catch. chrome.runtime.lastError doesn't fire either. It's not an exception. It's a property that gets set synchronously right before your callback runs, and it goes away right after. There's no event to subscribe to.

The pattern that causes silent failures

chrome.tabs.sendMessage(tabId, { type: "REFRESH" }, (response) => {
  // No check here. If the tab was closed, the content script was never
  // injected, or the receiving end doesn't exist, this callback still runs.
  // `response` is undefined and nothing tells you why.
  updateBadge(response);
});

If tabId refers to a tab with no listener (a common case: the tab navigated away, or it's a chrome:// page where content scripts can't run), Chrome doesn't throw. It calls your callback with response === undefined and sets chrome.runtime.lastError.message to something like "Could not establish connection. Receiving end does not exist." If you don't read chrome.runtime.lastError inside that callback, the message is discarded, and updateBadge(undefined) runs, probably clearing a badge that shouldn't be cleared, or throwing a confusing secondary error a few lines further in.

Other APIs with the same shape: chrome.tabs.query, chrome.storage.local.get/set, chrome.identity.getAuthToken, chrome.cookies.get, chrome.scripting.executeScript in some failure modes, and most of the messaging APIs.

The fix: check it in every callback

chrome.storage.local.set({ theme: "dark" }, () => {
  if (chrome.runtime.lastError) {
    console.error("storage.local.set failed:", chrome.runtime.lastError.message);
    return;
  }
  // proceed
});

The check has to happen synchronously inside the callback, before any await or other async work, because chrome.runtime.lastError is only valid for the duration of that callback's execution. Store the message in a local variable first if you need to reference it after an await.

If your codebase has dozens of these callsites, it's worth writing a small wrapper once:

function callWithLastError(fn, ...args) {
  return new Promise((resolve, reject) => {
    fn(...args, (result) => {
      if (chrome.runtime.lastError) {
        reject(new Error(chrome.runtime.lastError.message));
        return;
      }
      resolve(result);
    });
  });
}

// chrome.tabs.query({ active: true, currentWindow: true }, cb) becomes:
const tabs = await callWithLastError(chrome.tabs.query.bind(chrome.tabs), {
  active: true,
  currentWindow: true,
});

Now a failed call rejects like a normal promise, and a single try/catch (or a global unhandledrejection handler, for calls you don't explicitly await) catches it. Many chrome.* namespaces also expose promise-based versions directly in MV3 (chrome.storage.local.set(items) returns a promise if you omit the callback), but promise support is inconsistent across APIs and browsers, so check the docs for the specific method before assuming it.

Making these errors visible instead of silent

Even after you've added the checks, you still need somewhere for the resulting error to go. console.error inside a service worker only helps if someone has that specific worker's DevTools open at that moment, which for most real-world failures (a user on a locked-down managed Chromebook, a permission that got revoked) never happens.

This is exactly the gap the Moderok SDK's captureLastError helper is built for. It doesn't automatically detect lastError (there's no way to, since it isn't an event), but it gives you a place to route it once you've checked:

chrome.tabs.sendMessage(tabId, { type: "REFRESH" }, (response) => {
  if (chrome.runtime.lastError) {
    Moderok.captureLastError("tabs.sendMessage", chrome.runtime.lastError, {
      action: "refresh_tab",
    });
    return;
  }
  updateBadge(response);
});

This records a __error event with kind: "runtime_last_error", the API name, and the error message, so it shows up in the same error log as uncaught exceptions and unhandled rejections, which the SDK does capture automatically once you call Moderok.init(). Identical lastError messages within a short window get deduplicated, so a user stuck in a bad connectivity loop doesn't flood your logs with the same failure a hundred times a minute.

A quick audit you can run today

Search your extension's source for callback-style chrome.* calls and check whether each one reads chrome.runtime.lastError:

grep -rn "chrome\.\(tabs\|storage\|runtime\|identity\|cookies\|scripting\)\." src/ | grep -v "lastError"

It won't catch everything (some calls legitimately don't need the check, and some checks are named differently), but it's a fast way to find the obvious gaps, especially in code that was written against MV2 patterns and never revisited for MV3's stricter, more ephemeral execution model.

For more on the SDK this is part of, see the Moderok blog intro.