DEV Community

M.Bilal Khan
M.Bilal Khan

Posted on

How I built a Chrome extension that speaks your notifications aloud — and the two Manifest V3 problems nobody warned me about

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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>"],
    ...
  }
]
Enter fullscreen mode Exit fullscreen mode

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>"]
}
Enter fullscreen mode Exit fullscreen mode

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',
      }
    ]);
  }
});
Enter fullscreen mode Exit fullscreen mode

In the popup, show a "Turn on to hear alerts" button that triggers the permission request:

chrome.permissions.request({ origins: ['<all_urls>'] });
Enter fullscreen mode Exit fullscreen mode

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,
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
nazar-boyko profile image
Nazar Boyko

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.Notification catches sites that call the constructor directly, but a lot of web apps fire notifications from a service worker via registration.showNotification(), which never touches the page's window. 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.

Collapse
 
mbilalkhan192003 profile image
M.Bilal Khan

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.

Collapse
 
frank_signorini profile image
Frank

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.

Collapse
 
mbilalkhan192003 profile image
M.Bilal Khan

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:

  • Zero cost, no API key, nothing to provision
  • Zero latency — no round-trip to an external service
  • Works completely offline after install
  • No audio data leaves the browser at any point
  • Built into Chrome, so no dependency to manage

The implementation is straightforward once you know where speechSynthesis lives:

const utterance = new SpeechSynthesisUtterance(
  `${title}. ${body}`
);
utterance.rate = userSavedSpeed; // 0.5x to 2x
utterance.pitch = 1;
utterance.volume = 1;
speechSynthesis.speak(utterance);
Enter fullscreen mode Exit fullscreen mode

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:

speechSynthesis.cancel();
Enter fullscreen mode Exit fullscreen mode

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:

if (!chrome.runtime?.id) return;
Enter fullscreen mode Exit fullscreen mode

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.