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.
Simple idea. Should've been a simple build. It was not, because Manifest V3 said no.
The plan was easy, in my head
Here's what I thought I needed:
-
chrome.alarmsto fire at a random interval -
chrome.notificationsto show a "you good?" popup - If the user ignores it for 60 seconds, play a goat sound
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 Audio() object. No Web Audio API. Nothing. You can't just do new Audio('goat.mp3').play() and call it a day, because there's no DOM for it to live in.
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."
Enter: the offscreen document
Chrome's answer to "service workers can't do X" for a bunch of X's (audio playback included) is the offscreen document API. 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.
The flow ends up looking like this:
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 <audio> tag
→ actually plays the sound
→ closes itself when done
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.
Rough version of what that looks like in code:
// background.js
async function playGoatSound() {
if (!(await chrome.offscreen.hasDocument())) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['AUDIO_PLAYBACK'],
justification: 'Playing a goat scream sound effect'
});
}
chrome.runtime.sendMessage({ action: 'playSound' });
}
// offscreen.js
chrome.runtime.onMessage.addListener((msg) => {
if (msg.action === 'playSound') {
const audio = new Audio('sounds/goat.mp3');
audio.play();
}
});
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.
The other MV3 gotcha: your background script forgets everything
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 setTimeout just... doesn't fire. It's gone. Chrome ate it.
This is what chrome.alarms 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:
- One for "time to show the notification" (the random 5/10/15/20 min interval)
- One for "15 seconds have passed, did they dismiss it? No? Ok, scream."
Both survive the service worker dying and waking back up, because chrome.alarms is backed by the browser itself, not by whatever fragile JS context happened to be running when you set the timer.
What I'd tell past me
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.
Anyway. 100+ installs, people are enjoying it, so all efforts are worth it.
Give it a try, will put a smile on your face Goat Alert
Top comments (0)