Browser Extensions

Why Your Manifest V3 Service Worker Keeps Stopping (and How to Fix It)

In Manifest V3 your background script is no longer always running. Most 'random' extension bugs come from forgetting that. Here are the causes and the patterns that fix them.

By Prolificrew Engineering · · 4 min read

In this article
  1. The symptoms
  2. Cause 1: state held in global variables
  3. Cause 2: listeners registered too late
  4. Cause 3: timers that cannot survive a shutdown
  5. Cause 4: async message responses
  6. Cause 5: using page-only APIs in the worker
  7. Cause 6: long tasks with no event keeping the worker alive
  8. A debugging checklist
  9. Design for restarts from the start

If you migrated a Chrome extension from Manifest V2 to Manifest V3 and it now works for a while and then mysteriously stops, you have met the service worker lifecycle. In MV2 a background page could stay alive for as long as the browser was open. In MV3 the background is a service worker: the browser starts it when an event arrives and stops it again when it is idle. Anything you kept only in memory goes with it. This guide explains the symptoms, the causes and the fixes. For the wider picture of building an extension, see our Manifest V3 guide.

The symptoms

  • A variable that held the user's state is suddenly empty or undefined.
  • A timer created with setTimeout or setInterval never fires.
  • A message from a content script or popup gets no response, or the console says the message port closed before a response was received.
  • An event listener works right after you reload the extension, but not later in the day.
  • The extension works with the developer tools open and fails when they are closed, because open tools can keep the worker alive.

Cause 1: state held in global variables

Global variables in the service worker disappear whenever it is stopped. Treat the worker as stateless. Keep anything you need later in chrome.storage: use storage.local for data that should persist, storage.session for data that should live only for the browser session, and storage.sync for small settings that follow the user. Read what you need at the start of each event handler.

// Bad: lost when the worker stops
let count = 0;
chrome.runtime.onMessage.addListener(() => { count++; });

// Good: survives restarts
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  chrome.storage.session.get({ count: 0 }).then(async ({ count }) => {
    await chrome.storage.session.set({ count: count + 1 });
    sendResponse({ count: count + 1 });
  });
  return true; // keep the channel open for the async response
});

Cause 2: listeners registered too late

When an event wakes the worker, Chrome starts the script and then dispatches the event to listeners that were registered while the script first ran. If you add a listener inside a promise, a callback or after an await, it may not exist yet when the event arrives, and the event is lost. Register every event listener synchronously at the top level of the service worker file.

// Bad: registered after an async step
loadSettings().then(() => {
  chrome.alarms.onAlarm.addListener(handleAlarm);
});

// Good: registered immediately, loads settings inside the handler
chrome.alarms.onAlarm.addListener(async (alarm) => {
  const settings = await loadSettings();
  handleAlarm(alarm, settings);
});

Cause 3: timers that cannot survive a shutdown

setTimeout and setInterval live inside the worker, so they die with it. For delayed or repeating work use the chrome.alarms API, which wakes the worker when the time comes. Be aware that Chrome limits how short an alarm period can be, so check the current documentation if you need frequent ticks; very frequent polling is usually a sign the design should use events instead.

Cause 4: async message responses

If a message handler answers asynchronously, it must return true from the listener so Chrome keeps the message channel open. Forgetting this produces the 'message port closed before a response was received' error. Also remember that the sender may be talking to a worker that has just been started, so keep handlers fast and avoid assuming earlier state.

Cause 5: using page-only APIs in the worker

A service worker has no DOM. There is no window, document, localStorage or DOMParser. If you used them in your MV2 background page, you need a different approach: use chrome.storage instead of local storage, and for tasks that genuinely need a DOM, such as parsing HTML, playing audio or using the clipboard, create an offscreen document with the chrome.offscreen API and send it messages.

Cause 6: long tasks with no event keeping the worker alive

A worker is stopped after a period with no activity, and very long single tasks can also be cut off. Break large jobs into steps, save progress to storage, and resume from the saved state. Do not try to keep the worker alive with artificial pings; that works against the design, can change between Chrome versions and may cause review problems. If something truly needs to keep running, ask whether it belongs in your own server, in an offscreen document or in the content script instead.

A debugging checklist

  1. Open chrome://extensions, find your extension and click the service worker link to open its console. Note when it says the worker is inactive.
  2. Search the worker file for global variables that hold state and move them to storage.
  3. Check that every addListener call runs synchronously when the file loads.
  4. Replace setTimeout and setInterval for anything longer than a few seconds with chrome.alarms.
  5. Make sure every asynchronous message handler returns true.
  6. Test with the developer tools closed, and test after leaving the browser idle for a few minutes.

Design for restarts from the start

The most reliable MV3 extensions are written as small event handlers that read state, do one job and write state back. If you design that way from day one, the worker stopping becomes a non-event. If your extension is already in production and failing in the ways above, we can audit it and fix the lifecycle problems; send us the details.