<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: M.Bilal Khan</title>
    <description>The latest articles on DEV Community by M.Bilal Khan (@mbilalkhan192003).</description>
    <link>https://dev.to/mbilalkhan192003</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1716950%2Fd394b897-07c3-4b11-a635-fc908095c26c.jpeg</url>
      <title>DEV Community: M.Bilal Khan</title>
      <link>https://dev.to/mbilalkhan192003</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mbilalkhan192003"/>
    <language>en</language>
    <item>
      <title>Android Is Taking Away Orientation Locks on Large Screens. Here’s What Developers Need to Know</title>
      <dc:creator>M.Bilal Khan</dc:creator>
      <pubDate>Thu, 10 Sep 2026 07:43:29 +0000</pubDate>
      <link>https://dev.to/mbilalkhan192003/android-is-taking-away-orientation-locks-on-large-screens-heres-what-developers-need-to-know-3ob0</link>
      <guid>https://dev.to/mbilalkhan192003/android-is-taking-away-orientation-locks-on-large-screens-heres-what-developers-need-to-know-3ob0</guid>
      <description>&lt;p&gt;For years, Android developers have had a simple way to control how their apps behave: tell the system that an activity should stay in portrait, keep it in landscape, or otherwise restrict how the app can rotate.&lt;/p&gt;

&lt;p&gt;That familiar approach is changing.&lt;/p&gt;

&lt;p&gt;Starting with &lt;strong&gt;Android 16, API level 36&lt;/strong&gt;, apps targeting API 36 or higher can no longer rely on orientation restrictions on large-screen devices. That includes tablets and other devices where the display has a &lt;strong&gt;smallest width of 600dp or more&lt;/strong&gt;. On those screens, Android can ignore an app's requested orientation and allow the user and the device to determine how the app is displayed.&lt;/p&gt;

&lt;p&gt;And this is not just a small API change. It represents a bigger shift in how Android expects developers to think about app layouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old assumption: "This app is portrait"
&lt;/h2&gt;

&lt;p&gt;Many Android apps were built around an implicit assumption:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is a phone app, so portrait is the normal orientation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Developers could enforce that assumption through the manifest or at runtime. A typical application might declare a portrait-only activity, or call &lt;code&gt;setRequestedOrientation()&lt;/code&gt; when it needed to lock the screen.&lt;/p&gt;

&lt;p&gt;That worked reasonably well when the majority of Android devices were phones with relatively predictable screen sizes.&lt;/p&gt;

&lt;p&gt;But today's Android ecosystem is considerably messier, in the good sense.&lt;/p&gt;

&lt;p&gt;There are tablets, foldables, desktop-style windows, external displays and devices whose natural orientation may not match what an app developer expects. Google is increasingly treating Android as a platform where an application should be able to adapt to the space it is given rather than dictate the shape of that space.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changes in Android 16?
&lt;/h2&gt;

&lt;p&gt;For apps &lt;strong&gt;targeting Android 16/API 36 or higher&lt;/strong&gt;, Android ignores several mechanisms that developers previously used to restrict large-screen applications.&lt;/p&gt;

&lt;p&gt;That includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;android:screenOrientation&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;android:resizeableActivity&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;android:minAspectRatio&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;android:maxAspectRatio&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setRequestedOrientation()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getRequestedOrientation()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The orientation values developers are accustomed to, such as &lt;code&gt;portrait&lt;/code&gt;, &lt;code&gt;landscape&lt;/code&gt;, &lt;code&gt;sensorPortrait&lt;/code&gt;, and &lt;code&gt;sensorLandscape&lt;/code&gt;, are ignored on those large displays.&lt;/p&gt;

&lt;p&gt;So imagine an app that has always been portrait-only.&lt;/p&gt;

&lt;p&gt;On a phone, it can continue behaving as expected.&lt;/p&gt;

&lt;p&gt;On a sufficiently large tablet, however, targeting API 36 means the system is allowed to display that app in landscape even though the developer requested portrait.&lt;/p&gt;

&lt;p&gt;That can be a rather uncomfortable surprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is Google doing this?
&lt;/h2&gt;

&lt;p&gt;Google's reasoning is fairly straightforward: &lt;strong&gt;large screens are no longer supposed to behave like oversized phones&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A tablet can be used in portrait or landscape. A foldable can change its available space dramatically. Desktop-style windowing can give an app a window that changes size while the application is running.&lt;/p&gt;

&lt;p&gt;From Android's perspective, locking an application to one orientation makes these experiences harder to support.&lt;/p&gt;

&lt;p&gt;Google describes orientation and resizability restrictions as increasingly restrictive in a world of multiple device types and window sizes. The direction Android is taking is therefore toward &lt;strong&gt;adaptive applications&lt;/strong&gt;, where the UI responds to the available space rather than assuming one fixed screen shape.&lt;/p&gt;

&lt;p&gt;In other words, Android is effectively saying:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Don't tell me what shape your screen must be. Tell me how your UI behaves when the shape changes."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is a reasonable philosophy.&lt;/p&gt;

&lt;p&gt;It is also a potentially painful migration for existing applications.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem for developers
&lt;/h2&gt;

&lt;p&gt;The difficult part is not rotation itself.&lt;/p&gt;

&lt;p&gt;The difficult part is discovering how many parts of your application quietly depended on rotation never happening.&lt;/p&gt;

&lt;p&gt;An app may have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fixed-width layouts&lt;/li&gt;
&lt;li&gt;UI elements positioned with hardcoded assumptions&lt;/li&gt;
&lt;li&gt;animations that assume a particular screen ratio&lt;/li&gt;
&lt;li&gt;camera previews tied to a specific orientation&lt;/li&gt;
&lt;li&gt;custom drawing code that expects portrait dimensions&lt;/li&gt;
&lt;li&gt;fragments or activities that were never designed for resizing&lt;/li&gt;
&lt;li&gt;state that is not restored correctly when an activity is recreated&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google specifically warns that applications designed around small-screen layouts and a fixed portrait orientation can experience problems such as stretched layouts, components appearing off-screen, or animations behaving incorrectly when these restrictions are removed. Device rotation can also cause activity recreation, which makes proper state preservation important.&lt;/p&gt;

&lt;p&gt;This is where the change stops being a policy decision and becomes a real engineering problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But my app works perfectly in portrait"
&lt;/h2&gt;

&lt;p&gt;That may be exactly the problem.&lt;/p&gt;

&lt;p&gt;If your application works perfectly because it has &lt;strong&gt;never been exposed to anything else&lt;/strong&gt;, Android 16 can expose assumptions that were previously hidden.&lt;/p&gt;

&lt;p&gt;Take a simple example.&lt;/p&gt;

&lt;p&gt;Suppose a screen was designed around a width of 400dp and a vertical list of controls. In portrait mode, everything looks fine.&lt;/p&gt;

&lt;p&gt;Now put that same screen into a much wider window.&lt;/p&gt;

&lt;p&gt;The controls might spread too far apart. A fixed-width panel may look tiny. A button positioned near the bottom may end up somewhere unexpected. A custom animation may calculate its movement based on dimensions that no longer make sense.&lt;/p&gt;

&lt;p&gt;Nothing is technically "broken" in Android.&lt;/p&gt;

&lt;p&gt;The application simply wasn't designed for the new environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tablets are only part of the story
&lt;/h2&gt;

&lt;p&gt;Although this change is often described as the "tablet orientation lock problem," tablets are really just the most obvious example.&lt;/p&gt;

&lt;p&gt;The Android documentation specifically points to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tablets&lt;/li&gt;
&lt;li&gt;large foldable displays&lt;/li&gt;
&lt;li&gt;desktop windowing&lt;/li&gt;
&lt;li&gt;different window sizes and configurations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The common factor is &lt;strong&gt;available screen space&lt;/strong&gt;, not the marketing category of the device. Android uses the &lt;code&gt;sw600dp&lt;/code&gt; threshold to determine the large-screen behavior.&lt;/p&gt;

&lt;p&gt;This also explains why a developer may see behavior that seems inconsistent between devices.&lt;/p&gt;

&lt;p&gt;One Android device may respect the old behavior because its available screen width falls below the threshold, while another larger device may ignore the orientation request.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is a temporary escape hatch
&lt;/h2&gt;

&lt;p&gt;Android 16 does provide developers with a temporary compatibility option.&lt;/p&gt;

&lt;p&gt;An application or activity can use the &lt;code&gt;PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY&lt;/code&gt; manifest property to retain the older restricted behavior while the app is being migrated.&lt;/p&gt;

&lt;p&gt;That sounds comforting, but there is an important catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is temporary.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google's current documentation says this opt-out will not be available once an application targets &lt;strong&gt;Android 17/API 37&lt;/strong&gt;. At that point, orientation, aspect-ratio and resizability restrictions will always be ignored on displays meeting the large-screen threshold.&lt;/p&gt;

&lt;p&gt;So the compatibility property is better thought of as a migration bridge, not a permanent solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should developers do instead?
&lt;/h2&gt;

&lt;p&gt;The answer is not necessarily "remove every orientation restriction tomorrow."&lt;/p&gt;

&lt;p&gt;The more useful approach is to start identifying what your application actually depends on.&lt;/p&gt;

&lt;p&gt;If an application truly needs a particular layout, make that layout work at different sizes.&lt;/p&gt;

&lt;p&gt;If a camera screen needs special handling, make the camera preview respond correctly to the available orientation and display configuration.&lt;/p&gt;

&lt;p&gt;If a screen contains a large collection of controls, redesign it so those controls can reflow, resize or reposition instead of assuming a fixed portrait canvas.&lt;/p&gt;

&lt;p&gt;Modern Android development increasingly revolves around &lt;strong&gt;responsive and adaptive UI&lt;/strong&gt;. The goal is not simply to support portrait and landscape as two separate versions of a screen. It is to make the interface respond to the amount of space available.&lt;/p&gt;

&lt;p&gt;That distinction is important.&lt;/p&gt;

&lt;h2&gt;
  
  
  This may actually be good for Android users
&lt;/h2&gt;

&lt;p&gt;From a user's perspective, Google's decision makes sense.&lt;/p&gt;

&lt;p&gt;If someone buys a large tablet, they reasonably expect the tablet to behave like a tablet. If they rotate it, they expect applications to make reasonable use of that space.&lt;/p&gt;

&lt;p&gt;Being greeted by a narrow portrait application floating awkwardly inside a huge display is not exactly the premium large-screen experience.&lt;/p&gt;

&lt;p&gt;Android 16's change is intended to reduce that kind of compatibility behavior by allowing apps to use the full available display instead of remaining locked into older aspect-ratio and orientation assumptions.&lt;/p&gt;

&lt;p&gt;The downside is that Android is moving some of the complexity from the platform to the developer.&lt;/p&gt;

&lt;p&gt;Previously, the system could help maintain the developer's preferred shape.&lt;/p&gt;

&lt;p&gt;Now the developer increasingly has to make the application comfortable with whatever shape it receives.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing: API 36 is becoming unavoidable
&lt;/h2&gt;

&lt;p&gt;This change matters even more now because targeting API 36 is no longer a distant concern for Android developers.&lt;/p&gt;

&lt;p&gt;Google's current documentation states that &lt;strong&gt;Google Play requires apps to target API level 36 as of August 2026&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That means this is moving from "something to keep an eye on" to a practical compatibility issue for applications distributed through Google Play.&lt;/p&gt;

&lt;p&gt;And with Android 17/API 37 removing the temporary orientation/resizability opt-out entirely, developers shouldn't build their migration strategy around keeping the old behavior forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bigger change isn't orientation
&lt;/h2&gt;

&lt;p&gt;At first glance, Android 16's large-screen change looks like Google taking away a useful switch.&lt;/p&gt;

&lt;p&gt;But the bigger message is different:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android is moving from device-specific UI to space-specific UI.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of thinking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This is my portrait screen."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Developers increasingly need to think:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This is the space my application has right now. How should my interface behave inside it?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a bigger mental shift than simply changing an AndroidManifest setting.&lt;/p&gt;

&lt;p&gt;For new applications, this philosophy can lead to much better interfaces. For older applications, especially those built around fixed portrait screens, it can mean a painful round of testing and redesign.&lt;/p&gt;

&lt;p&gt;Either way, the old assumption that a large Android screen will politely obey a portrait-orientation lock is becoming a thing of the past.&lt;/p&gt;

&lt;p&gt;And Android 17 makes it clear that Google isn't planning to turn the clock back.&lt;/p&gt;

</description>
      <category>android</category>
      <category>mobile</category>
      <category>programming</category>
      <category>ui</category>
    </item>
    <item>
      <title>Two Ways to Write an Article in 2026 (And Why the Best Ones Use Both)</title>
      <dc:creator>M.Bilal Khan</dc:creator>
      <pubDate>Mon, 17 Aug 2026 12:11:01 +0000</pubDate>
      <link>https://dev.to/mbilalkhan192003/two-ways-to-write-an-article-in-2026-and-why-the-best-ones-use-both-5mb</link>
      <guid>https://dev.to/mbilalkhan192003/two-ways-to-write-an-article-in-2026-and-why-the-best-ones-use-both-5mb</guid>
      <description>&lt;p&gt;It's almost &lt;strong&gt;Midnight&lt;/strong&gt;. Somewhere, a developer opens a blank tab, types a prompt into an AI, and has a polished 1,200-word article in minutes. Somewhere else, another developer opens the same blank tab, stares at it for forty minutes, deletes three sentences, writes one true one, and finally starts typing for real.&lt;/p&gt;

&lt;p&gt;Both of them will hit publish tomorrow. Only one of them will get remembered.&lt;/p&gt;

&lt;p&gt;That's the tension I want to talk about, because I think it's the real question hiding under "how do I write a good article" in 2026: not &lt;em&gt;AI or heart&lt;/em&gt; — but how you let one serve the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Way One: Writing With AI
&lt;/h2&gt;

&lt;p&gt;This is the fast lane, and it's genuinely useful. You bring the idea, the AI helps you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Breakdown a topic&lt;/li&gt;
&lt;li&gt;Draft an outline&lt;/li&gt;
&lt;li&gt;Punch up a clunky sentence&lt;/li&gt;
&lt;li&gt;Catch the boring paragraph you were too tired to notice&lt;/li&gt;
&lt;li&gt;Turn years of scattered learning into one clean explanation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Used well, this isn't cheating. It's what editors have always done for writers — except now everyone has one.&lt;/p&gt;

&lt;p&gt;Used badly, though, it produces a very specific kind of article: correct, competent, and completely forgettable. You've read a hundred of them. Perfect grammar. Zero pulse. The kind of post you skim, nod at, and never think about again. The tell is always the same — it sounds like &lt;em&gt;an&lt;/em&gt; article about the topic, not like &lt;em&gt;your&lt;/em&gt; article about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Way Two: Writing With Heart
&lt;/h2&gt;

&lt;p&gt;This is the slow lane. It's you, the actual failure that taught you the lesson, the 2 AM bug that made you want to quit, the moment something finally clicked and you felt like a wizard for ten whole minutes.&lt;/p&gt;

&lt;p&gt;Heart-writing isn't about being emotional for its own sake. It's about specificity. It's the difference between:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Debugging can be frustrating but rewarding."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I spent three hours debugging our API, convinced the logic was broken. It wasn't. I'd written a nested loop with i and j as counters, and with my eyesight, on a bad-contrast monitor, they were nearly indistinguishable — I kept reading i where I'd written j, and my brain corrected it every single time without telling me. It took a junior dev, glancing over my shoulder for thirty seconds, to spot it. I went home and renamed many variable I owned that night..based on True story😊"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The second one isn't better because it's more honest in some abstract moral sense. It's better because it's &lt;em&gt;true in a way nobody else could have written&lt;/em&gt;. That's the whole game. AI can simulate insight. It cannot simulate the specific, embarrassing, real thing that happened to you.&lt;/p&gt;

&lt;p&gt;The cost of this path is that it's slower, scarier, and occasionally humbling — because writing with heart means admitting you didn't always know what you were doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Answer: They're Not Two Roads. They're Two Hands.
&lt;/h2&gt;

&lt;p&gt;Here's what actually works, and it's the framework underneath most articles you've bookmarked without noticing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Start with the true thing only you have.&lt;/strong&gt;&lt;br&gt;
Not a topic. A moment. The bug, the mistake, the surprising result, the question a junior dev asked you that you couldn't answer cleanly. This is the part AI cannot do for you, because it doesn't have your Tuesday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Let AI hold the structure while you hold the story.&lt;/strong&gt;&lt;br&gt;
Once you know &lt;em&gt;what happened&lt;/em&gt;, structure is a solved problem — and this is where AI genuinely shines. Ask it to help you outline, to suggest where the piece drags, to find the sentence buried on paragraph nine that should actually be your opening line. Let it be your scaffolding, not your voice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Write the ugly first draft yourself.&lt;/strong&gt;&lt;br&gt;
Even three rough paragraphs. Even bad ones. This is non-negotiable, and here's why: your voice lives in your &lt;em&gt;word choices under pressure&lt;/em&gt; — the slightly weird metaphor, the joke that only makes sense if you know you, the rhythm of how you explain things when you're not trying to sound impressive. AI can polish that voice. It can't originate it from nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Use AI as your harshest kind editor, not your ghostwriter.&lt;/strong&gt;&lt;br&gt;
"Where does this get boring?" "What's the weakest paragraph?" "Is my opening line actually earning the reader's next thirty seconds?" These are incredible questions to ask a model. "Write my article for me" is not — because you'll get back competence with the fingerprints sanded off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Read it out loud before you publish.&lt;/strong&gt;&lt;br&gt;
This one has nothing to do with AI. It's the oldest trick there is, and it still catches everything: the sentence that's too long, the paragraph that doesn't mean anything, the ending that fizzles instead of lands. If you'd feel embarrassed saying a sentence to a friend, don't publish it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Actually Matters
&lt;/h2&gt;

&lt;p&gt;Here's the thing nobody tells you about writing on dev.to or Medium in 2026: readers have gotten &lt;em&gt;very&lt;/em&gt; good at smelling the difference between a piece written &lt;em&gt;through&lt;/em&gt; someone and a piece written &lt;em&gt;by&lt;/em&gt; something. Not because AI writing is bad — it isn't — but because readers aren't actually looking for information anymore. They can get information anywhere, instantly, for free. What they're looking for, every time they click on an article instead of asking a chatbot directly, is &lt;em&gt;you&lt;/em&gt;. Your particular scar tissue. Your particular way of seeing a problem. The proof that a real person went through something and came back with a flashlight for the rest of us.&lt;/p&gt;

&lt;p&gt;That's not a nostalgic argument for doing things the hard way. It's a practical one: the articles that get saved, shared, and remembered are the ones where a real experience got a little help getting dressed for the world — not the ones where an experience was replaced entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, Start Tonight
&lt;/h2&gt;

&lt;p&gt;Don't open with a prompt. Open with a memory — the bug, the launch that went sideways, the comment on your PR that stung more than it should have, the tiny win nobody else would understand the weight of.&lt;/p&gt;

&lt;p&gt;Write that part yourself, badly, honestly.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Then&lt;/em&gt; bring in the help.&lt;/p&gt;

&lt;p&gt;That's not two ways to write an article in 2026. That's one way, done right — with both hands.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>writing</category>
      <category>productivity</category>
      <category>contentwriting</category>
    </item>
    <item>
      <title>I built a Chrome extension, got 100 installs, and 90% of users left. Here's what I'm fixing.</title>
      <dc:creator>M.Bilal Khan</dc:creator>
      <pubDate>Tue, 11 Aug 2026 10:42:27 +0000</pubDate>
      <link>https://dev.to/mbilalkhan192003/i-built-a-chrome-extension-got-100-installs-and-90-of-users-left-heres-what-im-fixing-3gmi</link>
      <guid>https://dev.to/mbilalkhan192003/i-built-a-chrome-extension-got-100-installs-and-90-of-users-left-heres-what-im-fixing-3gmi</guid>
      <description>&lt;p&gt;Somewhere around install #80, I stopped checking my Chrome Web Store dashboard every day. Not because I'd lost interest — because the number that mattered wasn't installs anymore, it was this one: 10 active users.&lt;/p&gt;

&lt;p&gt;Out of 101 people who installed &lt;a href="https://chromewebstore.google.com/detail/ihclgidibhioanhjchbhcblpfhaipiok?utm_source=dev.to"&gt;Goat Alert&lt;/a&gt;, roughly 20 uninstalled it outright, and most of the rest just... quietly stopped using it. No uninstall, no feedback, no bug report. They just went dark.&lt;/p&gt;

&lt;p&gt;That's a rough number to sit with when you built the thing yourself. But it's also the most useful number I have, because it's telling me something installs never could: the idea works well enough to get a "yes," and not well enough to get a "keep."&lt;/p&gt;

&lt;p&gt;Here's what I think is actually happening.&lt;/p&gt;

&lt;p&gt;Goat Alert does one thing: it notifies you at a random interval, and if you ignore it for 60 seconds, a goat sound plays. That's funny for the first three, maybe five times. Then it becomes exactly what any repetitive notification becomes — noise you've learned to dismiss on reflex, or annoying enough that you just turn the extension off.&lt;/p&gt;

&lt;p&gt;I built a joke. I didn't build a habit. Those are different problems, and I only fully understood that after watching my own retention numbers.&lt;/p&gt;

&lt;p&gt;What I'm changing:&lt;/p&gt;

&lt;p&gt;Snooze, not just dismiss. Right now the only options are "deal with the notification" or "ignore it and get screamed at." There's no middle ground for "not now, but check on me in 5." Adding a snooze button on the notification itself — no popup, no extra click, just an action button.&lt;/p&gt;

&lt;p&gt;Rotating messages. Same text every time is a big part of why it becomes background noise so fast. Swapping in a small rotating set of messages so it doesn't read as identical each time.&lt;/p&gt;

&lt;p&gt;Streaks and a stats page. This is the one I think matters most. Right now there's zero feedback loop — you either get annoyed or you don't, and nothing sticks around to show you it's working. Adding a simple streak counter and a stats page (times dismissed, times the goat actually screamed, days active) gives people a reason to open the popup that isn't just "turn this off."&lt;/p&gt;

&lt;p&gt;A gentler alternative sound. Not everyone wants a goat scream as their daily interruption. Adding a soft chime mode as an option, so the "annoying by design" mechanic still works for people who want persistence without chaos.&lt;/p&gt;

&lt;p&gt;None of this requires new permissions, so the update ships silently to everyone already using it — no re-approval prompt, no disruption.&lt;/p&gt;

&lt;p&gt;The honest part&lt;/p&gt;

&lt;p&gt;I don't know yet if any of this actually fixes retention. It's a reasonable theory based on watching where people seem to drop off, not a guarantee. The only way I'll actually know is by shipping it and watching the same numbers again in a few weeks.&lt;/p&gt;

&lt;p&gt;If you've built something small that people install and then abandon, I'd genuinely like to hear what you found — whether it was a UX thing, a "this solved a one-time problem" thing, or something else entirely. Retention on tiny tools feels like an underdiscussed problem compared to how much airtime "getting the first users" gets.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>sideprojects</category>
      <category>javascript</category>
      <category>extensions</category>
    </item>
    <item>
      <title>How MV3 Service Workers Made Me Use an Offscreen Document Just to Play a Goat Sound</title>
      <dc:creator>M.Bilal Khan</dc:creator>
      <pubDate>Tue, 21 Jul 2026 06:59:27 +0000</pubDate>
      <link>https://dev.to/mbilalkhan192003/how-mv3-service-workers-made-me-use-an-offscreen-document-just-to-play-a-goat-sound-dd3</link>
      <guid>https://dev.to/mbilalkhan192003/how-mv3-service-workers-made-me-use-an-offscreen-document-just-to-play-a-goat-sound-dd3</guid>
      <description>&lt;p&gt;So I built a Chrome extension that does one very stupid thing: it waits a random amount of time, then screams at you like a goat if you don't dismiss the notification fast enough.&lt;/p&gt;

&lt;p&gt;Simple idea. Should've been a simple build. It was not, because Manifest V3 said no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plan was easy, in my head&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what I thought I needed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;chrome.alarms&lt;/code&gt; to fire at a random interval&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chrome.notifications&lt;/code&gt; to show a "you good?" popup&lt;/li&gt;
&lt;li&gt;If the user ignores it for 60 seconds, play a goat sound&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is where things fell apart. Because in Manifest V3, your background script isn't a persistent background page anymore — it's a service worker. And service workers have the audio capabilities of a rock. No &lt;code&gt;Audio()&lt;/code&gt; object. No Web Audio API. Nothing. You can't just do &lt;code&gt;new Audio('goat.mp3').play()&lt;/code&gt; and call it a day, because there's no DOM for it to live in.&lt;/p&gt;

&lt;p&gt;I found this out the way everyone finds out things in MV3: by writing the obvious code, watching it silently fail, and then spending 20 minutes wondering if I'd misspelled "goat."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enter: the offscreen document&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Chrome's answer to "service workers can't do X" for a bunch of X's (audio playback included) is the &lt;strong&gt;offscreen document API&lt;/strong&gt;. It's exactly what it sounds like — a hidden HTML page that exists purely so your extension has something with a DOM, so it can do DOM things. In my case, playing an mp3 of a goat losing its mind.&lt;/p&gt;

&lt;p&gt;The flow ends up looking like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service worker (background.js)
  → creates an offscreen document if one doesn't exist
  → sends it a message: "play the sound"
offscreen.html/offscreen.js
  → has an &amp;lt;audio&amp;gt; tag
  → actually plays the sound
  → closes itself when done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a little bit like hiring a stunt double because the main actor (your service worker) isn't allowed near water. The offscreen document does the wet work, then goes home.&lt;/p&gt;

&lt;p&gt;Rough version of what that looks like in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// background.js&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;playGoatSound&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasDocument&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offscreen.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AUDIO_PLAYBACK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;justification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Playing a goat scream sound effect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playSound&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// offscreen.js&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playSound&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sounds/goat.mp3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing exotic. But it took me embarrassingly long to figure out that "reasons: ['AUDIO_PLAYBACK']" is a required field and not optional flavor text, and that you genuinely cannot skip the offscreen document if you want sound out of a service worker. There's no secret shortcut.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other MV3 gotcha: your background script forgets everything&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Service workers also don't persist. Chrome can (and will) kill your background script whenever it feels like, which is a problem when your whole app is "wait N minutes, then do a thing." If your script gets shut down mid-wait, a regular &lt;code&gt;setTimeout&lt;/code&gt; just... doesn't fire. It's gone. Chrome ate it.&lt;/p&gt;

&lt;p&gt;This is what &lt;code&gt;chrome.alarms&lt;/code&gt; is actually for — it's not just a nice API, it's the only way to reliably schedule something in the future when your background context can disappear at any moment. So Goat Alert ends up using two alarms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One for "time to show the notification" (the random 5/10/15/20 min interval)&lt;/li&gt;
&lt;li&gt;One for "15 seconds have passed, did they dismiss it? No? Ok, scream."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both survive the service worker dying and waking back up, because &lt;code&gt;chrome.alarms&lt;/code&gt; is backed by the browser itself, not by whatever fragile JS context happened to be running when you set the timer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd tell past me&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Building this taught me that a huge chunk of "why is my simple Chrome extension so complicated" comes down to one thing: Manifest V3 assumes your background script is disposable, and designs everything around surviving that. Once you accept that and stop trying to keep long-lived state or objects (like an Audio instance) alive in the service worker, the actual APIs make sense. Offscreen documents for anything DOM-shaped, alarms for anything time-shaped.&lt;/p&gt;

&lt;p&gt;Anyway. 100+ installs, people are enjoying it, so all efforts are worth it.&lt;/p&gt;

&lt;p&gt;Give it a try, will put a smile on your face &lt;a href="https://chromewebstore.google.com/detail/ihclgidibhioanhjchbhcblpfhaipiok?utm_source=dev.to"&gt;Goat Alert&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>showdev</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I spent a week trying to intercept Slack push notifications from a Chrome extension. Here's why it's impossible.</title>
      <dc:creator>M.Bilal Khan</dc:creator>
      <pubDate>Wed, 15 Jul 2026 18:55:52 +0000</pubDate>
      <link>https://dev.to/mbilalkhan192003/i-spent-a-week-trying-to-intercept-slack-push-notifications-from-a-chrome-extension-heres-why-9mc</link>
      <guid>https://dev.to/mbilalkhan192003/i-spent-a-week-trying-to-intercept-slack-push-notifications-from-a-chrome-extension-heres-why-9mc</guid>
      <description>&lt;p&gt;After I published my last article about building a Chrome extension that speaks browser notifications aloud, a commenter asked a question I didn't have a good answer to.&lt;/p&gt;

&lt;p&gt;He pointed out that a lot of web apps — Slack, Gmail, most modern tools — fire their notifications from a service worker via &lt;code&gt;registration.showNotification()&lt;/code&gt;, not from the page's JavaScript context. My MAIN world override of &lt;code&gt;window.Notification&lt;/code&gt; would never reach those.&lt;/p&gt;

&lt;p&gt;He was right. And I told him I'd look into it.&lt;/p&gt;

&lt;p&gt;I spent a week researching whether there was any way to close that gap. There isn't. But the reason why is more interesting than a simple "no."&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Two ways a website can show you a notification&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a website sends you a browser notification, it can do it in one of two ways.&lt;/p&gt;

&lt;p&gt;The first is the constructor path. The page's own JavaScript calls &lt;code&gt;new Notification("You have a message")&lt;/code&gt; directly. This is common for in-tab alerts, real-time updates when you're actively on the site, or any notification triggered by something you just did.&lt;/p&gt;

&lt;p&gt;The second is the push path. The browser receives a push event from the website's server, wakes up the website's service worker in the background, and the service worker calls &lt;code&gt;self.registration.showNotification()&lt;/code&gt; from inside its own scope. This is what happens when Slack notifies you of a new message while the tab is closed or backgrounded. The page never runs. No page JavaScript ever fires.&lt;/p&gt;

&lt;p&gt;My extension catches the first path. The MAIN world content script overrides &lt;code&gt;window.Notification&lt;/code&gt; before any page code runs. But the service worker never touches the page's window. It has no &lt;code&gt;window&lt;/code&gt;. It runs in a completely isolated thread, completely separate from the page, and calls &lt;code&gt;showNotification&lt;/code&gt; on itself.&lt;/p&gt;

&lt;p&gt;The override is never reached.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Why can't the extension reach the service worker?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the part that took me a week to fully accept.&lt;/p&gt;

&lt;p&gt;Chrome extensions can inject content scripts into web pages. They can run code in the MAIN world or the ISOLATED world of a page. They can observe network requests, intercept navigations, and modify headers.&lt;/p&gt;

&lt;p&gt;What they cannot do is inject code into a third-party website's service worker.&lt;/p&gt;

&lt;p&gt;Service workers operate in a separate thread with no DOM, no window, and no connection to the page's JavaScript context. The Chrome Extensions API simply does not offer a mechanism to run extension code inside an arbitrary third-party service worker scope. It's a hard security boundary — and for good reasons. Allowing extensions to arbitrarily patch service workers for any website on the internet would be a significant attack surface.&lt;/p&gt;

&lt;p&gt;So: the extension lives on the page side. The push notification lives on the service worker side. There is a wall between them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Three approaches I investigated&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn't accept "impossible" without looking for workarounds. Here's what I found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach 1: Override ServiceWorkerRegistration.prototype.showNotification in the MAIN world&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A page's JavaScript can call &lt;code&gt;navigator.serviceWorker.ready.then(reg =&amp;gt; reg.showNotification(...))&lt;/code&gt;. If you override &lt;code&gt;ServiceWorkerRegistration.prototype.showNotification&lt;/code&gt; in the MAIN world, you'd intercept that pattern.&lt;/p&gt;

&lt;p&gt;The problem: Slack's push notifications don't come from page JavaScript. They come from inside the service worker script itself, where it calls &lt;code&gt;self.registration.showNotification()&lt;/code&gt;. The service worker runs in its own scope. The MAIN world prototype override doesn't reach there.&lt;/p&gt;

&lt;p&gt;This approach catches one narrow pattern and misses the actual push notification path entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach 2: chrome.debugger API with CDP&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the most discussed workaround in the Chromium Extensions community. The &lt;code&gt;chrome.debugger&lt;/code&gt; API gives an extension access to the Chrome DevTools Protocol. With CDP's &lt;code&gt;Fetch.fulfillRequest&lt;/code&gt; command, you can intercept a network response — including a service worker JavaScript file — and modify it before the browser processes it. Inject your code into the SW script, and it runs inside the service worker scope.&lt;/p&gt;

&lt;p&gt;This technically works. It is completely unusable for a consumer extension.&lt;/p&gt;

&lt;p&gt;When you attach the debugger to any tab, Chrome displays a persistent banner at the top of the browser: "Chrome is being controlled by automated test software." This banner appears on every tab. It cannot be dismissed. It cannot be hidden by the extension. The only way to suppress it is to launch Chrome with &lt;code&gt;--silent-debugger-extension-api&lt;/code&gt;, which is not something a real user would do.&lt;/p&gt;

&lt;p&gt;For a Chrome Web Store extension that people install on their own machine, this approach would immediately look like malware. Ruled out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach 3: declarativeNetRequest&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Manifest V3's &lt;code&gt;declarativeNetRequest&lt;/code&gt; API lets extensions block or redirect network requests. Could you redirect the Slack service worker script to a modified version?&lt;/p&gt;

&lt;p&gt;No. &lt;code&gt;declarativeNetRequest&lt;/code&gt; can block or redirect a request, but it cannot modify the response body. And even if it could redirect the service worker script URL, the replacement would need to be served from the same origin as the site — extensions can't serve content from &lt;code&gt;slack.com&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What Firefox can do that Chrome can't&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's where it gets interesting.&lt;/p&gt;

&lt;p&gt;Firefox has an API called &lt;code&gt;webRequest.filterResponseData()&lt;/code&gt;. It lets an extension intercept a network response and modify its body before the browser processes it. Applied to a service worker JavaScript file, you could inject code that runs inside the SW scope. That code could override &lt;code&gt;self.registration.showNotification&lt;/code&gt; and intercept push notifications.&lt;/p&gt;

&lt;p&gt;This capability exists in Firefox. It was advocated for by Giorgio Maone, the developer of NoScript, who needed exactly this kind of deep injection for a privacy tool.&lt;/p&gt;

&lt;p&gt;Chrome removed blocking &lt;code&gt;webRequest&lt;/code&gt; in MV3 and never added a &lt;code&gt;filterResponseData&lt;/code&gt; equivalent. This is one of the more significant functional divergences between the two extension models post-MV3. Extension developers have raised it on the Chromium mailing lists. For now, it's a genuine gap.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What this means for &lt;a href="https://chromewebstore.google.com/detail/hnaggblalhlbihfaegbknioadncpcged?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=article2-sw" rel="noopener noreferrer"&gt;Serious Notification&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The extension works well for the constructor path — notifications fired from page JavaScript when a tab is active. For many apps and many use cases, that's most of their notification volume.&lt;/p&gt;

&lt;p&gt;For pure push-via-service-worker notifications from backgrounded tabs — like Slack when you've minimised the window — the extension will miss them. I've added a Known Limitations section to the original article to reflect this honestly.&lt;/p&gt;

&lt;p&gt;What would need to change for this to be solvable on Chrome? Some form of extension access to service worker scope — whether through a filtered response body API like Firefox's, or through a purpose-built notification interception hook in the Chrome Extensions API. Neither exists today.&lt;/p&gt;

&lt;p&gt;If you want to see this change, the Chromium issue tracker is the place to make that case. I'm watching it.&lt;/p&gt;

&lt;p&gt;In the meantime, if you want to try what's possible now:&lt;/p&gt;

&lt;p&gt;🔗 &lt;a href="https://chromewebstore.google.com/detail/hnaggblalhlbihfaegbknioadncpcged?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=article2-sw" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/hnaggblalhlbihfaegbknioadncpcged?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=article2-sw&lt;/a&gt;&lt;/p&gt;

</description>
      <category>extensions</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>How I built a Chrome extension that speaks your notifications aloud — and the two Manifest V3 problems nobody warned me about</title>
      <dc:creator>M.Bilal Khan</dc:creator>
      <pubDate>Tue, 07 Jul 2026 19:40:25 +0000</pubDate>
      <link>https://dev.to/mbilalkhan192003/how-i-built-a-chrome-extension-that-speaks-your-notifications-aloud-and-the-two-manifest-v3-3239</link>
      <guid>https://dev.to/mbilalkhan192003/how-i-built-a-chrome-extension-that-speaks-your-notifications-aloud-and-the-two-manifest-v3-3239</guid>
      <description>&lt;p&gt;I counted how many times I glanced at a notification during a coding session last week.&lt;/p&gt;

&lt;p&gt;Thirty-four times. Only four needed my attention.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://chromewebstore.google.com/detail/hnaggblalhlbihfaegbknioadncpcged?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=technical-article" rel="noopener noreferrer"&gt;Serious Notification&lt;/a&gt; — 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Problem 1: You can't intercept the Notification API in a regular content script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;Chrome's &lt;code&gt;Notification&lt;/code&gt; constructor lives on &lt;code&gt;window&lt;/code&gt;. When a website calls &lt;code&gt;new Notification("You have a message")&lt;/code&gt;, it's using &lt;code&gt;window.Notification&lt;/code&gt; in the page's own JavaScript context.&lt;/p&gt;

&lt;p&gt;A standard Manifest V3 content script runs in what Chrome calls an &lt;strong&gt;ISOLATED world&lt;/strong&gt; — 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 &lt;code&gt;window.Notification&lt;/code&gt; on the actual page. By the time your script runs, the page's Notification constructor is completely separate from anything you can reach.&lt;/p&gt;

&lt;p&gt;You can listen for a notification after it fires. You cannot intercept it before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: MAIN world content scripts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Manifest V3 added support for content scripts that run in the MAIN world. Set &lt;code&gt;"world": "MAIN"&lt;/code&gt; in your manifest and your script runs directly in the page's JavaScript context — before any page code executes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content_scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"matches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;all_urls&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"js"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"interceptor.js"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"world"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MAIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"run_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document_start"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can override &lt;code&gt;window.Notification&lt;/code&gt; before the page has a chance to use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OriginalNotification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Notification&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__sn_notification__&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OriginalNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The catch: MAIN world scripts have no Chrome extension APIs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is the problem with MAIN world scripts. They run in the page context, which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No &lt;code&gt;chrome.storage&lt;/code&gt; — you cannot read the user's saved keywords&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;speechSynthesis&lt;/code&gt; — you cannot speak the notification aloud&lt;/li&gt;
&lt;li&gt;No extension APIs at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You are in the page's world. Chrome's extension APIs are not available there.&lt;/p&gt;

&lt;p&gt;So you need two scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bridge&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Script 1 (&lt;code&gt;interceptor.js&lt;/code&gt;) runs in the MAIN world. It overrides &lt;code&gt;window.Notification&lt;/code&gt; and fires a custom DOM event when a notification arrives.&lt;/p&gt;

&lt;p&gt;Script 2 (&lt;code&gt;content.js&lt;/code&gt;) runs in the ISOLATED world. It has full access to &lt;code&gt;chrome.storage&lt;/code&gt; and &lt;code&gt;speechSynthesis&lt;/code&gt;. It listens for the custom event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__sn_notification__&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keywords&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;speed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keywords&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kw&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;utterance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SpeechSynthesisUtterance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;utterance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;speechSynthesis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;speak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;utterance&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two scripts. One custom DOM event as the bridge. The MAIN world intercepts, the ISOLATED world decides.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Problem 2: Chrome showed a "read and change all your data on all websites" warning at install&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The extension needs to inject on every page — that's legitimate, since you don't know which tab will fire a notification. So &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; host access is genuinely required.&lt;/p&gt;

&lt;p&gt;But I was declaring it as a required permission in &lt;code&gt;manifest.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"content_scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"matches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;all_urls&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: optional host permissions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Manifest V3 supports &lt;code&gt;optional_host_permissions&lt;/code&gt;. Move &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; there instead of requiring it at install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scripting"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"optional_host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;all_urls&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then register content scripts dynamically at runtime using &lt;code&gt;chrome.scripting.registerContentScripts()&lt;/code&gt;, only after the user explicitly grants the permission:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// background.js&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onAdded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origins&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerContentScripts&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;interceptor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;interceptor.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;world&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MAIN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;runAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document_start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;runAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document_start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the popup, show a "Turn on to hear alerts" button that triggers the permission request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;origins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result: the Chrome Web Store install dialog now shows &lt;strong&gt;Storage only&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One edge case: updating from a version that required the permission&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix: fire a native notification immediately after update using the &lt;code&gt;notifications&lt;/code&gt; permission:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onInstalled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update-notice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;basic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;iconUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;icon.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Serious Notification — One quick step needed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;We improved how permissions work. Open the extension and click "Turn on" to re-enable.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;requireInteraction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Silent failure is the worst outcome for retention. If something breaks on update, tell the user immediately and tell them exactly what to do.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What I built&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 (&lt;code&gt;permission&lt;/code&gt;, &lt;code&gt;requestPermission&lt;/code&gt;) that took me a while to get right.&lt;/p&gt;

&lt;p&gt;🔗&lt;a href="https://chromewebstore.google.com/detail/hnaggblalhlbihfaegbknioadncpcged?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=technical-article" rel="noopener noreferrer"&gt;Serious Notification - Live Chrome WebStore&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Known Limitations
&lt;/h2&gt;

&lt;p&gt;This approach only intercepts notifications fired via the Notification constructor in the page's JavaScript context.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>extensions</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
