errorschrome-extensionsmanifest-v3service-worker

Chrome Extension Error Tracking in Manifest V3 (the Complete Guide)

Most error trackers built for websites quietly miss half the errors in a Chrome extension — service-worker exceptions, runtime.lastError, async rejections in detached contexts. Here's how to capture errors reliably in Manifest V3, and what to do with them once you have them.

You bolt Sentry into a Chrome extension, ship it, get a polite trickle of errors, and conclude the codebase is unusually solid. It isn't. What's actually happening is that your error tracker — built for websites — is missing entire classes of failure that only exist in Manifest V3 extensions: service-worker exceptions before any listener registers, async rejections in a context that's already being torn down, the silent chrome.runtime.lastError pattern. This guide is the full picture of capturing errors in an MV3 extension reliably, and what to do with them once you have them.

The four execution contexts (and why each leaks errors differently)

A Manifest V3 extension runs code in up to four places, each with its own rules for unhandled errors:

  • Service worker — short-lived, no DOM, can be killed at any time.
  • Popup, options, side panel — regular DOM pages, but short-lived (close → context destroyed).
  • Content scripts — injected into other people's pages, where window.onerror on their site catches your throws unless you isolate.
  • Offscreen documents — newer, used for clipboard/audio. Same rules as popups.

Plugging one global handler into "the extension" misses 60% of errors because there is no single global. You need a listener per context, plus a transport that survives the service worker dying.

Service worker errors

In a service worker there's no window. You use self.addEventListener on error and unhandledrejection. Register them synchronously at the top of the file, before any other code runs — Chrome can terminate the worker between calls, and if your listener wasn't registered when the throw happened, the error is lost.

// background.js — must be the very first thing in the file
self.addEventListener('error', (e) => {
  reportError({
    kind: 'sw_error',
    message: e.message,
    filename: e.filename,
    lineno: e.lineno,
    colno: e.colno,
    stack: e.error?.stack ?? null,
  });
});

self.addEventListener('unhandledrejection', (e) => {
  reportError({
    kind: 'sw_unhandledrejection',
    message: String(e.reason?.message ?? e.reason),
    stack: e.reason?.stack ?? null,
  });
});

// ...then your other listeners, e.g. chrome.runtime.onInstalled etc.

The classic gotcha: a top-level await or a thrown error during module evaluation. If your service worker crashes before the listener registers, you can't catch it from inside. Two defenses: keep top-level code minimal (just listener registration), and wrap risky init in a try/catch:

// All the init logic lives inside listeners, not at module top level.
chrome.runtime.onInstalled.addListener(async () => {
  try {
    await migrateStorage();
    await Crxlytics.init({ apiKey: 'pk_live_...' });
  } catch (err) {
    reportError({ kind: 'sw_init', message: String(err), stack: err?.stack });
  }
});

These are regular DOM pages — use window.addEventListener('error', …) and window.addEventListener('unhandledrejection', …). Put the handlers in a small script that loads beforeany other script in the page. In a bundler that means a dedicated entry that imports nothing else first.

// popup-error-handler.ts — loaded first in popup.html
window.addEventListener('error', (e) => {
  reportError({
    kind: 'popup_error',
    message: e.message,
    filename: e.filename,
    lineno: e.lineno,
    colno: e.colno,
    stack: e.error?.stack ?? null,
  });
});

window.addEventListener('unhandledrejection', (e) => {
  reportError({
    kind: 'popup_unhandledrejection',
    message: String(e.reason?.message ?? e.reason),
    stack: e.reason?.stack ?? null,
  });
});

Popups die the moment the user clicks away. If you fire a normal fetch to report the error, half the time it's aborted. Use navigator.sendBeacon or fetch(..., { keepalive: true }) — same trick as our metrics tracking guide covers.

Content-script errors

This is where most homegrown error tracking goes wrong. Your content script runs in the host page's context. A throw inside an async callback hits the host page's window.onerror — not yours. The host page's analytics may even pick up your error, which is a privacy and debug-noise nightmare.

Two defenses, used together:

  1. Wrap every entry point in a try/catch. The entry points are: the top of the IIFE you inject, the message listeners (chrome.runtime.onMessage), and any DOM event listeners you attach. Inside the catch, message the service worker so it can report the error from a context you control.
  2. Register your own window.addEventListener('error', …, true) with capture: true, but don't call preventDefault — you don't want to swallow the host page's own errors. Filter on e.filename: only report rows whose filename matches your extension's URL (chrome-extension://<id>/…).
// content.js — your IIFE entry point
(function () {
  function safe(fn) {
    return function safeWrapped() {
      try {
        return fn.apply(this, arguments);
      } catch (err) {
        chrome.runtime.sendMessage({
          __crxlytics_error: true,
          kind: 'content_caught',
          message: String(err?.message ?? err),
          stack: err?.stack ?? null,
          page_url: location.origin, // ORIGIN only — not full URL
        });
        throw err; // keep host-page behavior unchanged
      }
    };
  }

  window.addEventListener('error', (e) => {
    if (!e.filename?.startsWith('chrome-extension://')) return; // not ours
    chrome.runtime.sendMessage({
      __crxlytics_error: true,
      kind: 'content_error',
      message: e.message,
      filename: e.filename, // safe, it's our extension URL
      lineno: e.lineno,
      colno: e.colno,
      stack: e.error?.stack ?? null,
      page_url: location.origin,
    });
  }, true);

  // Wrap your listeners explicitly
  chrome.runtime.onMessage.addListener(safe(handleMessage));
  document.addEventListener('click', safe(onClick));
})();

Note page_url: location.origin, not location.href. The full path of the page the user is on is sensitive (it's their browsing history). The origin (https://example.com) tells you which site the error happened on without leaking the rest. See privacy-first analytics for the why.

The chrome.runtime.lastError trap

This catches everyone once. Many Chrome APIs don't throw on failure — they set chrome.runtime.lastError instead, and Chrome only complains in DevTools if you don't read it in the callback. await on the promisified version does throw, but the legacy callback API silently swallows errors when nobody checks.

// Subtle bug — failure is invisible:
chrome.storage.local.set({ k: v }); // no callback, no check

// Correct:
chrome.storage.local.set({ k: v }, () => {
  if (chrome.runtime.lastError) {
    reportError({
      kind: 'chrome_api_lasterror',
      api: 'storage.local.set',
      message: chrome.runtime.lastError.message,
    });
  }
});

// Or with the promise API:
try {
  await chrome.storage.local.set({ k: v });
} catch (err) {
  reportError({
    kind: 'chrome_api_throw',
    api: 'storage.local.set',
    message: String(err),
  });
}

Prefer the promise API everywhere (Chrome 116+ supports it for most surfaces). Your top-level unhandledrejection handler will catch any case you forgot to wrap.

Promise rejections in dying contexts

Service workers, popups, and offscreen documents can all be torn down at any time. A common pattern: an async function starts a long task, the context is destroyed, the promise rejects in the middle of nowhere. The browser may or may not fire unhandledrejection — and even if it does, if your transport is a normal fetch, the request dies with the context.

Two countermeasures:

  • Wrap long-running work in idempotent steps stored in chrome.storage, not in-memory. If the context dies mid-task, the next wake reads the stored progress and resumes.
  • Use sendBeacon or keepalive fetch for the error report itself, so a half-dead context can still get the report out the door.

Context fields every report should carry

Useless error reports are reports with no context. The five fields that make every report 10× more useful:

  • extension_version — from chrome.runtime.getManifest().version. Lets you scope incidents to a release.
  • browser + browser_version — Chrome vs Edge vs Brave; minor versions matter.
  • os + os_version — Windows-only bugs are real.
  • locale — RTL languages break things; locale-sensitive parsing breaks things.
  • session_id — so you can join multiple errors from the same user's single session.

Strict schemas on the receiving side keep this honest — if a report shows up missing fields, your tracker should reject it and log the validation failure, not silently store half-data. Same lesson as the broken GA4 fallback: silent partial success is worse than loud failure.

Scrubbing URLs and PII

Error stack traces from content scripts contain the host page's URL in filename and sometimes in messages. Strip them before sending:

function scrub(s) {
  if (typeof s !== 'string') return s;
  // Replace anything that looks like a URL with its origin only.
  return s.replace(/https?:\/\/[^\s\/]+\/[^\s)\]]*/g,
    (m) => { try { return new URL(m).origin; } catch { return m; } });
}

function scrubReport(r) {
  return {
    ...r,
    message: scrub(r.message),
    stack: scrub(r.stack),
    filename: r.filename?.startsWith('chrome-extension://')
      ? r.filename : null,
  };
}

Don't skip this. A stack trace containing https://mail.google.com/?compose=draft-12345 is personal data the moment it leaves the user's machine.

Buffering errors when the service worker dies

If the service worker is terminated mid-report — or if the error happens during a cold start before the network is ready — the report is lost unless you queue it to storage first. Same pattern as our service-worker survival guide covers for analytics events:

async function reportError(payload) {
  payload = scrubReport(payload);

  // Append to a small persistent queue.
  const KEY = 'cx_err_queue';
  const { [KEY]: queue = [] } = await chrome.storage.local.get(KEY);
  queue.push({ ...payload, ts: Date.now() });
  await chrome.storage.local.set({ [KEY]: queue.slice(-50) });

  // Best-effort immediate send; an alarm flushes the rest later.
  try {
    await navigator.sendBeacon(
      'https://your-api.example.com/v1/errors',
      new Blob([JSON.stringify(payload)], { type: 'application/json' }),
    );
  } catch { /* alarm will pick up the queue */ }
}

Pair it with a chrome.alarms.create('cx-err-flush', { periodInMinutes: 1 }) that drains the queue when the worker is alive. Now even crashes that happen during a cold start get delivered the next time the worker wakes.

What to do with errors once you capture them

Three views you actually need:

  • Aggregated by message + filename. One row per unique error, with count, unique users hit, first seen, last seen, and the top 3 browsers. Most error trackers do this; if you're rolling your own, group by a hash of message + filename.
  • Versioned. Filter by extension_version. The vast majority of regressions tie back to a specific release.
  • Cross-referenced with retention. Users who hit an error within their first session churn at 2–4× the rate of users who don't. Joining errors to install date + retention bucket tells you which errors are actually losing you users vs which are cosmetic — error-driven is one of the four uninstall patterns covered in why users uninstall your Chrome extension.

That last one is the killer feature and the reason a generic web error tracker doesn't cut it for extensions. You need the install-anchored retention model alongside the error data.

FAQ

Can I just use Sentry / Bugsnag / Rollbar in a Chrome extension?

Partially. The SDKs work in popup and options pages. In MV3 they generally don't handle service-worker teardown,chrome.runtime.lastError, content-script isolation, or extension-specific context (version, browser channel). You end up writing most of the integration layer above anyway.

What about chrome.declarativeNetRequest errors?

Rule-set parsing errors surface via chrome.declarativeNetRequest.testMatchOutcome and the manifest validator. Runtime rule mismatches are silent — which is annoying but by design. Test with chrome.declarativeNetRequest.getMatchedRules() in development and report on rule-set load failures explicitly.

Should I capture console.error calls too?

Optional, and noisy. If you do, monkey-patch console.error at the top of each context and forward to reportError with kind: "console_error". Filter aggressively on the receiving side — third-party libraries log a lot.

How do I see errors from real users in production?

Ship the SDK with the listeners above, build the dashboard described in §9, and you're done. Or use a tool that does it out of the box — Crxlytics tracks errors with the version + browser + retention join described above. Either way: a generic web tracker is not enough.

What about source maps?

Yes, ship them. Upload source maps for each version at release time, key them by extension_version. When an error comes in with that version, your dashboard symbolicates the stack on read. Don't serve source maps publicly from the extension itself — keep them server-side only.

Error tracking that knows it's an extension
Crxlytics captures service-worker exceptions, chrome.runtime.lastError, content-script throws, and async rejections — with extension-version split, browser channel context, and a join against retention so you know which errors actually lose users. No remote code, no dropped events, no policy risk.
Get started free →