Chrome Extension Service Worker: MV3 Guide (2026)

Chrome Extension Service Worker: MV3 Guide (2026)

The single most important fact about the chrome extension service worker is that it is not a background process. It is an event handler that Chrome starts when something it listens for happens, and terminates after roughly thirty seconds of inactivity. Every variable you set is gone. Every timer you scheduled may never fire. Every connection you opened is closed.

Developers coming from Manifest V2 background pages — which stayed resident for the life of the browser session — hit this within an hour of porting, usually in the form of a counter that resets to zero, a cached auth token that vanishes, or a listener that fires the first time and never again. None of those are bugs in Chrome. They are the lifecycle working as designed.

This guide covers what the chrome extension service worker actually does, the four failure modes that account for most Manifest V3 bug reports, and the patterns that work once you stop fighting the model.

From Background Page to Service Worker

Manifest V2 gave extensions a background page: a real HTML document with a DOM, a window object, and an option to persist for the entire browser session. You could hold state in globals, keep a WebSocket open, and reach for document whenever you needed to parse HTML.

Manifest V3 replaced it with a service worker. In the manifest, the background field now takes a service worker script path rather than a page, plus an optional module type if you want ES module imports instead of importScripts.

The differences that matter:

  • No persistence. The worker runs when handling events and stops when idle.
  • No DOM. There is no document, no window, no localStorage.
  • No XMLHttpRequest. Use fetch.
  • No blocking network interception. Request modification moved to the declarative net request rules model.
  • Different global scope. self rather than window, with the service worker global scope API surface.

The motivation was resource use and security: an extension that only reacts to a keyboard shortcut should not hold memory all day, and code that cannot intercept requests at runtime is easier to review. The cost was landed entirely on extension developers, and the migration produced a large number of abandoned extensions.

If you have never built an extension at all, start with the end-to-end walkthrough in how to build a chrome extension and come back here for the lifecycle detail.

The Lifecycle: When Chrome Starts and Stops Your Worker

The chrome extension service worker starts when any of these happen:

  • The extension is installed, updated, or enabled.
  • Chrome starts and the extension has a startup listener.
  • Any event the worker registered a listener for fires — a command, a message, a tab update, an alarm, a context menu click.
  • A page or popup belonging to the extension connects to it.

It stops after a period of inactivity, on the order of thirty seconds. Two things reset that idle timer: receiving another event, and completing a call to an extension API. Chrome has relaxed the older hard ceiling on worker lifetime, so a worker that keeps receiving events can stay alive well past the original limit, but you must not design around that. Assume it dies whenever it is not busy.

Three consequences follow directly.

Cold start cost is real. Each wake-up parses and executes your worker script from the top. A script with heavy top-level work — importing a large library, building a lookup table, reading and processing storage — pays that cost on every single event. For a keyboard-shortcut extension where the user is waiting on the result, that is directly visible latency. Keep the top level of the worker tiny.

Long-running work must be chunked or moved. Anything that takes minutes cannot live in a single worker invocation. Split it into steps driven by alarms, or move it into an offscreen document or a tab you control.

Nothing in memory survives. This is the one people re-learn three times.

Failure Mode One: Listeners Registered Too Late

This is the most common Manifest V3 bug, and it is worth understanding precisely.

When an event fires and the worker is not running, Chrome starts the worker, executes the script, and then dispatches the event. Dispatch happens after the first turn of the event loop. If your listener registration has not happened by then, the event is simply lost.

So this pattern fails:

const config = await loadConfig();
chrome.commands.onCommand.addListener(handleCommand);

The await yields, the event dispatches, and the listener does not exist yet. The extension works when you are testing with the worker already warm, and fails in the wild — which makes it maddening to reproduce.

The rule: register every listener synchronously at the top level of the worker script. Do the async work inside the handler instead:

chrome.commands.onCommand.addListener(handleCommand);
chrome.runtime.onInstalled.addListener(handleInstall);
chrome.runtime.onMessage.addListener(handleMessage);

Each handler can be async internally. What must not be async is the registration itself. The same applies to conditional registration — do not wrap addListener in an if statement that depends on stored state, because that state read is asynchronous.

A related trap: a message listener that returns a promise does not keep the message channel open. To respond asynchronously you must return the literal value true from the listener synchronously, then call the response callback later.

Failure Mode Two: State in Module Variables

A module-level counter, cache, or flag in a chrome extension service worker is a variable with a thirty-second lifetime. It works during development because you are firing events rapidly. It fails for real users who trigger the extension twice an hour.

The replacements:

Session storage. In-memory, cleared when the browser closes, not written to disk, with a quota in the low tens of megabytes. This is the right home for anything you would have put in a global: a cached token, a computed lookup, a debounce flag. It is asynchronous, so every read is an await.

Local storage. Persisted to disk across browser restarts. Use it for user settings and anything that should outlive a session. Also asynchronous.

Sync storage. Persisted and synced across the user devices signed into the same profile, with much smaller quotas and per-item size limits. Settings only, never caches.

The migration pattern from a V2 background page is mechanical but tedious: every global becomes a storage read at the top of each handler and a storage write at the end. Batch the writes — storage calls are not free, and a handler that writes on every keystroke will show up in a performance trace.

One useful exception: for values that are expensive to compute and cheap to recompute, do not persist at all. Recomputing on each wake is often simpler and faster than a storage round trip, particularly for a small extension.

Failure Mode Three: Timers That Never Fire

setTimeout and setInterval exist in the worker scope, and they are unreliable there. A timeout scheduled for two minutes out will not fire if the worker is terminated at thirty seconds, which it will be, because a pending timer does not keep the worker alive.

Use the alarms API instead. Alarms are registered with Chrome rather than with the worker, survive termination, and wake the worker when they fire. The minimum interval has been reduced over time and now sits at thirty seconds for repeating alarms, which is short enough for most polling needs.

The practical guidance:

  • Under a second, inside a single handler. setTimeout is fine. The worker stays alive while it is doing API work anyway.
  • Anything longer, or anything that must survive idle. Use an alarm. Give it a name so you can identify it in the alarm handler.
  • Repeated polling. Alarms, always. And reconsider whether you need to poll at all — an event-driven design is both correct and cheaper.

Note that alarms are not precise. Chrome may delay them, particularly on battery power or when the browser is backgrounded. Do not build anything that assumes exact timing.

Failure Mode Four: Reaching for the DOM

There is no document in a chrome extension service worker. That breaks four common things:

HTML parsing. No DOMParser. Options: parse in a content script and message the result back, use a regex for trivially structured input, or open an offscreen document.

Audio playback. No Audio constructor. Offscreen document with an audio playback reason.

Clipboard writes. The async clipboard API requires a document and, usually, a focused one. A worker has neither. This is why extensions that put something on the clipboard either inject a content script into the active tab and perform the write there, or create an offscreen document with a clipboard reason and write from it. There is no way to write to the clipboard from the worker directly.

Canvas and image manipulation. OffscreenCanvas works in the worker for many cases and is the right first choice. Fall back to an offscreen document only if you need a real canvas element.

The offscreen documents API creates a single hidden document, invisible to the user, that you declare a reason for and close when finished. Only one can exist at a time per extension, so if you need it for two purposes you must coordinate. Treat it as a last resort rather than a general escape hatch — every offscreen document is a page Chrome has to keep alive, which undoes most of the resource argument for the service worker model in the first place.

Debugging an Extension Service Worker

The tooling is good once you know where it is.

Open the worker DevTools. Go to the extensions page with developer mode enabled. Your extension card shows a service worker link when the worker is running, and an inactive indicator when it is not. Clicking it opens a DevTools window scoped to the worker — console, sources, network, and a memory profiler.

Watch it terminate. Leave that DevTools window open and idle for a minute. The status flips to inactive and the console session ends. This is the single most useful thing to observe once, because it makes the lifecycle concrete.

Force a stop. The extensions page exposes a control to stop the worker, which is how you test cold-start paths deliberately rather than by waiting. Reproducing the late-listener bug requires exactly this.

Errors do not surface loudly. An exception thrown inside a handler lands in the worker console, which is closed most of the time. The extensions card shows an errors button when something has thrown — check it during development, and consider wrapping handlers in try/catch that writes to storage during testing.

Reloading invalidates contexts. After you reload the extension, existing content scripts from the previous version are orphaned and any message they send throws an extension-context-invalidated error. That error during development is usually not a real bug; reload the page and it goes away.

For DevTools techniques that apply beyond extension work, see chrome devtools tips and tricks.

A Worked Example: A One-Keystroke URL Copier

The smallest useful extension makes the model concrete. A copy-the-current-URL extension needs exactly three pieces.

A command. The manifest declares a command with a suggested key binding, which the user can rebind at the extension shortcuts page. Pressing the key fires a command event, which wakes the chrome extension service worker if it is stopped.

A handler registered at the top level. One chrome.commands.onCommand.addListener call, synchronous, first thing in the script. Inside the handler, query for the active tab in the current window to get its URL.

A clipboard write that happens somewhere with a document. As covered above, the worker cannot write to the clipboard itself. The two viable routes are injecting a small script into the active tab through the scripting API, or spinning up an offscreen document with a clipboard reason. The injection route is faster and avoids the offscreen document entirely, but it requires host access to the tab and it fails on restricted pages such as the Chrome settings pages and the Web Store.

That is the entire extension: one manifest, one worker script under fifty lines, no persistent state, no alarms, no network. It wakes on a keystroke, runs for a few milliseconds, and stops. This is exactly the shape the service worker model was designed for, and it is why small single-purpose tools survived the Manifest V3 migration while sprawling ones did not. The freely available Ctrl+Shift+C extension is this exact shape in production — clipboard permission only, no network calls, no data collection, and nothing that needs to stay resident.

The general case for keeping extensions small, which the service worker model actively rewards, is made in tiny chrome extensions.

Patterns That Work

A checklist for anything you build on the Manifest V3 model.

  1. Top-level listener registration, always. No awaits before addListener. No conditional registration.
  2. Treat every handler as a fresh process. Read what you need from storage at the start, write what changed at the end.
  3. Keep the top level of the worker cheap. Imports and setup run on every cold start. Lazy-load anything heavy inside the handler that needs it.
  4. Alarms for anything time-based beyond a few seconds. Never long timeouts.
  5. Design for zero persistent connections. If you think you need a socket open all day, reconsider the architecture — most cases resolve into an alarm plus a fetch.
  6. Use offscreen documents sparingly and close them. One at a time, with an explicit reason.
  7. Test cold. Stop the worker manually before every meaningful test. Warm-worker testing hides the majority of lifecycle bugs.
  8. Request the narrowest permissions that work. The chrome extension service worker model does not make a permission-hungry extension safe, and review scrutiny on broad host access has only increased.

Frequently Asked Questions

Why does my Chrome extension service worker keep stopping? Because it is supposed to. The worker terminates after roughly thirty seconds without activity and restarts when the next registered event arrives. Any state held in variables is lost between runs, which is the source of most porting bugs from Manifest V2.

Why are my event listeners never firing? Listeners must be registered synchronously at the top level of the worker script. If registration happens after an await or inside an async callback, Chrome dispatches the waking event before the listener exists and the event is lost. This reproduces only on a cold worker, which is why it is easy to miss in development.

How do I store state in a Manifest V3 service worker? Use the storage API. Session storage is in-memory and clears when the browser closes, which suits caches and tokens. Local storage persists to disk for settings. Both are asynchronous, so plan for an await on every read.

Does setTimeout work in an extension service worker? Timers under a second inside an active handler generally fire. Anything longer is unreliable because a pending timer does not keep the worker alive and the worker can be terminated first. Use the alarms API, which is registered with Chrome and wakes the worker when it fires.

Why can I not use the DOM in a service worker? A service worker has no document and no window, so DOM parsing, audio playback, and clipboard writes through a document are unavailable. Use a content script in a real tab, an offscreen canvas where applicable, or the offscreen documents API for the cases that genuinely need a document.

How do I debug an extension service worker? Open the extensions page with developer mode enabled and click the service worker link on your extension card. That opens DevTools scoped to the worker. Use the stop control on the same page to force termination and test cold-start behavior deliberately.

Do keyboard shortcut commands wake a stopped service worker? Yes. Command events start the worker like any other registered event, as long as the listener is registered at the top level of the script. This is what makes one-keystroke extensions viable under Manifest V3 despite the worker not being resident.

Build Small, Wake Fast

The Manifest V3 model punishes extensions that want to be applications and rewards extensions that want to be functions. A chrome extension service worker that wakes on an event, does one thing in a few milliseconds, and goes back to sleep is fast, cheap, and effectively immune to the entire class of lifecycle bugs described above. A worker holding state, connections, and timers is fighting the platform every day.

If you want to see the pattern in its smallest form, install Ctrl+Shift+C — a command listener, an active-tab query, and a clipboard write, free, with clipboard permission only, no network calls, and zero data collection. It is a working reference for the shape of extension that the service worker model was built to run, and a genuinely useful shortcut while you are building your own.

Try Ctrl+Shift+C

Copy any URL with one keyboard shortcut. Free forever, no data collected.