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); 

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', }, }) 

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.