How I built a Chrome extension that speaks notifications — and the two Manifest V3 problem i faced

Leader 4
calendar_today agoschedule5 min read
— Originally published at dev.to

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.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

Just completed another large-scale WordPress migration — and the client left this

saqib_devmorph - Apr 7

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelski - Mar 19

Why Are There Only 13 DNS Root Servers For The Whole World? Is that a problem

richarddjarbeng - May 7

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelski - Apr 23
chevron_left
764 Points4 Badges
2Posts
1Comments
4Connections
Solo dev building useful software.

Related Jobs

View all jobs →

Commenters (This Week)

3 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!