A countdown that shows the right number is only half a timer. The other half is the moment it ends: a sound, a system notification, or at least a screen that's still on when you glance over. All three of those are gated by browser rules, and every one of those rules is easier to satisfy at the moment the user presses Start than at the moment the timer runs out.
This post covers what the docs and specs say about each API, and one pattern that ties them together.
Rule 1: Notification permission belongs inside a click
The Notifications API has three permission states: default (not asked yet), granted, and denied. MDN notes that when the result is default, the app should act as if permission was denied.
The WHATWG Notifications standard itself doesn't spell out a gesture requirement. The browsers do. MDN's guide says you should only request permission in response to a user gesture such as a button click, and that Firefox (since version 72) and Safari already disallow requests that aren't triggered that way. It also notes that Chrome and Firefox only allow notification requests from a secure context (HTTPS), and not from cross-origin iframes.
Two consequences:
- Don't call
Notification.requestPermission()on page load. Call it from the handler of the button that starts the timer, and only ifNotification.permission === 'default'. - Don't rely on
new Notification()on phones. MDN warns that the constructor works on desktop but throws aTypeErroron most mobile browsers. For mobile, register a service worker and useServiceWorkerRegistration.showNotification().
Rule 2: Sound needs a gesture, too
Browsers treat script-started playback as autoplay. MDN's autoplay guide is explicit that both <audio autoplay> and a plain audioElement.play() outside a user-input handler fall under the autoplay blocking policy, and so does starting a Web Audio source node. As a general rule, audible media is allowed only if the user has interacted with the site, the site has been allowlisted, or (for iframes) the autoplay Permissions Policy grants it. Muted media isn't blocked, which doesn't help a timer much.
When playback is blocked, HTMLMediaElement.play() returns a promise that rejects with a NotAllowedError DOMException. Chrome's autoplay article adds the Web Audio side: an AudioContext created before the document receives a user gesture starts in the "suspended" state, and you need to call resume() after the gesture. Checking audioCtx.state tells you whether you're running or still suspended.
So the place to "unlock" audio is, again, the Start button. Create or resume the AudioContext there, before anything else that might make the user wait.
Rule 3: Wake Lock is a request, not a guarantee
Screen Wake Lock asks the device not to dim or lock the display while your page needs it. A few details from the W3C spec are worth knowing before you build on it:
-
navigator.wakeLockis marked[SecureContext], so it's HTTPS-only. -
request('screen')rejects withNotAllowedErrorif the document isn't fully active, if a permissions policy blocksscreen-wake-lock, if the user agent denies it, or if the page's visibility state is"hidden". - When the page becomes hidden, the spec's visibility-change steps release every active screen lock.
- The spec also lets the user agent or OS limit or refuse wake locks based on battery status or power-saving mode. MDN describes the same thing from the developer side: a request can be rejected because of power save mode or low battery.
Once released, a WakeLockSentinel can't be reused. If you still need the screen on, you request a new one, which MDN demonstrates with a visibilitychange listener.
One Start handler that prepares all three
Here's one way to wire it up. showStatus(), timerIsRunning() and startCountdown() stand in for your own UI and timer code.
let audioCtx = null;
let wakeLock = null;
async function prepareAlerts() {
// Audio first, before any await: create or resume the context
// while this click still counts as the user's gesture.
audioCtx ??= new AudioContext();
const audioReady =
audioCtx.state === 'suspended' ? audioCtx.resume() : Promise.resolve();
// Ask for notification permission only if the user hasn't decided yet.
const permissionReady =
'Notification' in window && Notification.permission === 'default'
? Notification.requestPermission()
: Promise.resolve();
await Promise.all([audioReady, permissionReady, requestWakeLock()]);
}
document.querySelector('#start').addEventListener('click', async () => {
await prepareAlerts();
startCountdown(onTimerDone);
});
The audio step runs first on purpose. A permission prompt can sit on screen for a while, and I'd rather not depend on the click still counting as a fresh gesture by the time the user answers it.
Keeping the screen on, and getting it back
async function requestWakeLock() {
if (!('wakeLock' in navigator)) return false;
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
wakeLock = null;
showStatus('Screen may sleep now');
});
showStatus('Screen will stay on');
return true;
} catch (err) {
// NotAllowedError: hidden page, power saver, blocked by policy...
showStatus(`Screen may sleep (${err.name})`);
return false;
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && timerIsRunning() && wakeLock === null) {
requestWakeLock();
}
});
Show the lock state in the UI. If the request fails, the user should know the screen might turn off before it does.
When the timer ends
function beep(durationSec = 0.4) {
if (!audioCtx || audioCtx.state !== 'running') return false;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.frequency.value = 880;
gain.gain.value = 0.2;
osc.connect(gain).connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + durationSec);
return true;
}
async function showDoneNotification() {
if (!('Notification' in window) || Notification.permission !== 'granted') return false;
const body = 'Time is up.';
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.getRegistration();
if (reg) {
await reg.showNotification('Timer finished', { body, tag: 'timer-done' });
return true;
}
}
try {
new Notification('Timer finished', { body, tag: 'timer-done' });
return true;
} catch {
return false; // e.g. TypeError on mobile without a service worker
}
}
async function onTimerDone() {
const rang = beep();
const notified = await showDoneNotification();
if (!rang && !notified) {
document.title = '⏰ Time is up';
}
}
Each channel reports whether it worked, and the tab title is the last fallback. Using the same tag means a second "done" replaces the first instead of stacking up.
A note on testing: I checked these snippets for syntax with Node, but they use browser-only APIs, so the behavior described here comes from MDN, the specs, and Chrome's docs, not from a Node run. Try them on an HTTPS page (or localhost) in the browsers your users have.
Checklist
- [ ] Permission is requested from the Start click, never on load, and only when the state is
default. - [ ]
defaultis treated the same asdenied. - [ ] Mobile uses
registration.showNotification(), notnew Notification(). - [ ] The
AudioContextis created or resumed inside the click handler, andstateis checked before beeping. - [ ]
play()rejections (NotAllowedError) are caught, not ignored. - [ ] The wake lock is re-requested on
visibilitychange, and its state is visible in the UI. - [ ] There's a fallback (tab title, on-screen banner) when every other channel is off.
None of this turns a web page into a guaranteed alarm. Users can deny permission, mute the tab, or turn on power saving, and the browser is right to let them. The goal is to ask at the right moment and to be honest in the UI about which channels are live.
Sources
- MDN, Using the Notifications API
- MDN, Notification.requestPermission()
- MDN, ServiceWorkerRegistration.showNotification()
- WHATWG, Notifications API Standard
- MDN, Autoplay guide for media and Web Audio APIs
- MDN, HTMLMediaElement.play()
- Chrome for Developers, Autoplay policy in Chrome
- MDN, Screen Wake Lock API
- W3C, Screen Wake Lock API
Side note: I made moment., a free timer that runs in the browser.
Top comments (0)