Error Tracking for Browser Extensions: Background, Content-Script, and Popup Failures
EngineeringJune 25, 202612 min read

Error Tracking for Browser Extensions: Background, Content-Script, and Popup Failures

Track crashes in extension service workers, content scripts, popups. Manifest V3 ready.

I shipped a Chrome extension last March that worked perfectly in development. Users started reporting crashes within hours. The extension popup would freeze, content scripts would silently fail on certain sites, and the whole thing would occasionally just... stop. No console logs on my end. No reproduction steps from users. Nothing.

The problem wasn't the code — it was that I had zero visibility into what was happening after install. Extensions run in three different execution contexts, each with its own error tracking quirks. This is fundamentally different from web app monitoring where you drop in a script tag and you're done. Manifest V3 made it worse by switching background pages to service workers that terminate after 30 seconds of inactivity. Errors that happened right before termination? Gone. Vanished into the void. I spent an embarrassing amount of time adding console.log statements that nobody would ever see.

This post is the error tracking setup I wish I'd built before launch. It covers all three contexts (background service worker, content scripts, popup), the Manifest V3 lifecycle gotchas, and install/uninstall tracking — all privacy-safe and ready for the Chrome Web Store review.

The three execution contexts (and why each breaks differently)

Browser extensions aren't a single JavaScript environment. They're three:

1. Background service worker — The extension's brain. Handles events, manages state, talks to APIs. In Manifest V3, this is a service worker that Chrome terminates after roughly 30 seconds of inactivity. When it wakes up again, all in-memory state is gone.

2. Content scripts — Injected into web pages you specify in the manifest. They share the DOM with the page but run in an isolated JavaScript context. They can't access your service worker directly — only through message passing.

3. Popup / options pages — The UI surfaces. These are normal HTML pages that run when the user clicks your extension icon or opens settings. They also terminate when closed.

Errors in each context behave differently:

  • Service worker errors might fire, then the worker dies before you can report them
  • Content script errors can get swallowed by the host page's error handlers
  • Popup errors are visible in DevTools but only if you have the popup open at the exact moment

The solution is to capture errors at the source in each context and funnel them to a central reporting endpoint. Let's set that up.

Setting up error capture in the service worker

The service worker is where most logic lives, so it's where most bugs hide. (I'm not proud of how long it took me to internalize this.) Here's a minimal error handler that catches both sync and async failures:

// background.js (service worker)
const ERROR_ENDPOINT = "https://api.justanalytics.app/v1/events";
const SITE_ID = "your-site-id";

async function reportError(error, context = "background") {
  const payload = {
    site_id: SITE_ID,
    event: "extension_error",
    properties: {
      context,
      message: error.message || String(error),
      stack: error.stack || null,
      url: self.location?.href || "service-worker",
      version: chrome.runtime.getManifest().version,
      timestamp: new Date().toISOString(),
    },
  };

  try {
    await fetch(ERROR_ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
  } catch (fetchErr) {
    // Queue for retry if network fails
    const queue = (await chrome.storage.local.get("errorQueue")).errorQueue || [];
    queue.push(payload);
    await chrome.storage.local.set({ errorQueue: queue.slice(-50) }); // cap at 50
  }
}

// Global error handler
self.addEventListener("error", (event) => {
  reportError(event.error || new Error(event.message), "background-sync");
});

// Unhandled promise rejections
self.addEventListener("unhandledrejection", (event) => {
  reportError(event.reason || new Error("Unhandled rejection"), "background-async");
});

A few things worth noting. We queue errors to chrome.storage.local when the network call fails — this matters because the service worker might terminate before the fetch completes. We cap the queue at 50 errors because storage.local has size limits and runaway error loops will fill it fast. (Ask me how I know. Actually, don't.) We also include the extension version in every error so you can filter by release — critical when you're trying to figure out if that hotfix you pushed at 2am actually fixed anything.

The next time the service worker wakes (on any event — alarm, message, user action), flush the queue:

// Flush queued errors on wake
chrome.runtime.onStartup.addListener(flushErrorQueue);
chrome.runtime.onInstalled.addListener(flushErrorQueue);

async function flushErrorQueue() {
  const { errorQueue } = await chrome.storage.local.get("errorQueue");
  if (!errorQueue?.length) return;

  for (const payload of errorQueue) {
    try {
      await fetch(ERROR_ENDPOINT, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
    } catch {
      // Still failing — leave in queue for next wake
      return;
    }
  }
  await chrome.storage.local.remove("errorQueue");
}

Content script error handling

Content scripts run in web pages, which means they compete with the page's own JavaScript for error handling. A page that sets its own window.onerror can swallow your errors before you see them. This is, frankly, a terrible developer experience — but it's what we're stuck with.

The fix is to capture errors before they propagate:

// content.js
(function () {
  const originalOnError = window.onerror;
  window.onerror = function (message, source, lineno, colno, error) {
    // Only report errors from our extension's scripts
    if (source && source.includes(chrome.runtime.id)) {
      chrome.runtime.sendMessage({
        type: "CONTENT_SCRIPT_ERROR",
        payload: {
          message,
          source,
          lineno,
          colno,
          stack: error?.stack || null,
          pageUrl: window.location.href,
        },
      });
    }
    // Let the original handler run too
    if (originalOnError) {
      return originalOnError.call(this, message, source, lineno, colno, error);
    }
    return false;
  };

  // Catch promise rejections too
  window.addEventListener("unhandledrejection", (event) => {
    chrome.runtime.sendMessage({
      type: "CONTENT_SCRIPT_ERROR",
      payload: {
        message: event.reason?.message || String(event.reason),
        stack: event.reason?.stack || null,
        pageUrl: window.location.href,
        async: true,
      },
    });
  });
})();

Then handle the message in your service worker:

// background.js
chrome.runtime.onMessage.addListener((message, sender) => {
  if (message.type === "CONTENT_SCRIPT_ERROR") {
    reportError(
      {
        message: message.payload.message,
        stack: message.payload.stack,
      },
      `content-script:${new URL(message.payload.pageUrl).hostname}`
    );
  }
});

Including the hostname in the context string helps you spot site-specific bugs. I once had a content script that broke only on GitHub because they do something unusual with MutationObserver. Without the hostname, I would have never found it. This is the kind of structured context that turns "something broke somewhere" into "this specific DOM manipulation fails on github.com/issues pages."

Popups are simpler — they're normal pages that run in an extension context. Finally, something that works the way you'd expect. But they terminate instantly when closed, so any async error reporting needs to finish fast:

// popup.js
window.onerror = (message, source, lineno, colno, error) => {
  chrome.runtime.sendMessage({
    type: "POPUP_ERROR",
    payload: { message, source, lineno, colno, stack: error?.stack },
  });
  return false;
};

window.addEventListener("unhandledrejection", (event) => {
  chrome.runtime.sendMessage({
    type: "POPUP_ERROR",
    payload: {
      message: event.reason?.message || String(event.reason),
      stack: event.reason?.stack,
    },
  });
});

Handle it the same way in your service worker:

// background.js — add to existing onMessage listener
if (message.type === "POPUP_ERROR") {
  reportError(
    { message: message.payload.message, stack: message.payload.stack },
    "popup"
  );
}

Tracking install and uninstall events

Install events are straightforward — chrome.runtime.onInstalled fires with a reason of install or update:

// background.js
chrome.runtime.onInstalled.addListener((details) => {
  const event = details.reason === "install" ? "extension_installed" : "extension_updated";

  fetch(ERROR_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      site_id: SITE_ID,
      event,
      properties: {
        previousVersion: details.previousVersion || null,
        currentVersion: chrome.runtime.getManifest().version,
      },
    }),
  }).catch(() => {}); // Fire and forget on install
});

Uninstall tracking is harder. There's no onUninstalled event because by the time the user uninstalls, your extension context is already dead. The workaround is chrome.runtime.setUninstallURL:

// background.js — call once on install
chrome.runtime.onInstalled.addListener(() => {
  chrome.runtime.setUninstallURL(
    `https://yoursite.com/uninstall?ext_id=${chrome.runtime.id}&v=${chrome.runtime.getManifest().version}`
  );
});

Then track the pageview server-side. It's not perfect — if the user is offline when they uninstall, you miss it. Annoying. But it catches 80-90% of uninstalls, which is better than the 0% you get otherwise.

Common errors and how to fix them

"Extension context invalidated" when sending messages. This happens when the service worker terminates while a content script is trying to message it — a common headache when building privacy-focused tools that need persistent background processing. Wrap your chrome.runtime.sendMessage calls in a try-catch and queue failed messages to retry:

try {
  chrome.runtime.sendMessage(payload);
} catch (err) {
  if (err.message.includes("Extension context invalidated")) {
    // Worker died — message is lost. Consider localStorage queue.
  }
}

Errors fire but never reach your endpoint. Check your manifest's host_permissions. To send data to your API, you need permission for that domain:

{
  "host_permissions": ["https://api.justanalytics.app/*"]
}

Without this, fetch calls silently fail with a CORS error that doesn't surface cleanly.

Service worker terminates mid-fetch. Manifest V3 service workers have a hard 30-second inactivity timeout. If your fetch takes longer than that (unlikely, but possible with slow endpoints or retries), the worker dies and the request is cancelled. No warning. No callback. Just silence. This is, in my opinion, the single worst design decision in Manifest V3. Use chrome.alarms to wake the worker and retry queued errors:

chrome.alarms.create("flushErrors", { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === "flushErrors") flushErrorQueue();
});

Content script errors from the host page leak into your reports. Filter by source URL. Only report errors where the source path includes your extension ID (chrome.runtime.id). Otherwise you'll get flooded with errors from websites, which is noise.

Putting it together with JustAnalytics

The endpoint examples above use JustAnalytics, but the pattern works with any error tracking service that accepts JSON payloads. The key insight is that extension contexts need explicit error capture — unlike web apps, there's no global "just add a script tag" solution. (I wish there were. I really do.)

For teams already using JustAnalytics for web apps, extensions slot into the same dashboard. Errors from your extension appear alongside your web errors, searchable by the context property. You can set up custom alerts for spikes in extension_error events, filter by extension version during rollouts, and correlate extension crashes with web traffic patterns.

The under-5KB script size matters less for extensions (you're bundling anyway), but the event API is the same. If you're tracking both a web app and a companion extension, having them in one place beats juggling Sentry for errors, GA for install counts, and a custom endpoint for extension-specific signals. That's the same consolidation story — one tool instead of three.

For teams doing ad verification or multi-account management with JustBrowser, extension error tracking pairs well with the antidetect browser's profile isolation. Same error pipeline, different browser contexts.

Next steps

This setup gives you basic visibility into extension crashes. From here, you'll probably want to add:

  • Source maps for minified production code — upload them to your error tracking service so stack traces show real line numbers instead of bundle.min.js:1:43827. We have a guide to session replay debugging that covers source map setup.
  • User consent for diagnostic data if you're shipping to EU users or the Firefox Add-ons store, which has stricter review guidelines than Chrome
  • Rate limiting on your error endpoint so a bug loop doesn't DDoS your own API (been there — at 3am, of course). VeloCalls customers use similar rate limiting for their telephony APIs.
  • Error grouping by message fingerprint so you can triage by issue, not by individual occurrence. Trust me, seeing 47,000 identical errors is not as informative as seeing "1 issue, 47,000 occurrences."

Browser extensions are under-instrumented as a category. Most developers ship them with console.log and prayer. Honestly? I was one of them for years. A few hours of setup gets you the same error visibility you'd expect from a web app — and you'll catch the bugs users can't describe.

Frequently Asked Questions

Does this work for Firefox and Safari extensions too?

Yes. The WebExtensions API is standardized across Chrome, Firefox, and Safari. The same error handling patterns apply. Firefox and Safari service workers behave slightly differently on wake timing, but the instrumentation code is identical. Safari has stricter CSP defaults so you may need to adjust manifest permissions.

Can I use Sentry instead of a custom error endpoint?

Yes. Sentry's browser SDK works in extension contexts — install it via npm and import it in your background script. The tricky part is Sentry's default transport assumes a normal web page context, and service workers don't have window. Use Sentry's fetch transport explicitly. Also be aware that Sentry's session replay won't work in extension popups or content scripts due to CSP restrictions.

Why do my errors disappear when the service worker terminates?

Service workers in Manifest V3 terminate after about 30 seconds of inactivity. Any pending async work — including fetch calls to your error endpoint — gets killed mid-flight. Use chrome.alarms or queue errors to chrome.storage.local and flush them the next time the worker wakes. Never rely on setTimeout or setInterval for anything that needs to survive termination.

How do I track install and uninstall events for my extension?

Install events fire via chrome.runtime.onInstalled with reason set to install or update. Uninstall tracking is trickier — use chrome.runtime.setUninstallURL to redirect users to a page you control, then log the hit server-side. There is no synchronous uninstall callback in Manifest V3 because the extension context is already dead by the time the event would fire.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts