Notification API: Reminders That Survive a Tab Switch (2026)
The Notification API lets a timer reach you after you switch tabs. Here is the permission flow, the options that matter, and the failures nobody warns you of.
Notification API: Reminders That Survive a Tab Switch
You start a 25-minute timer, switch to your editor, and forget the browser exists. The countdown finishes perfectly — in a tab you cannot see. The Notification API is the piece that closes that loop: a system-level alert that fires outside the page. It is a small API with a short spec and a surprising number of ways to fail silently.
TL;DR
- Request permission inside a click handler, never on page load.
Notification.permissionhas three values:default,granted,denied.- The
tagoption replaces a stale reminder instead of stacking duplicates. - Non-persistent notifications auto-close in seconds unless
requireInteractionis true. new Notification()throws on most mobile browsers — use a service worker there.
How to send a browser notification when a tab is in the background
A background tab is still a running tab. Its JavaScript is throttled, not suspended, so a scheduled callback will still fire — late, but it fires. The notification itself does not care about visibility at all. What it cares about is permission.
Step 1: check that the browser supports notifications
Feature detection here is one line, and skipping it means an uncaught ReferenceError in older or stripped-down browsers.
const canNotify = "Notification" in window;
if (!canNotify) {
showInPageBanner("Timer finished");
}
Note that the Notification interface is marked as limited availability on MDN — it is not Baseline, because the constructor form is missing on major mobile browsers. Treat it as an enhancement, not a dependency.
Why Notification.requestPermission must be called from a click
Firefox has required a user gesture since version 72, and Safari has required one for longer. Calling requestPermission() during page load gets the request dropped on the floor in those browsers, and Chrome's Lighthouse audits it as a best-practice failure.
btn.addEventListener("click", async () => {
const result = await Notification.requestPermission();
btn.hidden = result === "granted";
});
The promise-based form is the one to use. The old callback form still works but is deprecated, and MDN notes there is no reliable way to feature-test which form a browser supports — so pick the promise and move on. Both Chrome and Firefox also require a secure context, and neither allows the request from a cross-origin <iframe>.
The three permission states, and what "default" really means
Notification.permission is a static read-only string. The naming trips people up because default sounds neutral and is not.
| State | What it means | What you should do |
|---|---|---|
default |
Never asked, or dismissed | Show your own "enable" button |
granted |
User said yes | Construct notifications freely |
denied |
User said no, or auto-blocked | Fall back; do not re-prompt |
The spec is explicit about the mapping: per the Notifications API Standard, getting the notifications permission state returns default whenever the underlying Permissions state is prompt. So default means "the browser will act as if this were denied until you ask". A denied origin cannot be un-denied from JavaScript — requestPermission() resolves instantly with denied and no dialog appears. Only the user can reverse it from site settings.
Why my browser notification does not show up
This is the part that eats an afternoon. The code looks right, no exception is thrown, and nothing appears on screen. There are four distinct causes and they need different fixes.
The Notification constructor throws a TypeError on mobile
On most mobile browsers, new Notification("Timer done") throws a TypeError outright. MDN's guidance is to register a service worker and call ServiceWorkerRegistration.showNotification() instead — that path creates a persistent notification, which the spec says user agents should keep in the platform notification center until it is removed.
function notify(title, opts) {
try {
return new Notification(title, opts);
} catch {
return navigator.serviceWorker?.ready
.then((reg) => reg.showNotification(title, opts));
}
}
That distinction matters beyond mobile. Non-persistent notifications — the constructor kind — are the ones the spec says should not be shown in a platform notification center. If your reminder disappears from macOS Notification Center or the Android shade seconds after firing, that is conforming behaviour, not a bug.
The notification fired but you never saw it
Operating-system Do Not Disturb, macOS Focus modes, and Windows focus assist all swallow notifications without telling the page. There is no API to detect this. The show event still fires, because from the browser's perspective it did show the notification.
Design around it. Anything time-critical should have a second channel in the page itself — an updated document.title, a colour change, and an audible cue. Our own countdown timer pairs the notification with a generated beep for exactly this reason, since a sound gets through some Focus configurations that suppress banners.
Chrome's quieter prompt hides your permission request
Chrome enrolls origins with very low accept rates into a quieter permission UI, and since Chrome 98 a permission request generally appears as a small animated chip beside the address-bar lock rather than a modal dialog. If your users say "it never asked me", they may simply not have noticed the chip.
The fix is not technical. Ask at the moment the permission is obviously useful — when someone starts a 25-minute timer, not when the page loads — so the accept rate stays high and the loud prompt stays available to you.
Nothing is scheduled because the tab was throttled
A hidden tab gets its timers checked far less often, so a setTimeout due at minute 25 can fire at minute 26. The notification is correct; the schedule is not. That is a timer-architecture problem rather than a Notification API problem, and the fix is to compute from an absolute deadline instead of counting ticks.
Notification options that actually matter for a reminder
The options bag has a lot of fields. For a reminder, four of them do real work.
tag and renotify: replacing a stale reminder instead of stacking
tag is an ID. If a notification with the same tag has not been displayed yet, the new one replaces it; if it has already been displayed, the old one is closed and the new one shown. Ten notifications fired 200 ms apart with the same tag produce exactly one visible alert — the last.
new Notification("Break over", {
tag: "ikit-timer",
renotify: true,
body: "Back to the 25-minute block.",
});
renotify controls whether the replacement re-alerts the user (sound, vibration) or slips in quietly. Without it, a replacement can arrive completely unnoticed.
requireInteraction: keeping the alert on screen until acknowledged
The spec's require interaction preference is a boolean, initially false, that tells devices with a sufficiently large screen to keep the notification active until the user clicks or dismisses it. Without it, you are at the mercy of the default lifetime.
| Option | Default | Use it when |
|---|---|---|
requireInteraction |
false |
The user must acknowledge |
silent |
false |
You supply your own sound |
icon |
none | Brand or status at a glance |
Firefox shipped requireInteraction support on Windows and Linux relatively recently compared with Chrome, so treat it as a preference the browser may ignore rather than a guarantee.
Handling the click without a click handler
You can attach onclick and call window.focus(), which is the classic pattern. Newer builds also support a navigate option that opens a URL on activation and bypasses the click and notificationclick events entirely. Feature-detect it and keep the handler as the fallback:
const n = new Notification("Timer finished", {
tag: "ikit-timer",
requireInteraction: true,
});
n.onclick = () => {
window.focus();
n.close();
};
One caveat from the spec that the docs call out directly: a close event does not prove the user closed it. The platform can run the close steps on its own, so never treat close as "acknowledged".
Wiring notifications into a drift-free countdown
The API is the easy half. The reliability comes from how you schedule.
Fire on the deadline, not on the tick count
Store deadline = Date.now() + durationMs once. On every tick, compare against the current clock and fire when you cross the line. Tick counting breaks the moment the tab is hidden, because the ticks stop arriving on schedule.
const deadline = Date.now() + 25 * 60 * 1000;
let fired = false;
setInterval(() => {
const left = deadline - Date.now();
render(left);
if (left <= 0 && !fired) {
fired = true;
notify("25 minutes up", { tag: "ikit-timer" });
}
}, 250);
The fired guard matters. A throttled tab that wakes up 40 seconds late will run the branch on its first tick back, and without a guard a re-render loop can fire duplicates. If you are debugging with server logs, remember your millisecond Date.now() value is 1000× a backend's second-based epoch — our Unix timestamp converter is quicker than doing that division in your head.
Use the Page Visibility API to close a stale notification
If the user returns to the tab, the banner is redundant. The Page Visibility API gives you the hook, and MDN's own example uses precisely this pattern:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
n?.close();
}
});
Do not use close() as a timer to dismiss a banner after a fixed delay. It also removes the entry from the notification tray, which takes away the user's chance to act on it later.
Fallbacks when permission is denied
Assume denial is the common case and build the ladder in order:
- Granted: system notification with
tagandrequireInteraction. - Denied or default: in-page banner plus a Web Audio beep.
- Always: update
document.titleso a glance at the tab strip tells the story. - Never: re-prompt on every visit, or auto-play audio before any user gesture.
That ladder is what a preset countdown should do — degrade quietly, without ever pretending a reminder was delivered when it was not.
References
- Using the Notifications API — permission flow, the tag replacement example, and the visibilitychange close pattern.
- Notification — Web APIs — full option list, Baseline status, and the mobile TypeError note.
- Notifications API Standard — Living Standard (last updated 15 March 2026); persistent vs non-persistent lifetime and permission-state mapping.
- Notification: requestPermission() static method — return values and the user-gesture requirement.
- Permissions request chip — Chrome 98 chip UI that replaced the modal permission prompt.
- Page Visibility API —
visibilityStatevalues used to dismiss stale notifications.
Related on iKit
- Why background tabs pause your JavaScript timer — the throttling rules that decide how late your notification actually fires.
- How setInterval drifts and performance.now() doesn't — the deadline-based scheduling this article's countdown example relies on.
- Build a browser alarm sound with the Web Audio API — the audible fallback for when notification permission is denied or Focus mode eats the banner.
- Online timer: how accurate is an in-browser countdown — where notification latency sits relative to the timer's own accuracy budget.
- 5-minute timer, 10, 25: one URL trick for all — preset links that pair well with a one-click permission prompt.
- Pomodoro technique in 2026: why 25/5 still works — the workflow that makes tab-switched reminders worth wiring up at all.
Related posts
CSV Type Inference: When "42" Should Stay a String (2026)
CSV files carry no types, so every parser guesses. Here is when a numeric-looking field should stay a string, and how to stop silent data loss.
Semicolon CSV Explained: Why European Excel Uses ; (2026)
Semicolon CSV files aren't broken exports. They come from Excel's locale list separator. Here's why it happens and how to read the files anywhere.
CSV Garbled in Excel? Fix It With a UTF-8 BOM (2026)
Your CSV opens as mojibake in Excel because the file declares no encoding. Here is why it happens, how a UTF-8 BOM fixes it, and when the BOM backfires.