chrome.storage.onChanged not firing: the cases that never trigger it
6 min readModerok team
Why chrome.storage.onChanged never fires in an MV3 service worker, the top-level listener fix, and the writes Chrome does not report at all.
If chrome.storage.onChanged is not firing in a Manifest V3 extension, the listener is almost always registered too late. It has to be attached at the top level of the service worker, before any await, and it has to watch the storage area you are actually writing to:
// background.js
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "local") return;
const change = changes.settings;
if (!change) return;
applySettings(change.newValue);
});
// Everything async comes after the listener is attached.
init();
{
"manifest_version": 3,
"name": "My extension",
"version": "1.0.0",
"background": { "service_worker": "background.js" },
"permissions": ["storage"]
}
That covers the common failure. The rest of this post is the cases where the listener is correct and Chrome still reports nothing.
Why top-level registration is the fix
An MV3 background script is a service worker, and Chrome keeps shutting it down. The lifecycle page lists three conditions under which Chrome normally terminates one, and the first is "After 30 seconds of inactivity. Receiving an event or calling an extension API resets this timer." It also says that "if the service worker has gone dormant, an incoming event will revive them."
Reviving means Chrome runs your script again from the top and then dispatches the queued event. If addListener runs after an await in your bootstrap, or inside a chrome.storage.local.get(...) callback, the worker finishes its startup turn with no listener attached, so Chrome has nothing to deliver to. The reason is timing: registration has to happen during that initial synchronous evaluation, which is why Chrome tells you to keep handlers out of functions altogether rather than reason case by case about where the first await lands. Chrome's events guide states the rule directly: "Event handlers in service workers need to be declared in the global scope", and "they should be at the top level of the script and not be nested inside functions", because that "ensures that they are registered synchronously on initial script execution, which enables Chrome to dispatch events to the service worker as soon as it starts."
The symptom is confusing because it is intermittent. While you have DevTools open on the worker it stays alive, the listener you attached inside your async bootstrap is still in memory, and everything works. Close DevTools, wait, change a value from the options page, and the event disappears.
Check the manifest before you debug the listener at all. Chrome's storage reference requires you to declare the "storage" permission to use the API, so without it there is no event to miss.
You are listening on the wrong area
chrome.storage.onChanged is global and receives changes from every area. Each area also has its own event, and MDN describes the difference: "Compared to storage.onChanged, this event enables you to listen for changes in one of the storage areas: local, managed, session, or sync."
Two mismatches account for most silent listeners:
- Listening on
chrome.storage.sync.onChangedwhile your code writes withchrome.storage.local.set. The per-area event only sees its own area. If you are not sure which area your settings actually live in, chrome.storage.local vs sync walks through the quotas and the reasons to pick one. - Reading
areaNamefrom a per-area listener.chrome.storage.local.onChangedpasses onlychanges; the second parameter isundefined, so a guard likeif (areaName !== "local") return;silently rejects every event. Use the globalchrome.storage.onChangedif you want the area name.
The write did not change anything
MDN describes the trigger for the global event as: "Fired when storageArea.set, storageArea.remove, or storageArea.clear executes against any of the storage areas." Chrome narrows that in practice. The WebExtensions community group issue Inconsistency: storage onChanged records Chrome as returning only the changed values and not dispatching the event when nothing changed, with maintainer agreement that firing only on real changes is the intended behaviour.
Chrome's own reference does not spell this out, so do not build on it as a guarantee in either direction. Two practical consequences:
- Writing the same object back (a settings save where the user changed nothing, a counter re-saved at its current value) may produce no event at all in Chrome. If your UI depends on a round trip through storage to confirm a save, confirm it from the
setcallback instead. - When you do get an event, the
changesobject holds only keys Chrome considers changed, sochanges.someKeybeingundefinedmeans that key was not part of this write, not that it was deleted. A deletion arrives as aStorageChangewith anoldValueand nonewValue.
The context cannot see that area
Chrome's storage reference says "All extension contexts, including the extension service worker and content scripts have access to the Storage API." The exception is storage.session, where the reference notes "By default, it's not exposed to content scripts, but this behavior can be changed by calling" setAccessLevel. A content script listening for session-area changes therefore gets nothing until you raise the access level from the service worker. The same restriction produces a louder error on read, which we covered in Access to storage is not allowed from this context.
Two more context traps:
- A listener in a popup or options page only exists while that page is open. Closing the popup tears down its JavaScript context; changes made afterwards are not queued for it.
window.localStorageandchrome.storageare unrelated stores. Nothing written tolocalStorageon an extension page ever reacheschrome.storage.onChanged.
What still breaks after the fix
A correctly registered listener does not make storage a reliable message bus.
Your handler competes with worker shutdown. The event resets the idle timer, but long async work inside the handler can still be cut short by another condition on that same list, "When a single request, such as an event or API call, takes longer than 5 minutes to process.", and anything you only kept in a module-level variable is gone on the next wake. Persist what matters before you await.
Writes are not ordered for you. Two contexts doing read-modify-write on the same key will each emit an event, and the second write can clobber the first. Serialise writes in one place, usually the service worker.
A failed write emits nothing. If chrome.storage.local.set fails, for example over quota, it reports through chrome.runtime.lastError in the callback and no change event follows. A listener that never fires can mean the write never landed.
Firefox differences
Firefox is the browser where the no-op rule goes the other way. MDN's note is explicit: "In Firefox, the listener receives all the keys from a storage area where storageArea.set executes. The listener may be invoked when there is no change to the data." So code written against Chrome, where a redundant save is quiet, can loop in Firefox if the handler writes back to the same area. Compare oldValue and newValue yourself before acting.
MDN also notes that "Firefox does not fire this event for changes to storage.managed because managed storage is only read on browser startup (from the JSON manifest (native manifest) file or 3rdparty enterprise policy)." If an enterprise-policy-driven feature re-reads storage.managed on an event in Chrome, give Firefox a startup read instead.
Where this shows up with analytics
If you add a chrome.storage.onChanged listener for debugging and suddenly see keys you did not write, check the key names before chasing them. The Moderok SDK keeps its state under __moderok__ in chrome.storage.local and, when sync is available, a small identity record under __moderok_identity__ in chrome.storage.sync, which is why the storage permission is a required part of setup in the Moderok manifest guide. Filter your handler to your own keys and the noise goes away.