iKit
Technical · 10 min read ·

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 (2026)

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.permission has three values: default, granted, denied.
  • The tag option replaces a stale reminder instead of stacking duplicates.
  • Non-persistent notifications auto-close in seconds unless requireInteraction is 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 tag and requireInteraction.
  • Denied or default: in-page banner plus a Web Audio beep.
  • Always: update document.title so 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

Related on iKit

Related posts