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.
Top comments (1)
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.