I counted how many times I glanced at a notification during a coding session last week.
Thirty-four times. Only four needed my attention.
So I built Serious Notification — a Chrome extension that speaks browser notifications aloud, but only when they match keywords you set. Type "failed" and only hear build errors. Type "urgent" and only hear what actually matters. Everything else stays silent.
It took a weekend to build the core. The two Manifest V3 architectural problems I hit along the way took longer to figure out. Neither of them is well documented. Here's both.
Problem 1: You can't intercept the Notification API in a regular content script
The plan seemed simple: listen for notifications, check them against keywords, speak the matching ones. A content script listening for notification events should work, right?
No.
Chrome's Notification constructor lives on window. When a website calls new Notification("You have a message"), it's using window.Notification in the page's own JavaScript context.
A standard Manifest V3 content script runs in what Chrome calls an ISOLATED world — a sandboxed environment that shares the DOM with the page but has its own separate JavaScript context. Your content script can read the DOM, but it cannot touch window.Notification on the actual page. By the time your script runs, the page's Notification constructor is completely separate from anything you can reach.
You can listen for a notification after it fires. You cannot intercept it before.
The fix: MAIN world content scripts
Manifest V3 added support for content scripts that run in the MAIN world. Set "world": "MAIN" in your manifest and your script runs directly in the page's JavaScript context — before any page code executes.
{
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["interceptor.js"],
"world": "MAIN",
"run_at": "document_start"
}
]
}
Now you can override window.Notification before the page has a chance to use it:
const OriginalNotification = window.Notification;
window.Notification = function(title, options = {}) {
window.dispatchEvent(
new CustomEvent('__sn_notification__', {
detail: { title, body: options.body || '' }
})
);
return new OriginalNotification(title, options);
};
The catch: MAIN world scripts have no Chrome extension APIs
Here is the problem with MAIN world scripts. They run in the page context, which means:
- No
chrome.storage— you cannot read the user's saved keywords - No
speechSynthesis— you cannot speak the notification aloud - No extension APIs at all
You are in the page's world. Chrome's extension APIs are not available there.
So you need two scripts.
The bridge
Script 1 (interceptor.js) runs in the MAIN world. It overrides window.Notification and fires a custom DOM event when a notification arrives.
Script 2 (content.js) runs in the ISOLATED world. It has full access to chrome.storage and speechSynthesis. It listens for the custom event:
window.addEventListener('__sn_notification__', async (e) => {
const { title, body } = e.detail;
const data = await chrome.storage.local.get(['enabled', 'keywords', 'speed']);
if (!data.enabled) return;
const keywords = data.keywords || [];
const text = `${title} ${body}`.toLowerCase();
const match = keywords.some(kw => text.includes(kw.toLowerCase()));
if (match || keywords.length === 0) {
const utterance = new SpeechSynthesisUtterance(`${title}. ${body}`);
utterance.rate = data.speed || 1.0;
speechSynthesis.speak(utterance);
}
});
Two scripts. One custom DOM event as the bridge. The MAIN world intercepts, the ISOLATED world decides.
Problem 2: Chrome showed a "read and change all your data on all websites" warning at install
The extension needs to inject on every page — that's legitimate, since you don't know which tab will fire a notification. So <all_urls> host access is genuinely required.
But I was declaring it as a required permission in manifest.json:
"content_scripts": [
{
"matches": ["<all_urls>"],
...
}
]
This caused Chrome to show the scariest possible install warning before users had any reason to trust the extension. I was watching my analytics and seeing a 33% uninstall rate. Some of that was almost certainly people bouncing at the permission screen, before they ever heard the extension speak a single word.
The fix: optional host permissions
Manifest V3 supports optional_host_permissions. Move <all_urls> there instead of requiring it at install:
{
"permissions": ["storage", "scripting"],
"optional_host_permissions": ["<all_urls>"]
}
Then register content scripts dynamically at runtime using chrome.scripting.registerContentScripts(), only after the user explicitly grants the permission:
// background.js
chrome.permissions.onAdded.addListener(async (permissions) => {
if (permissions.origins?.includes('<all_urls>')) {
await chrome.scripting.registerContentScripts([
{
id: 'interceptor',
matches: ['<all_urls>'],
js: ['interceptor.js'],
world: 'MAIN',
runAt: 'document_start',
},
{
id: 'content',
matches: ['<all_urls>'],
js: ['content.js'],
runAt: 'document_start',
}
]);
}
});
In the popup, show a "Turn on to hear alerts" button that triggers the permission request:
chrome.permissions.request({ origins: ['<all_urls>'] });
The result: the Chrome Web Store install dialog now shows Storage only. No broad-access warning. The scary prompt only appears when the user explicitly clicks "Turn on" inside the popup — after they have already installed the extension and understand what it does.
One edge case: updating from a version that required the permission
When you update an extension and a previously required permission becomes optional, Chrome removes it from the user's granted set. Existing users lose host access silently on update — the extension just stops working with no explanation.
The fix: fire a native notification immediately after update using the notifications permission:
chrome.runtime.onInstalled.addListener(({ reason }) => {
if (reason === 'update') {
chrome.notifications.create('update-notice', {
type: 'basic',
iconUrl: 'icon.png',
title: 'Serious Notification — One quick step needed',
message: 'We improved how permissions work. Open the extension and click "Turn on" to re-enable.',
requireInteraction: true,
});
}
});
Silent failure is the worst outcome for retention. If something breaks on update, tell the user immediately and tell them exactly what to do.
What I built
Serious Notification speaks Chrome notifications aloud when they match keywords you set. Free, no account, works offline. Just shipped v0.2.0 with the optional permissions model above.
If you're building Chrome extensions and hit either of these problems — happy to answer questions in the comments. The MAIN world injection in particular has some edge cases around static property copying (permission, requestPermission) that took me a while to get right.
🔗Serious Notification - Live Chrome WebStore
Known Limitations
This approach only intercepts notifications fired via the Notification constructor in the page's JavaScript context.
Notifications fired from a service worker via registration.showNotification() — which is the path used by background push notifications in apps like Slack — bypass the MAIN world override entirely. The service worker runs in its own scope and never touches the page's window.
Chrome's extension model does not currently offer a hook into arbitrary third-party service workers, so this gap cannot be closed with the current architecture.
The extension works well for in-tab notifications and apps that call the constructor directly from page scripts. For pure push-via-service-worker apps when the tab is backgrounded, it will miss notifications.
Top comments (4)
The MAIN-world-to-ISOLATED bridge over a custom DOM event is a clean way out of a genuinely annoying corner, thanks for writing up the part the docs skip. One case I'm curious about: overriding
window.Notificationcatches sites that call the constructor directly, but a lot of web apps fire notifications from a service worker viaregistration.showNotification(), which never touches the page'swindow. Does yours catch those, or is it constructor-path only? Not a knock, just wondering if the Slack/Gmail style of push notification slips past the interceptor, since that'd be the batch most people actually want spoken.You're right — constructor-path only.
Slack's background push notifications come through registration.showNotification() inside their service worker, which never touches the page's window, so the MAIN world override misses them entirely.
Chrome extensions can't inject into third-party service workers, so there's no clean fix with this architecture.
In-tab notifications from apps that call new Notification() directly from page scripts are caught. Pure push-via-SW when the tab is backgrounded — not caught.
Adding a Known Limitations section to the article now. Good catch. Open for Suggestion.
Did you consider using the Web Speech API for text-to-speech functionality in your extension? I'm curious about your experience with Manifest V3, would love to hear more about the problems you encountered.
Yes — Web Speech API was a deliberate choice from the start, and it shaped the architecture in ways I didn't fully anticipate.
The reasoning for choosing it:
The implementation is straightforward once you know where speechSynthesis lives:
One thing worth knowing if you build something similar: speechSynthesis is only available in the ISOLATED world content script — not in a MAIN world script.
Also: speechSynthesis is not available in MV3 service workers at all. I confirmed this the hard way. background.js is a service worker in MV3, so speech has to happen in a content script or the popup — not in the background.
One practical issue: if notifications arrive faster than the speech finishes, utterances queue up and you end up hearing notifications from three minutes ago. Fix is one line before every speak call:
Clears the queue. Each notification speaks immediately or not at all.
Voice quality is the real limitation. Web Speech API uses the OS's built-in voices, which vary a lot. Windows voices sound noticeably different from macOS voices, and some Linux installs have minimal voice options. Nothing you can do about this without switching to an external TTS API, which would break the offline-and-free guarantees.
On MV3 problems beyond what's in the article:
Service worker lifecycle. In MV2 you had a persistent background page that stayed alive. In MV3 the service worker wakes on events and goes idle when there's nothing to do. For this extension that's mostly fine — the interceptor lives in content scripts that are always active on pages, not in the service worker. But it matters for things like chrome.action.openPopup() which only works when the browser is in focus and a window is active. If the SW is woken by an alarm or event when Chrome is minimised, openPopup() silently fails.
Content script context invalidation. During development: every time you reload the extension in chrome://extensions, any already-open tabs still have the previous content script instance attached. That orphaned script throws "Extension context invalidated" when it tries to call chrome.storage. Not a production bug — real users don't reload extensions mid-session — but it's noisy enough during development to be worth guarding:
Put that at the top of any storage call in content.js. Silent failure instead of unhandled promise rejections in the console.
Dynamic script registration gets cleared on update. When you register content scripts via chrome.scripting.registerContentScripts() (which the optional permissions model requires), Chrome clears those registrations when the extension updates. So in onInstalled with reason "update", you have to re-register the scripts if the user had already granted permissions. Easy to forget, noticeable when you do.
Happy to go deeper on any of these if useful. MV3 has genuinely good ideas but the migration
docs skip over a lot of the sharp edges.