Debugging a Service Worker That Won't Start

Fix MV3 service workers stuck as inactive or failing to register: diagnose manifest path errors, syntax issues, type:module imports, top-level await, and uncaught startup exceptions.

Published June 19, 2026 Updated June 19, 2026 10 min read
Table of Contents

The service worker shows “inactive” on chrome://extensions and the Inspect link is greyed out. Alternatively, the extension loads without error but the background context never responds to messages. Both symptoms share a root cause: the worker failed to register or threw an uncaught exception during its startup sequence, and Chrome quietly swallowed the failure. This guide walks through every layer of the registration pipeline and gives you a deterministic sequence for isolating which layer broke. It is part of the debugging extension contexts guide.

Why this happens in MV3’s execution model

MV3 service workers follow the browser’s standard service worker lifecycle: parse → install → activate → idle → terminated → (wake on event). Chrome registers the worker from the path declared in manifest.json under background.service_worker. Any failure in the parse or install phase terminates the worker immediately. Unlike a traditional background page, there is no persistent process to keep alive — a startup failure means the entire background context is gone, with no accessible console.

The Errors panel on chrome://extensions is the only surface that captures these pre-console failures. The service worker DevTools Inspect link is only available after the worker reaches the activated state — if the worker never activates, the link never appears (or appears briefly then disappears).

manifest.json
    │
    ▼ background.service_worker path resolved
    │
    ├─[file not found]──────────────────────► Registration failure (Errors panel)
    │
    ▼ script fetched and parsed
    │
    ├─[syntax error / bad import]──────────► Parse error (Errors panel)
    │
    ▼ top-level code executed
    │
    ├─[uncaught throw / top-level await]───► Install event rejected (Errors panel)
    │
    ▼ listeners registered → install event fired
    │
    ▼ activate event fired
    │
    ▼ worker reaches "activated" state ────► Inspect link appears

Step 1 — Check the Errors panel first

Before opening any DevTools, navigate to chrome://extensions and click the Errors button on your extension card. If the button is absent, enable Developer mode in the top-right toggle.

The Errors panel surfaces every failure that occurred before the service worker reached an inspectable state. Common entries and their meanings:

Error message fragmentRoot cause
Could not load background scriptThe background.service_worker path does not exist on disk
Failed to register a ServiceWorkerPermission or scope mismatch — rare in extensions, usually a manifest typo
SyntaxError: Unexpected tokenSyntax error in the worker script or an imported module
Cannot use import statementtype: "module" missing from the manifest background declaration
The top-level-await experiment...Top-level await used without type: "module"
Uncaught (in promise) ReferenceErrorA module import resolved but a referenced identifier does not exist
1// manifest.json — the background declaration must be exact
2{
3  "manifest_version": 3,
4  "background": {
5    "service_worker": "background.js",  // relative to manifest.json, no leading slash
6    "type": "module"                    // required for ES module import/export syntax
7  }
8}

Execution context: Parsed by the Chrome extension host at install/reload time. The service_worker value is resolved relative to the directory containing manifest.json. If your build tool emits to a dist/ subdirectory, the path must reflect the output structure — it does not crawl subdirectories. Omitting "type": "module" when your worker uses import statements produces the Cannot use import statement error even though the file is valid TypeScript/ES module source.*

Step 2 — Verify the file path and build output

The most common startup failure is a path mismatch between manifest.json and the actual build output. Run this check before anything else:

1# Confirm the file the manifest references actually exists in the build output
2ls -la dist/background.js   # or whatever path you declared in manifest.json
3
4# Confirm manifest.json is at the root of the loaded directory
5ls dist/manifest.json

Execution context: Shell, run from the project root. If background.js is missing, the build step did not emit it — check your bundler entry points. If manifest.json is present but the path inside it uses a subdirectory (e.g., "service_worker": "js/background.js"), confirm that dist/js/background.js exists, not dist/background.js.*

After fixing any path mismatch, reload the extension on chrome://extensions via the circular arrow reload button. Do not rely on the extension auto-reloading — it does not detect new files added to the directory.

Step 3 — Isolate syntax and import errors

If the Errors panel reports a parse error, the challenge is finding it: the error usually names the file but the line number may point at transpiled output rather than your TypeScript source.

Enable source maps in your build configuration so the line number in the error message maps back to your original file. Then reproduce the error with a minimal worker:

 1// background.ts — strip everything except the failing import to isolate it
 2console.log('[SW] script start');   // appears in Errors panel if the script parses
 3
 4// Re-add imports one at a time until the error reappears
 5import { initStorage } from './storage.js';
 6
 7console.log('[SW] imports resolved');  // if this line never appears, initStorage import failed
 8
 9chrome.runtime.onInstalled.addListener(() => {
10  console.log('[SW] onInstalled fired');
11});

Execution context: Service worker. The sequential console.log calls act as a binary search across the startup sequence. Because the Errors panel captures output even from workers that immediately terminate, you can see how far execution reached. If [SW] script start appears but [SW] imports resolved does not, the import of ./storage.js is the failure point. If neither appears, the parse error is in the outer file itself.*

Step 4 — Fix type:module and bare import specifiers

"type": "module" enables ES module syntax in the service worker but also enables strict module semantics. Two traps are common:

1// WRONG — bare specifier without a bundler or import map fails at runtime
2import { something } from 'lodash';
3
4// CORRECT — use a bundled output or relative paths only
5import { something } from './vendor/lodash.esm.js';
1// WRONG — .ts extension does not resolve in Chrome's module loader
2import { helper } from './helper.ts';
3
4// CORRECT — reference the compiled output extension
5import { helper } from './helper.js';

Execution context: Service worker module loader (Chrome’s native ES module loading). Chrome does not run a transpiler — it loads the output file exactly as written. If your TypeScript source uses .ts extensions in imports, your bundler must rewrite them to .js in the output. Confirm this with grep -r "from '.*.ts'" dist/ — any match is a bug.*

A module worker that imports a file that itself has a syntax error will fail at the import resolution stage, not at parse time for the outer file. The Errors panel will name the inner file in the error, which can be confusing. Work outward from the innermost file named in the error.

Step 5 — Diagnose top-level await pitfalls

Top-level await is valid in module workers ("type": "module") but it blocks the entire install event until the awaited Promise resolves. If the Promise never resolves or rejects, the worker hangs indefinitely in the installing state and never activates.

 1// DANGEROUS — if getConfig() never resolves, the worker never activates
 2const config = await getConfig();
 3
 4// SAFER — move async initialization inside an event listener
 5chrome.runtime.onInstalled.addListener(async () => {
 6  const config = await getConfig();
 7  await chrome.storage.local.set({ config });
 8});
 9
10// ALSO SAFE — top-level await only for values that always resolve quickly
11const manifest = await chrome.runtime.getManifest();   // synchronous-equivalent, resolves immediately

Execution context: Service worker module top-level scope. The browser gives the install event a bounded time to complete (30 seconds in most implementations). A network fetch at the top level that times out will cause the worker to fail to activate after that timeout. Use chrome.runtime.onInstalled for initialization logic that requires I/O.*

To confirm a top-level await is the culprit: check the Errors panel for "The install event handler ran for too long" or a timeout-related message, and check chrome://extensions for a worker stuck in "installing" state for more than a few seconds.

Step 6 — Catch uncaught exceptions on startup

An uncaught exception at the top level of the service worker (outside any try/catch) terminates the install event and prevents activation. These appear in the Errors panel but can be hard to spot if the error message is generic.

 1// background.ts — wrap all top-level synchronous initialization
 2try {
 3  const initialState = buildInitialState();   // any synchronous init code
 4  registerListeners(initialState);
 5} catch (err) {
 6  // Log to the Errors panel-visible surface before termination
 7  console.error('[SW] Fatal startup error:', err);
 8  // Re-throw so Chrome reports this as an install failure rather than silently succeeding
 9  throw err;
10}
11
12function registerListeners(state: AppState) {
13  chrome.runtime.onMessage.addListener((msg) => handleMessage(msg, state));
14  chrome.alarms.onAlarm.addListener((alarm) => handleAlarm(alarm, state));
15}

Execution context: Service worker top-level scope. The explicit throw after logging ensures Chrome records this as a registration failure in the Errors panel rather than treating it as a handled error. Without the re-throw, Chrome may mark the worker as activated despite it being in an inconsistent state. This pattern also makes the stack trace visible when source maps are active.*

Cross-browser variation

  • Chrome — The Errors panel and chrome://extensions Inspect link are the primary debugging surface. “inactive” in the extension card status indicates the worker has been evicted but successfully registered; “error” or a missing Inspect link indicates a registration failure.
  • Firefox — Navigate to about:debugging#/runtime/this-firefox and click Inspect on the extension. The combined DevTools panel shows startup errors in the console immediately, without a separate Errors panel. Firefox distinguishes "stopped" (evicted, healthy) from "terminated due to error" in its extension console.
  • Safari — Use Develop → Web Extension Background Content. Safari surfaces startup errors as NSLog entries visible in Console.app rather than in browser DevTools. Check the system console with the process filter set to com.apple.WebKit.WebContent.

Verification

After applying fixes, confirm the worker is healthy:

  1. Navigate to chrome://extensions. The extension card should show a blue Service Worker link, not a greyed-out one.
  2. Click Service Worker to open the SW DevTools. In the Console tab, confirm you see your startup log lines (e.g., [SW] onInstalled fired).
  3. In the Application tab → Service Workers, verify the worker shows Status: activated and running or Status: activated and idle.
  4. Trigger a background event (e.g., click the extension icon) and confirm the worker wakes and processes it without errors.
  5. Clear the Errors panel via Clear all and reload the extension — reconfirm the Errors panel remains empty after a fresh load.
1// Quick smoke-test assertion you can run from the SW DevTools console
2chrome.runtime.getBackgroundPage
3  ? 'MV2 background page (wrong context)'
4  : 'MV3 service worker (correct)';

Execution context: Typed directly into the SW DevTools Console tab. chrome.runtime.getBackgroundPage is undefined in MV3 service workers — its presence would indicate you are accidentally inspecting an MV2 extension or the wrong DevTools window.*

FAQ

The worker activated (reaching the Inspect state) but then immediately threw an uncaught exception or timed out. Open the Errors panel right after the link disappears — the error from the brief activation window is captured there. This pattern usually indicates an uncaught exception in an async function called at the top level that was not awaited.

My worker is “inactive” but not “errored” — is that a problem?

No. “Inactive” means the worker was evicted after the idle timeout (approximately 30 seconds with no events). This is normal MV3 behaviour. Click the Service Worker link to wake it, or trigger an extension event (open the popup, fire an alarm). The worker should reactivate within a few hundred milliseconds.

I get “Cannot use import statement outside a module” — but I set type:module

Confirm the "type": "module" field is at the right level in manifest.json:

1{
2  "background": {
3    "service_worker": "background.js",
4    "type": "module"    // must be inside "background", not at the root
5  }
6}

If the field is present and correctly nested, check that your build tool is not emitting CommonJS (require/module.exports) output — Chrome’s ES module loader rejects CommonJS syntax with this error even if the field is set.

How do I debug a worker that fails only in production (CRX), not when loaded unpacked?

Production CRX files are fetched from the Web Store and loaded differently. Check the Errors panel on a device where the CRX is installed. The most common production-only failure is a resource file (icon, HTML page) referenced in manifest.json that was omitted from the CRX package. Run zip -sf your-extension.zip | grep background to confirm all background-related files are in the package.

Other Testing, Debugging & Performance Optimization Resources