DEV Community

INNERLUXES
INNERLUXES

Posted on

Manifest V3 Service Workers: Why Your Chrome Extension Keeps Losing State

The bug report always reads the same way. The extension works. Then the user leaves it alone for a minute, comes back, clicks the toolbar icon, and nothing happens. Or the badge count is wrong. Or the auth token the popup needs has vanished. Reload the extension and everything is fine again, which makes the whole thing look like a ghost.

It is not a ghost. In Manifest V3 the background page is gone and a service worker took its place, and a service worker is not a process that stays running. Chrome starts it when an event needs it and shuts it down when nothing does. Every piece of state held in a module-scope variable goes with it.

This post covers the exact lifecycle numbers, the five rules that follow from them, and the reason the bug never reproduces while you are watching it in DevTools.

What Chrome actually does to your worker

Four behaviours matter, and all four are documented:

  1. Idle for 30 seconds and the worker is terminated. Receiving an event or calling an extension API resets that timer.
  2. A single event or API call that runs longer than 5 minutes is killed.
  3. A fetch() whose response takes more than 30 seconds to arrive is killed.
  4. Global variables do not survive. The next event starts a fresh JavaScript instance with every module global back at its initial value.

There is no chrome.runtime.keepAlive(). There is no manifest key that asks for a persistent worker. The lifecycle is not a setting, it is the contract.

Two narrow exceptions are worth knowing. Since Chrome 116, four APIs that put a prompt in front of the user are allowed to run past the five-minute ceiling, because the clock is really the user's: desktopCapture.chooseDesktopMedia(), identity.launchWebAuthFlow(), management.uninstall() and permissions.request(). Also since Chrome 116, an active WebSocket connection extends the worker's life, and every message sent or received over that socket resets the idle timer.

Everything below follows from those four behaviours.

Rule 1: register every listener at the top level, synchronously

This is the single most common cause of "my extension responds to nothing until I reload it".

When an event arrives for a sleeping worker, Chrome boots the worker, runs the script top to bottom, and then dispatches the event. If the listener for that event is not registered by the time the first turn of the event loop finishes, the event is already gone. It is not queued.

So this breaks:

// background.js — BROKEN
chrome.storage.local.get('settings', ({ settings }) => {
  // registered one microtask too late; the event that woke us is already lost
  chrome.action.onClicked.addListener(() => openPanel(settings));
});
Enter fullscreen mode Exit fullscreen mode

And this breaks for the same reason:

// background.js — ALSO BROKEN
const settings = await chrome.storage.local.get('settings'); // top-level await
chrome.runtime.onMessage.addListener(handleMessage);
Enter fullscreen mode Exit fullscreen mode

Top-level await is legal in a service worker module and it still loses you the wake-up event, because the registration now happens after the initial evaluation.

The fix is to register first and load state inside the handler:

// background.js — CORRECT
chrome.action.onClicked.addListener(async (tab) => {
  const { settings = defaults } = await chrome.storage.local.get('settings');
  await openPanel(tab, settings);
});

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  handleMessage(msg, sender).then(sendResponse);
  return true; // keep the message channel open for the async reply
});

chrome.runtime.onInstalled.addListener(seedDefaults);
chrome.runtime.onStartup.addListener(resyncFromServer);
Enter fullscreen mode Exit fullscreen mode

Three details in that block earn their place:

  • return true from an onMessage listener is what keeps the channel open for an asynchronous sendResponse. Without it the port closes the moment the listener returns and the sender's callback gets undefined.
  • onInstalled fires on install and on update. It is the right place to write default settings and to call chrome.alarms.create.
  • onStartup fires when the browser profile starts. It is the right place to rebuild anything that lives only in memory.

A useful habit is to keep background.js as nothing but a list of top-level listener registrations plus imports, and push all the work into other modules. If the file has any conditional or awaited code above a registration, that is the bug.

Rule 2: module scope is a cache, never a source of truth

A variable at module scope in a service worker is a cache with an eviction policy you do not control. Treat it exactly that way.

// BROKEN — token is gone 30 seconds after the last event
let authToken = null;

async function getToken() {
  if (!authToken) authToken = await fetchToken();
  return authToken;
}
Enter fullscreen mode Exit fullscreen mode

The version that survives:

async function getToken() {
  const { authToken, authExpiry } = await chrome.storage.session.get([
    'authToken',
    'authExpiry',
  ]);

  if (authToken && authExpiry > Date.now() + 30_000) return authToken;

  const fresh = await fetchToken();
  await chrome.storage.session.set({
    authToken: fresh.token,
    authExpiry: Date.now() + fresh.expiresIn * 1000,
  });
  return fresh.token;
}
Enter fullscreen mode Exit fullscreen mode

Pick the storage area deliberately:

Need Use Notes
Secrets and session state chrome.storage.session In memory, cleared when the browser closes, not written to disk
Settings that must persist chrome.storage.local Survives restarts and updates
Settings that follow the user chrome.storage.sync Small quotas, per-item size limits
Large structured data IndexedDB Use when a quota or a query pattern rules out storage
Fetched assets CacheStorage Same API as in a page worker

localStorage and sessionStorage are not available in an extension service worker at all, which is its own source of confusion when porting a Manifest V2 background page.

One more trap: chrome.storage reads and writes are asynchronous, and two events can wake the worker close together. If a counter matters, do not read, add one and write. Serialise the update behind a promise chain, or keep the counter somewhere that gives you an atomic operation.

Rule 3: setTimeout and setInterval are not timers any more

A timer longer than the idle window simply does not fire, because the worker holding it is gone before the callback is due.

// BROKEN — the worker is terminated long before this runs
setInterval(syncWithServer, 15 * 60 * 1000);
Enter fullscreen mode Exit fullscreen mode

Use chrome.alarms, which lives in the browser rather than in the worker and wakes the worker up to deliver the event:

chrome.runtime.onInstalled.addListener(() => {
  chrome.alarms.create('sync', { periodInMinutes: 15 });
});

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === 'sync') await syncWithServer();
});
Enter fullscreen mode Exit fullscreen mode

Notes that save time:

  • The minimum period is 30 seconds as of Chrome 120. Earlier versions clamp to one minute, and in a released extension anything under a minute was silently rounded up.
  • Alarms persist across worker restarts but not across extension updates reliably, so create them in onInstalled rather than assuming they are still there.
  • chrome.alarms.create with the same name replaces the existing alarm instead of adding a second one, which makes it safe to call on every install and update.
  • Short setTimeout calls inside a single event handler are fine. The rule is about anything that needs to outlive the current event.

Rule 4: design for a 5-minute ceiling

A long import, a bulk upload, a full re-index: any one of these can exceed five minutes in one event handler and be killed mid-way, leaving half-written state behind.

The pattern that holds up is a queue plus an alarm. Write the work items to storage, process a bounded batch per wake-up, and let the alarm bring the worker back for the next batch.

const BATCH = 25;

chrome.alarms.onAlarm.addListener(async ({ name }) => {
  if (name !== 'drain-queue') return;

  const { queue = [] } = await chrome.storage.local.get('queue');
  if (!queue.length) {
    await chrome.alarms.clear('drain-queue');
    return;
  }

  const batch = queue.slice(0, BATCH);
  const done = await processBatch(batch); // returns the ids it finished
  await chrome.storage.local.set({
    queue: queue.filter((item) => !done.includes(item.id)),
  });
});
Enter fullscreen mode Exit fullscreen mode

Because each wake-up re-reads the queue and removes only what finished, a termination at any point costs one batch of work, not the job. The same shape makes the job resumable after a browser restart for free.

The 30-second fetch() limit deserves its own line. If a request to your own backend can legitimately take longer than that, the extension is the wrong place to wait for it. Return a job id immediately and poll.

Rule 5: ports and sockets, and what they actually keep alive

A long-lived connection from a content script or popup via chrome.runtime.connect() keeps the worker alive while the port is open, and messages across it reset the idle timer. That is useful and it is also a trap, because the port closes when the popup closes or the tab navigates, and the worker then goes to sleep on the usual 30-second clock. Any code that assumed the port is still there needs an onDisconnect handler:

chrome.runtime.onConnect.addListener((port) => {
  const onTick = (data) => port.postMessage(data);
  subscribe(onTick);
  port.onDisconnect.addListener(() => unsubscribe(onTick));
});
Enter fullscreen mode Exit fullscreen mode

On the content script side, reconnect rather than assume:

let port;

function connect() {
  port = chrome.runtime.connect({ name: 'panel' });
  port.onMessage.addListener(render);
  port.onDisconnect.addListener(() => {
    port = null;
    setTimeout(connect, 1000); // the worker was asleep; try again
  });
}

connect();
Enter fullscreen mode Exit fullscreen mode

An open WebSocket extends the worker's lifetime since Chrome 116, which makes a push connection viable, but it does not make it permanent. Treat every reconnect as normal operation and keep the server side idempotent.

What does not work is a keepalive loop that pings itself to stay awake. It burns battery, it is a review flag, and Chrome has closed off the versions of the trick that used to work. If the design needs a process that never sleeps, that process belongs on a server, not in a browser extension.

Rule 6: anything that needs a DOM goes in an offscreen document

A service worker has no document, no DOMParser, no Audio, no canvas, and no clipboard. Code ported from a Manifest V2 background page hits this within minutes.

The chrome.offscreen API creates a hidden document for exactly these cases:

async function ensureOffscreen() {
  const existing = await chrome.runtime.getContexts({
    contextTypes: ['OFFSCREEN_DOCUMENT'],
  });
  if (existing.length) return;

  await chrome.offscreen.createDocument({
    url: 'offscreen.html',
    reasons: ['DOM_PARSER'],
    justification: 'Parse HTML returned by the sync endpoint',
  });
}
Enter fullscreen mode Exit fullscreen mode

Two things to keep in mind. The reasons array is reviewed, so it has to be honest. And the offscreen document has its own lifetime, so close it when the work is done rather than leaving it open as a back door to a persistent background page.

Why it works on your machine

This is the part that wastes the most days.

While DevTools is attached to the service worker, Chrome does not terminate it. The worker you are inspecting is immortal, so none of the bugs above can reproduce. The state stays in memory, the interval keeps firing, the token is still there, and the conclusion is that the report must be wrong.

Reproduce it properly instead:

  1. Open chrome://extensions, find the extension, and close any DevTools window attached to the worker.
  2. Open the card's service worker link only when you need the console, and remember that opening it changes the behaviour you are testing.
  3. On the extensions page, the worker's status shows when it is inactive. Click Service worker and it starts; wait 30 seconds without touching the extension and it stops.
  4. Exercise the feature cold: trigger the event with the worker already stopped, not immediately after a reload.

A faster loop is to make the assumption testable in code. A single accessor for every piece of cross-event state, with a log line on a cache miss, turns "it loses the token sometimes" into a line in the console the first time the worker restarts.

The checklist

Before shipping a Manifest V3 worker, confirm each of these:

  • Every addListener call runs during initial evaluation, with no await, condition or callback above it.
  • No await at the top level of the worker entry file.
  • No cross-event state in module scope that is not backed by chrome.storage, IndexedDB or CacheStorage.
  • No setInterval, and no setTimeout expected to outlive the current event. Alarms instead, created in onInstalled.
  • Every alarm handler checks alarm.name.
  • Every long job is a queue drained in bounded batches, resumable after a kill at any point.
  • No fetch() on the critical path that can exceed 30 seconds.
  • Every port has an onDisconnect handler on both sides, and the content script reconnects.
  • onMessage listeners that reply asynchronously return true.
  • DOM work is in an offscreen document that gets closed.
  • The feature has been tested once with the worker cold and DevTools closed.

Most Manifest V3 migrations that are described as unstable are not fighting a Chrome bug. They are running Manifest V2 assumptions about a page that never went away on a worker that is designed to. Once state lives in storage, timers live in alarms and listeners are registered in the first turn of the event loop, the worker being terminated stops being an event worth handling.


Written by the engineering team at Innerluxes. We build Chromium browsers and browser extensions, so this checklist comes out of migrations and audits rather than from the spec alone. If there is a lifecycle case here that does not match what you are seeing, the comments are a good place for it.

Top comments (0)