Requesting Desktop Notification Permissions in JS

A complete tutorial on requesting desktop notification permissions in JavaScript. Covers Notification.requestPermission(), Permissions API query, permission states, best practices for timing prompts, progressive permission flows, detecting denied state, and building a permission manager with localStorage persistence.

JavaScriptintermediate
14 min read

Requesting notification permission at the right time and handling every state correctly determines whether users allow or permanently block your notifications. This guide covers the permission lifecycle, best practices for prompting, and building a permission manager.

For the full Notifications API reference, see JavaScript Notifications API: Complete Tutorial.

Permission States

The browser tracks notification permission as one of three states:

StateValueMeaning
Default"default"User has not decided; you can request permission
Granted"granted"User allowed notifications
Denied"denied"User blocked notifications; browser will not re-prompt
javascriptjavascript
function getNotificationState() {
  if (!("Notification" in window)) {
    return "unsupported";
  }
  return Notification.permission;
}
 
// Check without prompting
const state = getNotificationState();
console.log("Current permission:", state);

Requesting Permission

javascriptjavascript
// Modern async/await approach
async function requestNotificationPermission() {
  if (!("Notification" in window)) {
    return { granted: false, reason: "unsupported" };
  }
 
  if (Notification.permission === "granted") {
    return { granted: true, reason: "already-granted" };
  }
 
  if (Notification.permission === "denied") {
    return { granted: false, reason: "previously-denied" };
  }
 
  try {
    const result = await Notification.requestPermission();
    return {
      granted: result === "granted",
      reason: result, // "granted", "denied", or "default" (dismissed)
    };
  } catch (error) {
    // Fallback for older callback-based API
    return new Promise((resolve) => {
      Notification.requestPermission((result) => {
        resolve({
          granted: result === "granted",
          reason: result,
        });
      });
    });
  }
}
 
// Usage
const permission = await requestNotificationPermission();
if (permission.granted) {
  new Notification("Notifications enabled!");
}

Using the Permissions API

The Permissions API provides a way to query and watch permission state changes without triggering a prompt:

javascriptjavascript
async function queryNotificationPermission() {
  if (!("permissions" in navigator)) {
    // Fall back to Notification.permission
    return { state: Notification.permission };
  }
 
  try {
    const status = await navigator.permissions.query({
      name: "notifications",
    });
 
    return {
      state: status.state, // "granted", "denied", or "prompt"
      status,
    };
  } catch (error) {
    return { state: Notification.permission };
  }
}
 
// Watch for permission changes (e.g., user changes in browser settings)
async function watchPermissionChanges(callback) {
  if (!("permissions" in navigator)) return null;
 
  try {
    const status = await navigator.permissions.query({
      name: "notifications",
    });
 
    status.addEventListener("change", () => {
      callback(status.state);
    });
 
    return status;
  } catch (error) {
    return null;
  }
}
 
// Usage
watchPermissionChanges((newState) => {
  console.log("Permission changed to:", newState);
  if (newState === "denied") {
    showInAppFallbackBanner();
  }
});

Progressive Permission Flow

Never prompt on page load. Use a two-step approach where the user first clicks an in-app button, then sees the browser prompt:

javascriptjavascript
class ProgressivePermission {
  constructor() {
    this.prePromptShown = false;
    this.container = null;
  }
 
  shouldShowPrePrompt() {
    if (Notification.permission !== "default") return false;
    if (localStorage.getItem("notification-prompt-dismissed")) return false;
    return true;
  }
 
  showPrePrompt(container) {
    if (!this.shouldShowPrePrompt()) return;
 
    this.container = container;
    container.innerHTML = `
      <div class="permission-prompt" role="dialog" aria-label="Enable notifications">
        <div class="prompt-content">
          <h3>Stay Updated</h3>
          <p>Get notified when new tutorials are published.</p>
          <div class="prompt-actions">
            <button id="enable-notifications" class="btn-primary">
              Enable Notifications
            </button>
            <button id="dismiss-notifications" class="btn-secondary">
              Not Now
            </button>
          </div>
        </div>
      </div>
    `;
 
    this.prePromptShown = true;
 
    document.getElementById("enable-notifications")
      .addEventListener("click", () => this.handleEnable());
 
    document.getElementById("dismiss-notifications")
      .addEventListener("click", () => this.handleDismiss());
  }
 
  async handleEnable() {
    const result = await Notification.requestPermission();
 
    if (result === "granted") {
      this.showSuccess();
      new Notification("Notifications enabled!", {
        body: "You will receive updates for new content.",
        icon: "/icons/check-96.png",
      });
    } else if (result === "denied") {
      this.showDenied();
    } else {
      // Dismissed (still "default")
      this.removePrompt();
    }
  }
 
  handleDismiss() {
    localStorage.setItem("notification-prompt-dismissed", Date.now().toString());
    this.removePrompt();
  }
 
  showSuccess() {
    if (this.container) {
      this.container.innerHTML = `
        <div class="permission-success" role="status">
          Notifications enabled! You are all set.
        </div>
      `;
      setTimeout(() => this.removePrompt(), 3000);
    }
  }
 
  showDenied() {
    if (this.container) {
      this.container.innerHTML = `
        <div class="permission-denied" role="status">
          <p>Notifications are blocked. To enable them:</p>
          <ol>
            <li>Click the lock icon in the address bar</li>
            <li>Find "Notifications" in site settings</li>
            <li>Change from "Block" to "Allow"</li>
          </ol>
        </div>
      `;
    }
  }
 
  removePrompt() {
    if (this.container) {
      this.container.innerHTML = "";
    }
  }
}
 
// Usage: show after user engagement (e.g., after reading 2 articles)
const pageViews = parseInt(sessionStorage.getItem("page-views") || "0", 10) + 1;
sessionStorage.setItem("page-views", pageViews.toString());
 
if (pageViews >= 3) {
  const prompt = new ProgressivePermission();
  prompt.showPrePrompt(document.getElementById("notification-area"));
}

Permission Manager with Persistence

javascriptjavascript
class PermissionManager {
  constructor(storageKey = "notification-prefs") {
    this.storageKey = storageKey;
    this.prefs = this.loadPrefs();
    this.watchers = new Set();
    this.startWatching();
  }
 
  loadPrefs() {
    try {
      const stored = localStorage.getItem(this.storageKey);
      return stored ? JSON.parse(stored) : {
        prompted: false,
        promptedAt: null,
        dismissCount: 0,
        lastDismissed: null,
        enabledAt: null,
        deniedAt: null,
      };
    } catch {
      return {
        prompted: false,
        promptedAt: null,
        dismissCount: 0,
        lastDismissed: null,
        enabledAt: null,
        deniedAt: null,
      };
    }
  }
 
  savePrefs() {
    localStorage.setItem(this.storageKey, JSON.stringify(this.prefs));
  }
 
  getCurrentState() {
    if (!("Notification" in window)) return "unsupported";
    return Notification.permission;
  }
 
  canPrompt() {
    const state = this.getCurrentState();
    if (state !== "default") return false;
 
    // Respect dismiss cooldown: 7 days after each dismiss
    if (this.prefs.lastDismissed) {
      const cooldown = 7 * 24 * 60 * 60 * 1000;
      const elapsed = Date.now() - this.prefs.lastDismissed;
      if (elapsed < cooldown) return false;
    }
 
    // Give up after 3 dismissals
    if (this.prefs.dismissCount >= 3) return false;
 
    return true;
  }
 
  async prompt() {
    if (!this.canPrompt()) {
      return { success: false, reason: "cannot-prompt" };
    }
 
    this.prefs.prompted = true;
    this.prefs.promptedAt = Date.now();
 
    const result = await Notification.requestPermission();
 
    if (result === "granted") {
      this.prefs.enabledAt = Date.now();
      this.savePrefs();
      this.notifyWatchers("granted");
      return { success: true, reason: "granted" };
    }
 
    if (result === "denied") {
      this.prefs.deniedAt = Date.now();
      this.savePrefs();
      this.notifyWatchers("denied");
      return { success: false, reason: "denied" };
    }
 
    // Dismissed
    this.prefs.dismissCount += 1;
    this.prefs.lastDismissed = Date.now();
    this.savePrefs();
    this.notifyWatchers("dismissed");
    return { success: false, reason: "dismissed" };
  }
 
  recordDismiss() {
    this.prefs.dismissCount += 1;
    this.prefs.lastDismissed = Date.now();
    this.savePrefs();
  }
 
  async startWatching() {
    if (!("permissions" in navigator)) return;
 
    try {
      const status = await navigator.permissions.query({ name: "notifications" });
      status.addEventListener("change", () => {
        this.notifyWatchers(status.state);
      });
    } catch {
      // Permissions API not available for notifications
    }
  }
 
  onStateChange(callback) {
    this.watchers.add(callback);
    return () => this.watchers.delete(callback);
  }
 
  notifyWatchers(state) {
    for (const cb of this.watchers) {
      cb(state);
    }
  }
 
  getStats() {
    return {
      currentState: this.getCurrentState(),
      canPrompt: this.canPrompt(),
      dismissCount: this.prefs.dismissCount,
      enabledAt: this.prefs.enabledAt,
      deniedAt: this.prefs.deniedAt,
    };
  }
 
  reset() {
    localStorage.removeItem(this.storageKey);
    this.prefs = this.loadPrefs();
  }
}
 
// Usage
const permManager = new PermissionManager();
 
permManager.onStateChange((state) => {
  console.log("Notification state changed:", state);
});
 
if (permManager.canPrompt()) {
  // Show your custom pre-prompt UI
  showPrePromptUI(() => permManager.prompt());
}

Timing Strategies

StrategyWhenWhy
After user actionUser clicks "Get notified" buttonHigh intent = high acceptance
After engagement3+ page views or 30+ seconds on pageDemonstrates value before asking
After value deliveryUser completes a tutorial or saves contentPositive moment = higher acceptance
Re-prompt with cooldown7+ days after a dismissRespects the "Not now" decision
Never on first visitSkip first-time visitors entirelyNo context for the request yet

Detecting Denied State

javascriptjavascript
function handleDeniedPermission() {
  if (Notification.permission !== "denied") return;
 
  // Detect browser for specific instructions
  const isChrome = /Chrome/.test(navigator.userAgent) && !/Edg/.test(navigator.userAgent);
  const isFirefox = /Firefox/.test(navigator.userAgent);
  const isEdge = /Edg/.test(navigator.userAgent);
  const isSafari = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
 
  let instructions = "";
 
  if (isChrome || isEdge) {
    instructions = "Click the lock/tune icon in the address bar, find Notifications, and set to Allow.";
  } else if (isFirefox) {
    instructions = "Click the shield icon in the address bar, then Permissions, and clear the notification block.";
  } else if (isSafari) {
    instructions = "Go to Safari > Settings > Websites > Notifications and allow this site.";
  } else {
    instructions = "Check your browser settings to allow notifications for this site.";
  }
 
  return instructions;
}
Rune AI

Rune AI

Key Insights

  • Three permission states require different handling: default allows prompting, granted enables notifications immediately, and denied requires manual user action in browser settings
  • Progressive prompting improves acceptance rates: Show an in-app pre-prompt first so users understand the value, then trigger the browser prompt only after explicit user action
  • Track dismissals with cooldowns: Store dismiss counts and timestamps in localStorage, implementing 7-day cooldowns and giving up after 3 dismissals
  • Permissions API enables state watching: Use navigator.permissions.query() to read state without prompting and listen for change events when users modify settings externally
  • Always provide denied-state recovery instructions: Detect the browser type and show specific step-by-step instructions for re-enabling notifications through settings
RunePowered by Rune AI

Frequently Asked Questions

Why does the browser not show the permission prompt again after the user dismisses it?

Some browsers (like Chrome) treat repeated permission requests as potential abuse and may auto-block the prompt after a few dismissals. That is why progressive prompting with an in-app pre-prompt is important. Only call `Notification.requestPermission()` after explicit user action like clicking a button.

Can I detect if the user dismissed the prompt without choosing?

Yes. When `requestPermission()` resolves with `"default"`, the user closed the prompt without choosing. This differs from `"denied"` (which means they explicitly blocked notifications). Track dismiss counts in localStorage and implement cooldown periods between re-prompts.

How do I re-enable notifications after the user denied them?

You cannot programmatically re-enable denied notifications. The user must manually change the setting in browser preferences. Your application should detect the denied state and show clear instructions for how to allow notifications in their specific browser. Use the [localStorage API](/tutorials/programming-languages/javascript/js-localstorage-api-guide-a-complete-tutorial) to persist whether you have shown these instructions.

Does the Permissions API replace Notification.requestPermission?

No. `navigator.permissions.query({ name: "notifications" })` only reads the current state without prompting. You still need `Notification.requestPermission()` to actually trigger the browser permission dialog. The Permissions API adds the ability to watch for changes with the `change` event, which `Notification.permission` cannot do.

Should I use Service Worker notifications instead of the Notification constructor?

Use `ServiceWorkerRegistration.showNotification()` for notifications triggered by push events or background sync. Use `new Notification()` for notifications triggered by user actions on the current page. The Service Worker approach supports action buttons and works when the tab is closed. See [JavaScript Notifications API: Complete Tutorial](/tutorials/programming-languages/javascript/javascript-notifications-api-complete-tutorial) for details.

Conclusion

Requesting notification permissions effectively requires progressive prompting, respect for user decisions, and persistent state tracking. Never prompt on page load, always use a two-step flow with an in-app pre-prompt, and implement cooldowns between re-prompts. Track dismiss counts in localStorage and provide clear browser-specific instructions when permissions are denied. For the complete notification display API, see JavaScript Notifications API: Complete Tutorial.