DEV Community

Dmitry
Dmitry

Posted on

Write Your Chrome MV3 Background Worker Like It Never Dies

How a build-time AST transform finally makes Manifest V3 state persistence painless


Manifest V3 didn’t just change the extension model — it actively punishes normal JavaScript patterns.

Your background service worker is killed after roughly 30 seconds of inactivity. When Chrome restarts it, every top-level let, every Map, every pending setTimeout is gone. The next event handler starts from a clean slate.

The official workaround is brutal:

  • Manually serialize every piece of state to chrome.storage
  • await it back on every event
  • Rewrite long-running timers as chrome.alarms
  • Maintain careful locking so concurrent restarts don’t cause duplicate work

The result is hundreds of lines of defensive plumbing that has nothing to do with your actual feature.

A different approach: transform the code, not the developer

vite-mv3-sw-compile-persist takes a build-time approach. You keep writing ordinary JavaScript. The plugin rewrites your background entry so that state and timers actually survive restarts.

You write:

let counter = 0;
let cache = new Map();
let isSyncing = false;

chrome.action.onClicked.addListener(() => {
  counter++;
  cache.set(Date.now(), true);
});

setTimeout(() => cleanup(), 5 * 60_000);
Enter fullscreen mode Exit fullscreen mode

After the transform you effectively get:

  • Automatic dirty tracking on every mutation
  • Restoration from chrome.storage.session (or local) on startup
  • Long timers backed by chrome.alarms
  • Recovery of registered callbacks after a worker death

No runtime library to import. No decorators. No wrapper APIs. The source stays clean.

How the transform actually works

The plugin runs three passes over the background entry:

1. Analyze (Babel AST)

It walks the AST and collects:

  • Top-level let / var declarations (with special handling for Map, Set, Date, arrays, and plain objects)
  • Every assignment and method call that mutates those variables
  • All setTimeout / setInterval / clearTimeout / clearInterval calls
  • Event handlers registered via .addListener
  • Function declarations (needed for callback recovery)

It is careful about control flow. Bare bodies of if / for / while are detected so the later injection of dirty-tracking code remains inside the correct branch.

2. Transform (magic-string)

  • After every relevant assignment or mutation it injects __mv3_markDirty('varName'). When necessary it wraps the statement in braces first.
  • Long timers (default threshold: 60 000 ms) are rewritten to __mv3_setTimeout / __mv3_setInterval wrappers that use chrome.alarms under the hood.
  • Short timers stay native (with optional offscreen-document keepalive).
  • Event handlers that do not return a value are made async and receive an await __mv3_init so restored state is guaranteed to be ready before your logic runs.
  • Handlers that do return a value (classic onMessage return true, blocking webRequest, etc.) are left completely untouched.

3. Generate runtime

A virtual module is emitted and inlined. It contains:

  • State restore on startup
  • Debounced flush of dirty variables (default 2 s)
  • Serialization / deserialization for Map, Set, Date, and arrays
  • Alarm registration and callback recovery via a function registry
  • Stale-lock recovery for boolean flags (so an isRunning = true that never got cleared doesn’t deadlock the next incarnation)
  • Optional Proxy-based reactivity when stateStrategy: 'proxy' is enabled

The common path stays fast — almost nothing runs unless the worker actually restarts.

Configuration that matters

mv3Persistent({
  backgroundEntry: /background[\\/]index\.ts$/,
  stateStrategy: 'proxy',          // catch nested + Map/Set/Date mutations
  storageArea: 'session',          // or 'local'
  alarmThresholdMs: 60_000,
  autoFlushMs: 2_000,
  keepaliveStrategy: 'alarm',      // 'alarm' | 'offscreen' | 'none'
  staleLockThresholdMs: 30_000,
  promiseResolverStrategy: 'native',
  namedAlarmHandlers: {            // for callbacks that can’t be auto-recovered
    cleanup: 'runCleanup',
  },
})
Enter fullscreen mode Exit fullscreen mode

stateStrategy: 'static' is faster and sufficient for simple scalar / top-level reassignment patterns.

proxy is the recommended setting for real applications that mutate objects, Maps, or Sets.

What it deliberately does not break

  • chrome.runtime.onMessage handlers that return true (or any value) stay synchronous.
  • Blocking webRequest listeners are left alone.
  • Source maps are preserved.
  • The generated runtime is small and only activated on the restart path.

Current limitations (honest list)

  • Destructuring declarations (let { a, b } = …) are not supported for persistence — use individual lets.
  • Anonymous timer callbacks that close over non-persisted local variables cannot be reliably recovered after a restart. Prefer named functions or register them via namedAlarmHandlers.
  • new Promise(resolve => setTimeout(resolve, …)) is inherently fragile when the worker can die. The plugin warns and offers strategies, but there is no perfect solution.
  • Requires Vite ≥ 8 and the "storage" (plus "alarms" when using long timers) permission.

Why this approach feels different

Most MV3 advice forces you to change how you write code.

This plugin changes the compiled output so that ordinary code starts behaving correctly.

The difference in day-to-day development is large: you stop thinking about the service-worker lifecycle on every line and start focusing on the feature again.


If you maintain a non-trivial MV3 extension, try it on a side branch. The first time you kill the worker from chrome://serviceworker-internals and watch your counter keep incrementing, the value becomes obvious.
Here’s the improved ending with a clear GitHub link and a proper call for stars:


If you maintain a non-trivial MV3 extension, try it on a side branch. The first time you kill the worker from chrome://serviceworker-internals and watch your counter keep incrementing, the value becomes obvious.

Try it

Repository: https://github.com/GameGC/vite-mv3-sw-compile-persist

If this plugin saves you from the MV3 state-management hell, please give it a ⭐. It really helps more people find it.

Top comments (2)

Collapse
 
alexshev profile image
Alex Shev •

The key mindset shift is treating the service worker as a cache, not a process. Persist the state needed to resume, make listeners idempotent, and assume an event can arrive after restart. That also makes the architecture easier to test: kill the worker between every meaningful transition.

Collapse
 
mp3sss31 profile image
mp3sss •

Clever approach - trading runtime discipline for a build-time AST transform is the right call for MV3's brutal lifecycle. The parallel in Electron is the renderer's 'killed without warning' reality: you learn to persist state on mutation rather than on teardown, because beforeunload is never a guarantee. One caution from that world - automatic dirty-tracking can mask how often state actually changes, so keep the serialized surface small (session, not local) and treat alarms as the source of truth for anything time-critical.