Your Chrome Extension Service Worker Is Lying to You (Here's How to Catch It)

AppBooster Team · · 11 min read
Developer debugging Chrome extension service worker errors in DevTools

Your service worker stopped working. You open DevTools. Nothing in the console. No error. No stack trace. Just… silence.

This is MV3 in 2026. And if you’ve migrated — or are still migrating — you already know that service worker debugging in Manifest V3 is a completely different beast than background pages ever were. The rules changed. The failure modes changed. And Chrome’s tooling hasn’t fully caught up.

By August 2025, 73.4% of Chrome extensions had migrated to MV3. The remaining MV2 extensions were force-removed when Chrome officially killed support in July 2025. So whether you wanted this migration or not, you’re in it now.

Here’s what nobody tells you: most MV3 service worker bugs aren’t crashes. They’re silent failures — events that never fire, state that vanishes, listeners that register too late. This post is about finding those.


The 5-Minute Termination Problem

This is the one that catches everyone first.

Chrome terminates idle service workers after approximately 5 minutes. Background pages in MV2 lived forever. Service workers don’t. Chrome designed this intentionally for battery and memory efficiency — but the consequence for extension developers is brutal: any state you stored in memory is gone.

// This looks fine. It will fail after 5 minutes of inactivity.
let userPreferences = {};

chrome.runtime.onMessage.addListener((message) => {
  if (message.type === 'GET_PREF') {
    return userPreferences[message.key]; // undefined after termination
  }
});

The fix isn’t complex, but it requires changing how you think about state entirely.

// Persist everything that needs to survive termination
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'GET_PREF') {
    chrome.storage.local.get(message.key, (result) => {
      sendResponse(result[message.key]);
    });
    return true; // keep message channel open for async response
  }
});

Every piece of state that matters goes into chrome.storage.local. Not sessionStorage. Not a module-level variable. chrome.storage.local. Treat your service worker like a serverless function that gets cold-started on every event — because that’s exactly what it is.

Chrome DevTools service worker lifecycle panel showing terminated state The service worker lifecycle panel in DevTools — your first stop when debugging termination issues


”Service Worker Registration Failed” Tells You Nothing

You’ve seen it. The console says:

Service worker registration failed. Status code: 15

Status code 15. What does that mean? Chrome doesn’t say. There’s no stack trace. No file reference. Just a number.

This generic error masks a surprisingly wide range of actual problems:

  • A syntax error anywhere in your service worker file
  • An import that fails to resolve
  • A top-level await outside an async function
  • A missing permission in your manifest
  • A module that uses browser APIs unavailable in service workers (like localStorage)

The fastest way to narrow it down: open chrome://inspect/#service-workers and click “inspect” on your extension’s service worker. This gives you a dedicated DevTools panel for the worker itself — separate from the popup or content script context.

From there, check the Sources panel. If the worker failed to parse, you’ll often see the actual file with the problematic line highlighted. It’s not always there, but when it is, it saves you 20 minutes of guessing.

The other thing to check immediately: your manifest.json background field.

// MV3 - correct
{
  "background": {
    "service_worker": "background.js",
    "type": "module"
  }
}

If you’re using ES modules in your service worker, you need "type": "module". Without it, any import statement will cause a silent registration failure.


How DevTools Lies to You

Here’s something that will save you hours of confusion.

When you have the service worker’s DevTools panel open, the worker stays alive. Chrome keeps it running because it assumes you’re actively inspecting it. The moment you close DevTools, the normal 5-minute idle timer kicks back in.

This creates a specific class of bug that only appears in production: your extension works perfectly during development (because you always have DevTools open), then breaks for real users.

The fix is to test your extension with DevTools closed. Deliberately. Leave it for 6+ minutes. Come back. Trigger the flow you’re testing. See if it still works.

You can also force-test termination behavior without waiting. In chrome://inspect/#service-workers, find your extension’s worker and click “Stop”. This simulates termination. Then trigger an event from your popup or content script. Does the worker restart and handle the event correctly? It should.

// Top-level logging — runs every time the worker starts (including restarts)
console.log('[SW] Worker started at', new Date().toISOString());

chrome.runtime.onInstalled.addListener(() => {
  console.log('[SW] onInstalled fired');
});

chrome.runtime.onMessage.addListener((message) => {
  console.log('[SW] Message received:', message.type);
});

Add startup logging at the very top of your service worker file. Every time you see this log in the console, the worker just cold-started. If you see a message handler fire without the startup log appearing, something is wrong with your listener registration timing.


Async Listeners: The Silent Failure You Don’t See

This one is subtle and it burns people constantly.

In MV3, event listeners must be registered synchronously at the top level of your service worker. Chrome registers the events during the worker’s startup phase. If your listener registration is inside an async callback or a Promise chain, Chrome might not know about it in time — and the event gets dropped silently.

// BROKEN - async registration
async function init() {
  const config = await chrome.storage.local.get('config');
  
  // This listener might never fire
  chrome.runtime.onMessage.addListener((message) => {
    handleMessage(message, config);
  });
}

init();
// CORRECT - synchronous registration, async logic inside
let configPromise = chrome.storage.local.get('config');

chrome.runtime.onMessage.addListener(async (message, sender, sendResponse) => {
  const config = await configPromise;
  handleMessage(message, config, sendResponse);
  return true;
});

Register the listener synchronously. Do the async work inside the listener. The distinction matters more than it looks — Chrome’s event dispatch system checks for registered listeners immediately after the worker starts, before any microtasks resolve.

Code editor showing Chrome extension service worker with proper event listener registration Synchronous listener registration is non-negotiable in MV3 service workers


Replace setTimeout With the Alarms API

If you’re using setTimeout or setInterval for any periodic work in your service worker, stop. They don’t survive worker termination.

// This dies when the worker terminates
setTimeout(() => {
  syncData();
}, 30 * 60 * 1000);

The replacement is chrome.alarms. It’s browser-managed, persists across worker restarts, and fires even if the worker isn’t currently running (Chrome will restart it to deliver the alarm).

// Register the alarm once (check first to avoid duplicates)
chrome.runtime.onInstalled.addListener(() => {
  chrome.alarms.get('sync-alarm', (alarm) => {
    if (!alarm) {
      chrome.alarms.create('sync-alarm', {
        periodInMinutes: 30
      });
    }
  });
});

// Handle it every time the worker starts
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'sync-alarm') {
    syncData();
  }
});

The chrome.alarms API requires the "alarms" permission in your manifest. The minimum period is 1 minute — you can’t use it for sub-minute intervals. For anything more frequent than that, you need a different architectural approach (like keeping a content script alive and messaging the worker).


The chrome://inspect Workflow You Should Know Cold

Most developers know about chrome://extensions but underuse chrome://inspect/#service-workers. Here’s the full inspection workflow:

Step 1: Navigate to chrome://inspect/#service-workers

Step 2: Find your extension in the list. It shows the worker URL, status (running/stopped), and a timestamp.

Step 3: Click “inspect” to open a dedicated DevTools instance for the worker context only. This is separate from your popup DevTools.

Step 4: In this worker DevTools, go to Application > Service Workers to see the full lifecycle: installing, waiting, active, redundant.

Step 5: Use the Console tab here to run arbitrary code in the worker context:

// Run this directly in the worker's DevTools console
chrome.storage.local.get(null, console.log); // dump all stored state
chrome.runtime.getManifest().version; // verify which version is loaded

This is especially useful for checking whether your storage state is what you think it is after a worker restart.

Pro tip: If you’re seeing ERR_BLOCKED_BY_CLIENT or mysterious fetch failures from your service worker, check the Network tab in the worker’s DevTools panel — not the page’s Network tab. Service worker network requests appear in a completely separate context.


Debugging the “Worker Starts, Then Nothing Happens” Case

You add startup logging. You see the log. Then nothing else fires. No errors. Just startup and silence.

This usually means one of three things:

1. Your event listener registration has a runtime error.

Wrap your top-level code in a try-catch and log everything:

try {
  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    console.log('[SW] Message:', message);
    // your logic
  });
  
  console.log('[SW] Listeners registered successfully');
} catch (error) {
  console.error('[SW] Registration error:', error);
}

2. The message isn’t reaching the worker.

Verify from the sender’s side. In your popup or content script:

chrome.runtime.sendMessage({ type: 'PING' }, (response) => {
  if (chrome.runtime.lastError) {
    console.error('Send failed:', chrome.runtime.lastError.message);
  } else {
    console.log('Response:', response);
  }
});

chrome.runtime.lastError is how Chrome surfaces messaging errors. Always check it in the callback.

3. The worker isn’t the active version.

If you’ve updated your extension and the old version is still cached, Chrome might be running the old worker. Go to chrome://extensions, find your extension, and click the reload icon (the circular arrow). Then check chrome://inspect/#service-workers to confirm the new version is active.

Chrome extensions management page showing extension reload controls Force-reloading your extension from chrome://extensions ensures the latest service worker version is active


Monitoring Worker Health in Production

Once you ship, you can’t open DevTools on a user’s machine. You need your extension to report its own health.

A lightweight approach using chrome.storage.local as a health log:

async function logWorkerEvent(event, data = {}) {
  const log = await chrome.storage.local.get('worker_health');
  const health = log.worker_health || { events: [] };
  
  health.events.push({
    event,
    data,
    timestamp: Date.now()
  });
  
  // Keep last 50 events only
  if (health.events.length > 50) {
    health.events = health.events.slice(-50);
  }
  
  await chrome.storage.local.set({ worker_health: health });
}

// Log every startup
console.log('[SW] Starting');
logWorkerEvent('startup');

// Log key lifecycle events
chrome.runtime.onInstalled.addListener((details) => {
  logWorkerEvent('installed', { reason: details.reason });
});

Then expose this log in your extension’s options page for debugging. When a user reports a bug, you can ask them to open options and copy the worker health log. It’s not perfect telemetry, but it beats flying blind.

For more serious monitoring, ExtensionBooster’s free developer tools include an extension health checker that can surface manifest issues, permission problems, and common MV3 migration errors before they hit your users.


The Honest State of MV3

Developer sentiment on MV3 has been mixed-to-negative since day one. The service worker model is genuinely harder to work with than background pages. The debugging story is incomplete. The restrictions (no persistent background, no remote code execution, limited webRequest) have frustrated developers building legitimate tools.

That frustration is valid.

But MV3 is also what you’re shipping now. MV2 is gone. The extensions that survive — and grow — are the ones built by developers who understand the new constraints deeply enough to work around them.

Service workers aren’t broken. They’re just different. And the debugging techniques above actually work once you internalize the lifecycle model.


What to Do Right Now

If your extension is misbehaving and you don’t know why, run this sequence:

  1. Open chrome://inspect/#service-workers and find your extension
  2. Click “Stop” to simulate termination, then trigger your broken flow
  3. Open the worker’s dedicated DevTools and check the Console for startup logs
  4. Run chrome.storage.local.get(null, console.log) in the worker console to verify state
  5. Check that all event listeners are registered synchronously at the top level
  6. Verify any setTimeout calls are replaced with chrome.alarms
  7. Test with DevTools fully closed for 6+ minutes before retesting

If you’re still stuck after that, the issue is almost always one of: async listener registration, missing return true for async message responses, or state that wasn’t persisted before a worker restart.

The bugs are findable. The tools exist. You just have to know where to look.

Developer successfully debugging Chrome extension with clean console output A clean worker console after systematic debugging — worth every frustrating minute getting there

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles