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 -
awaitit 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(orlocal) 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/vardeclarations (with special handling forMap,Set,Date, arrays, and plain objects) - Every assignment and method call that mutates those variables
- All
setTimeout/setInterval/clearTimeout/clearIntervalcalls - 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_setIntervalwrappers that usechrome.alarmsunder the hood. - Short timers stay native (with optional offscreen-document keepalive).
- Event handlers that do not return a value are made
asyncand receive anawait __mv3_initso restored state is guaranteed to be ready before your logic runs. - Handlers that do return a value (classic
onMessagereturn true, blockingwebRequest, 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 = truethat 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.onMessagehandlers that returntrue(or any value) stay synchronous. - Blocking
webRequestlisteners 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 individuallets. - 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.

