← Back to blog
Deep Dive · 10 min read

Manifest V3 Service Workers: The Complete Mental Model

Service workers are the biggest change in MV3 — and the source of the most bugs. Here's how to think about them correctly.

Illustration for the ManifestGo article: Manifest V3 Service Workers: The Complete Mental Model

Manifest V2 used long-lived background pages. Manifest V3 replaced them with short-lived service workers that wake up on events and shut down when idle. This single change breaks more first-time extensions than anything else.

What changes in practice

  • No DOM access — service workers can't read or modify HTML directly.
  • No in-memory state — the worker dies between events.
  • All persistence must go through `chrome.storage` or IndexedDB.
  • Timers (`setTimeout`, `setInterval`) don't survive worker shutdown — use `chrome.alarms`.

The correct mental model

Treat the service worker like a serverless function: it wakes up, handles one event, persists what it needs, and exits. Anything you keep in module-level variables will be gone next time. Anything you scheduled with `setTimeout` will not fire.

What ManifestGo does for you

Every generated extension uses `chrome.alarms` for scheduling and `chrome.storage.sync` for state — so your extension keeps working when the worker recycles.

Lifecycle events and exact timeouts

A Manifest V3 service worker is unloaded after roughly 30 seconds of inactivity, and Chrome enforces a hard 5-minute cap on any single event handler even if it's actively doing work (30 seconds if it's waiting on a response with no active listener). If your handler calls fetch() and awaits a slow API, register the request before any await that could exceed these windows, and consider chrome.alarms with a 1-minute-minimum period instead of a long-running loop.

The manifest fields that matter

  • background.service_worker: 'background.js' — the single entry file, no background.page or background.scripts array like MV2.
  • background.type: 'module' — required if you use ES module import statements inside the worker.
  • "manifest_version": 3 — non-negotiable; MV2 submissions have been rejected by the Web Store since June 2024 for new items and June 2025 for existing ones with limited exceptions.
  • permissions: ['alarms'] — required to use chrome.alarms at all; forgetting it throws 'chrome.alarms is undefined' at runtime, not at load time.

Common error messages and what they mean

  • 'Service worker registration failed. Status code: 3' — usually a syntax error in background.js that prevents it from parsing at all.
  • 'Unchecked runtime.lastError: The message port closed before a response was received' — you called sendResponse asynchronously without returning true from onMessage.
  • 'Manifest version 2 is deprecated' banner in chrome://extensions — the extension will stop loading in a future Chrome release; migrate immediately.

Waking the worker reliably

Service workers wake on six triggers: extension install/update, browser startup, an alarm firing, a message via chrome.runtime.onMessage, a declarativeNetRequest match, or the user opening the popup/options page. If your logic depends on none of these — for example 'check every 10 seconds' — it will not run reliably. chrome.alarms.create with periodInMinutes has a practical floor of 1 minute (0.5 minutes above 30 in older Chrome, but 2026 versions still enforce the 1-minute floor for unpacked/non-policy extensions).

Debugging a worker that keeps dying

  1. Open chrome://extensions, click 'service worker' under your extension to open its dedicated DevTools.
  2. Watch the 'Inactive' status — if it flips right after a log line, your handler returned without keeping the event loop alive.
  3. Add console.log('SW start') and console.log('SW idle-check') at both ends of async handlers to see the exact drop-off point.
  4. If using fetch(), confirm you're not relying on a response after the worker's 30-second idle window without an open message channel.

State that must never live in a module variable

Auth tokens, feature flags, in-progress counters, and anything a popup or content script needs later must be written to chrome.storage.session (cleared on browser close, kept in memory only — good for tokens) or chrome.storage.local/sync (persisted to disk). chrome.storage.session was introduced specifically for MV3 to replace the in-memory globals MV2 developers relied on.

If you can't answer 'what wakes this code back up', the service worker model isn't fully understood yet — and ManifestGo's generated background.js always answers that question explicitly in comments.

Frequently asked questions

How long can a Chrome MV3 service worker run before Chrome kills it?

Roughly 30 seconds of idle time triggers unload, and any single event handler is capped at 5 minutes of active execution regardless of idle state. Pending network requests without an active listener are cut off after about 30 seconds.

Why does my chrome.alarms alarm not fire every few seconds?

Chrome enforces a practical minimum period of 1 minute for chrome.alarms.create to prevent battery and CPU abuse. For sub-minute polling you need a persistent connection via chrome.runtime.connect while a popup or tab is open, not an alarm.

Do I need background.type: module in every MV3 manifest?

Only if your background.js uses import/export syntax. Without it, Chrome parses the file as a classic script and any ES module import statement throws a SyntaxError at registration time.

Keep reading