Capturing Uncaught Errors in Every Context

Install error and unhandledrejection handlers in the MV3 service worker, extension pages and content scripts — early enough to catch startup failures, filtered so content scripts ignore the page's own errors.

Published September 18, 2026 Updated September 18, 2026 7 min read
Table of Contents

“Install a global error handler” is one line in a web application and five slightly different lines in an extension, because every context has its own global object, its own lifetime and its own way of losing errors. A handler installed after the first await in the worker misses the startup failure that matters most; a handler in a content script, installed naively, reports every JavaScript error the host website makes. This guide is part of error monitoring and crash reporting.

What each context must handle

Error sources an extension must captureFour kinds of failure: synchronous throws, rejected promises, errors in chrome.* callbacks reported through lastError, and errors swallowed by catch blocks that should have been reported.Synchronous throwserror eventglobal handler catches itRejected promisesunhandledrejection eventglobal handler catches itchrome.runtime.lastErrorcallback-style APIsonly if you read itCaught and swallowedcatch {} blocksonly if you report it
Global handlers catch the first two — the last two need code at the call site.

Step-by-step

1. Install the worker handlers first — literally first

 1// capture.js
 2export function installCapture(ctx, report) {
 3  const g = typeof window !== "undefined" ? window : self;
 4  g.addEventListener("error", (e) => report(ctx, e.error ?? new Error(e.message), { file: e.filename, line: e.lineno }));
 5  g.addEventListener("unhandledrejection", (e) => {
 6    const r = e.reason;
 7    report(ctx, r instanceof Error ? r : new Error(typeof r === "string" ? r : JSON.stringify(r)));
 8  });
 9}
10
11// service-worker.js — the first import, before anything with side effects
12import { installCapture } from "./capture.js";
13import { report } from "./report.js";
14installCapture("sw", report);
15
16import "./listeners.js";   // everything else afterwards

Execution context: the top of the service worker module. ES module imports are evaluated in order, so an import that throws during evaluation — a bad top-level statement in a dependency — is only caught if the capture module was evaluated before it. Putting installCapture in the first imported module is the difference between catching startup failures and missing them. When the worker fails to start at all, the fallback is the browser’s own view, covered in debugging a service worker that won’t start.

2. Do the same in every extension page

1<!-- popup.html -->
2<script type="module" src="capture-boot.js"></script>
3<script type="module" src="popup.js"></script>
1// capture-boot.js
2import { installCapture } from "./capture.js";
3import { report } from "./report.js";
4installCapture(location.pathname.replace(/^\/|\.html$/g, ""), report);

Execution context: the popup, options page, side panel and offscreen document. Module scripts execute in document order, so a separate boot module listed first is installed before the page’s own code runs. The context name derived from the path — popup, options — tags every report without per-page configuration.

3. Filter content-script capture to your own files

A content script’s window is shared with the host page, so error events fire for the site’s bugs too. Report only errors whose stack or filename points into your extension.

 1const OWN = chrome.runtime.getURL("");      // "chrome-extension://<id>/"
 2
 3function isOurs(err, filename) {
 4  if (filename?.startsWith(OWN)) return true;
 5  return typeof err?.stack === "string" && err.stack.includes(OWN);
 6}
 7
 8window.addEventListener("error", (e) => {
 9  if (!isOurs(e.error, e.filename)) return;
10  chrome.runtime.sendMessage({ type: "err", ctx: "content", host: location.host, name: e.error?.name, message: e.message, stack: e.error?.stack }).catch(() => {});
11});

Execution context: the content script’s isolated world. Checking both the filename and the stack covers errors thrown directly from your files and errors thrown in page code called from yours. Sending to the worker rather than writing storage directly avoids concurrent tabs clobbering one queue, the same reasoning as in logging across contexts without losing messages.

4. Read lastError where callbacks are still used

Callback-style chrome.* calls do not throw; they set chrome.runtime.lastError, and if nothing reads it Chrome logs “Unchecked runtime.lastError” and moves on.

1chrome.contextMenus.create({ id: "reader:save", title: "Save", contexts: ["page"] }, () => {
2  const err = chrome.runtime.lastError;
3  if (err && !/duplicate id/.test(err.message)) report("sw", new Error(`contextMenus.create: ${err.message}`));
4});

Execution context: the service worker. Prefer the promise forms where the API offers them — a rejection reaches unhandledrejection if nobody catches it. Where callbacks remain, check lastError and report anything that is not an expected, benign case.

5. Report what you deliberately catch

Global handlers never see errors your own code catches. For caught errors that still indicate a bug, report explicitly.

1try {
2  await migrateSettings();
3} catch (err) {
4  report("sw", err, { phase: "migration", from: previousVersion });
5  await chrome.storage.local.set({ migrationFailed: true });   // degrade, but tell us
6}

Execution context: the service worker. The extra object carries context that makes the report actionable — which migration, from which version. The fallback behaviour keeps the extension usable while the report tells you it needs a fix, the approach described in running data migrations on onInstalled.

A content-script error reaching the queueAn error thrown in the content script fires the window error event, which is filtered by extension URL, sent to the worker, scrubbed and appended to the durable queue.Page windowContent scriptService workerstorage.localthrow in content/main.jserror eventisOurs? stack has extension URLsendMessage({type:'err', host})scrub()append to errQueue
The page's own errors stop at the filter — only failures in your files travel further.

De-duplicating and rate-limiting at the source

A bug in a content script that runs on every page, or in a handler called on every tab update, can produce thousands of identical reports an hour from one user. Sending all of them wastes the user’s bandwidth, burns your reporting quota, and drowns other errors. De-duplicate before queueing.

 1const seen = new Map();   // fingerprint → count, per context lifetime
 2
 3function fingerprint(ctx, err) {
 4  const top = (err.stack ?? "").split("\n").find((l) => l.includes(chrome.runtime.getURL(""))) ?? "";
 5  return `${ctx}|${err.name}|${err.message.slice(0, 80)}|${top.trim()}`;
 6}
 7
 8export function report(ctx, err, extra) {
 9  const fp = fingerprint(ctx, err);
10  const n = (seen.get(fp) ?? 0) + 1;
11  seen.set(fp, n);
12  if (n > 3) return;                         // first three per lifetime only
13  enqueue({ ctx, err, extra, fp });
14}

Execution context: every context. The fingerprint combines the context, the error type, a truncated message and the first stack frame inside your extension — stable across occurrences, distinct between different bugs. The in-memory map resets on eviction, which is fine: a worker lifetime is a natural window for “don’t send the same thing twice”.

Where each capture step belongsGlobal handler installation, content-script filtering, lastError checks, explicit reporting of caught errors and de-duplication mapped to the context and code location for each.StepWhereWhyInstall handlersFirst module in each contextCatch startup failuresFilter by extension URLContent scripts onlyIgnore the site's errorsCheck lastErrorCallback call sitesOtherwise silently droppedReport caught errorscatch blocks for real bugsHandlers never see themDe-duplicateBefore queueingProtect bandwidth and quota
The first row is about timing — installed late, the handler misses exactly the failures that stop the extension working.

Cross-browser variation

  • Chrome / Edge: error and unhandledrejection fire on self in the worker and window in pages. Unchecked lastError is logged as a warning, not an exception.
  • Firefox: the same events on the event-page background and in pages. Stack frames use fn@url:line:col format and moz-extension:// URLs — the isOurs check works if it uses runtime.getURL("") rather than a hard-coded scheme.
  • Safari: events behave the same; stacks use safari-web-extension:// URLs. Safari’s background context starts cold more often, making early handler installation matter most here.
  • All three: a content script’s window.onerror sees the page’s errors. Filtering is required in every engine.

Verification

  1. Throw deliberately in each context and confirm a queued report appears for each:
1(await chrome.storage.local.get("errQueue")).errQueue.map((e) => `${e.ctx}: ${e.message}`);
2// ["sw: probe", "popup: probe", "content: probe"]

Execution context: any extension page’s console after triggering a test error in each context. A missing context means its handler is not installed or installed too late.

  1. Add a top-level throw in a module imported after capture.js and confirm the startup failure is captured.
  2. Visit a site with its own console errors and confirm none appear in your queue.
  3. Trigger the same error fifty times and confirm at most three reports were queued.

FAQ

Does self.onerror work in a module service worker?

Use addEventListener("error", …); it works in both classic and module workers and does not overwrite anything another module installed.

Why do some rejections arrive with a non-Error reason?

Code sometimes rejects with a string or an object. Wrap non-Error reasons in an Error so the report has a stack from the handler at least, and fix the rejecting code to use proper errors.

Should the popup report errors when it is about to close?

Queueing to storage completes even as the popup closes, because the write is issued before the document is destroyed. Keep the queue write to a single set call so it is not cut off.

Other Testing, Debugging & Performance Optimization Resources