On this page
Blog

Three things MV3 taught me about the service-worker lifecycle

8 min readUpdated Aug 25, 2026

An hourglass running out of sand against a black background

The feature worked. I tested it, it passed, I shipped it. Twenty minutes into a user session, it stopped responding. Not with an error - it just went quiet. The content script sent a message to the background, the background did not reply, and from the outside it looked like the whole feature had been removed. Restarting the browser fixed it. Reloading the extension fixed it. The problem was not reproducible in dev tools because opening dev tools for the background page keeps the service worker alive. I had been testing in a condition that made the bug invisible.

This is the canonical Manifest V3 debugging trap. The background is no longer a persistent page that lives as long as the browser does. It is a service worker that Chrome terminates after 30 seconds of inactivity - the documented figure, not an approximation, and the timer is reset by any event or any call to an extension API.[1] When it wakes back up on the next event, it starts fresh - no in-memory state, no timers, no open connections, nothing from the previous invocation. The code I had written assumed a persistent process model. It was wrong about its environment.

The extensions this came out of are ones I built - Save Image As Type, TheTab and the ZeroUtil images extension - so read the war stories below as first-hand rather than surveyed. I have shipped MV3 across them over the past two years and the lifecycle is the part I underestimated most. This is the developer’s side of that change; the same manifest read from the user’s side, including what the ones I build can and cannot see, is what a browser extension can actually see. Three lessons below cost me real debugging time. Each one is a consequence of the same root cause - the service worker is ephemeral - but they surface in different ways.

Lesson 1: Termination is not a bug - it is the contract

Chrome documents the idle timeout precisely: the worker is terminated “after 30 seconds of inactivity”, and “receiving an event or calling an extension API resets this timer” (Chrome extension service worker lifecycle). Two other documented ceilings sit next to it and catch people out separately: a single request may not take longer than 5 minutes to process, and a fetch response has to arrive within 30 seconds.[1] So the worker does not die on a wall clock - it dies on a quiet clock, and any API call you make pushes the deadline out by another 30 seconds. This is intentional. It is the whole point of the service worker model: no persistent background processes, no memory leaks that accumulate over days of browser uptime, no extension that silently consumes CPU. The browser makes the lifecycle decision, not the extension.

The failure mode this creates is straightforward: any state you store in module-scope variables is gone on the next wake. A map you built up over ten message events. A timer you set to fire in 60 seconds. A WebSocket connection you opened. An in-flight promise whose resolution you were waiting on. None of these survive termination.

The fix is to treat the worker as stateless by default and rehydrate from chrome.storage on each cold start. Here is the wrong approach and the right approach side by side:

// Wrong: module-scope state that vanishes on termination
const cache = new Map();

chrome.runtime.onMessage.addListener((msg, sender, reply) => {
  if (msg.type === "get") {
    reply({ value: cache.get(msg.key) }); // always undefined after cold start
  }
  if (msg.type === "set") {
    cache.set(msg.key, msg.value);
  }
});
wrong: module state, gone on the next wake
// Right: treat storage as the source of truth
chrome.runtime.onMessage.addListener((msg, sender, reply) => {
  if (msg.type === "get") {
    chrome.storage.session.get(msg.key, (result) => {
      reply({ value: result[msg.key] });
    });
    return true; // keep the message channel open for async reply
  }
  if (msg.type === "set") {
    chrome.storage.session.set({ [msg.key]: msg.value });
  }
});
right: storage as the source of truth

chrome.storage.session was added specifically for MV3 to address this pattern. It persists across worker restarts within a browser session but is cleared when the browser closes - the right semantics for ephemeral extension state. chrome.storage.local persists across browser restarts. Which one you use depends on whether the state needs to survive a browser restart.

The rehydration pattern means that on every cold start, the first message the worker handles reads from storage rather than from an in-memory cache that does not exist yet. The cost is one storage read on the first message after a wake, and I have never had it show up in a profile. The data being silently absent because you assumed a persistent process is a correctness problem, which outranks a latency question either way.

Lesson 2: Event listeners must be registered at the top level, synchronously, on every load

The service worker registers event listeners by running the module script. Chrome evaluates the top level of the script synchronously on each wake, before dispatching any events, and requires listeners to be registered there.[2] This is where you get to reconnect to the event system. If your listener is behind an async boundary - inside a promise callback, inside an await, inside a timeout - it may not be registered before the event that woke the worker fires.

The wrong shape looks like this:

// Wrong: listener registered inside an async callback
async function setup() {
  const config = await chrome.storage.local.get("config");
  // By the time this resolves, the event that woke the worker
  // may have already been dispatched with no listener registered.
  chrome.runtime.onMessage.addListener((msg) => {
    handleMessage(msg, config);
  });
}

setup();
wrong: the listener registered inside an async callback

The right shape is synchronous listener registration at the module top level:

// Right: listener registered synchronously at top level
let config = null;

chrome.runtime.onMessage.addListener((msg) => {
  // config may be null on first message after cold start
  // handle that case explicitly inside the listener
  handleMessage(msg, config);
});

// Load config asynchronously, separately from listener registration
chrome.storage.local.get("config", (result) => {
  config = result.config ?? defaultConfig;
});
right: the listener registered synchronously at the top level

This pattern means the listener is registered before any async work begins. If a message arrives while config is still loading, the listener handles it and deals with the null config explicitly (either with a fallback or by queuing the message). The alternative - missing the event entirely because the listener was not yet registered - is harder to debug because it produces silence rather than an error.

The same constraint applies to chrome.alarms.onAlarm, chrome.tabs.onUpdated, chrome.action.onClicked, and every other event source. Register all listeners synchronously at the top level, always, even if the handler itself needs async data to do its work. The handler can await; the registration cannot.

The canonical documentation for this pattern is in the Chrome MV3 service worker lifecycle guide, which explains the registration window in more detail than any blog post will. Read it once. The constraint is a few paragraphs but it will save you a full debugging session.

Lesson 3: State that used to live in setInterval now needs the alarms API

The setInterval and setTimeout APIs work inside a service worker. They do not work across the service worker’s lifetime boundary. If you set a 5-minute interval inside the worker, it fires correctly - until the worker is terminated after 30 idle seconds. The next wake starts a fresh module execution; the interval is gone.

I hit this when I had a background polling loop that was supposed to check an external status endpoint every two minutes. It worked for the first two checks, then stopped. The interval was set at module load, ran twice, and the third firing never happened because the worker was terminated before the two-minute mark.

The fix is chrome.alarms, which is the platform-level timer that survives service worker restarts. The browser persists alarms in its own scheduler and wakes the service worker when they fire.

// Create the alarm once (check it does not already exist to avoid duplication)
chrome.alarms.get("status-poll", (existing) => {
  if (!existing) {
    chrome.alarms.create("status-poll", { periodInMinutes: 2 });
  }
});

// Handle it with a listener registered at top level
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === "status-poll") {
    checkStatus();
  }
});
chrome.alarms: created once, handled at the top level

The alarm persists until you clear it with chrome.alarms.clear("status-poll"), even across browser restarts. The handler runs on each wake triggered by the alarm. The worker does its work, goes idle, gets terminated, and the next alarm fires cleanly on schedule.

One gotcha: chrome.alarms has a minimum period of 1 minute in released extensions, and the limit is relaxed during development.[2] If you need sub-minute intervals, the architecture is different - you need to keep the worker alive via another mechanism, or redesign the feature to not require sub-minute background activity.

The general rule for anything that used setInterval or setTimeout with a long duration in an MV2 background page: replace it with chrome.alarms. For short-duration timeouts inside a single event handler that completes well within the 30-second window, plain setTimeout is fine - the worker will not be terminated while it is actively handling an event chain.

The one-line rule

The mental model I ended up with is this: the background service worker is a stateless event handler that can die at any instant between events.

Write code for that model. Register listeners synchronously. Read state from storage, not from module scope. Use chrome.alarms for anything that needs to happen on a schedule. Avoid patterns that assume the worker is alive between the event that set up state and the event that needs to read it.

The MV2 background page model was easy to reason about because it matched the mental model of a normal long-running program. The MV3 model matches the mental model of a serverless function - it wakes, it handles one event, it may die. The debugging difficulty is that MV3 code running under dev tools often does not die because the inspector keeps the worker alive. Your tests need to account for that. Close the extension inspector before testing lifecycle behavior. Add artificial idle time. Test against the environment the worker will actually run in, not the environment that is convenient to inspect.

The Chrome service workers documentation is thorough and accurate. The three lessons above are the ones that were not obvious from reading the docs the first time - they became obvious only after debugging sessions that would have been shorter if I had internalized the stateless-handler model from the start.

Sources

Every claim below about something outside this site traces to a source in this list. Where a claim could not be sourced, it was removed rather than softened.

  1. Extension service worker lifecycle

    Primary sourceChrome for Developers, Google

    The exact figures the article turns on: termination after 30 seconds of inactivity, that any event or extension API call resets the timer, a 5-minute ceiling on a single request and 30 seconds for a fetch response.

  2. About extension service workers

    Primary sourceChrome for Developers, Google

    That listeners must be registered synchronously in the top level of the worker script on every wake, and that chrome.alarms has a one-minute minimum period in released extensions.

New posts by email

One email when something new lands here. No schedule, no digest, no sales sequence - and you can leave from any of them.

Your address is used for these posts and nothing else.