Skip to content

Permissions in Progressive Web Apps

A web permission is the user's recorded decision about whether an origin may use a powerful feature such as notifications, location, the camera or the clipboard. Browsers enforce it through several gates: the secure context, the document's Permissions Policy, a user-activation requirement, the stored permission state, the prompt, and finally the operating system's own app permission. A PWA that asks at the wrong moment, from the wrong frame, or after an await that burned the user gesture gets silently denied, quieted or embargoed. This page covers every gate, the Permissions API that lets you observe them, and the behavior of installed apps on each platform.

Key takeaways

  • navigator.permissions.query() only reads state ("granted", "denied", "prompt"). Every capability has its own request method: request() never shipped by default anywhere, and revoke() shipped only in Firefox 47 to 50.
  • Permission names vary by engine. An unknown name makes query() reject with a TypeError (and Chrome rejects push without userVisibleOnly: true with NotSupportedError), so always wrap it. Safari fires no change events at all (WebKit bug 259432), so re-query when the page becomes visible.
  • Many prompts require transient activation: a trusted click, tap or key press in the last few seconds (5 seconds in Chromium, Firefox and WebKit). An await on the network between the click and the request can expire it, and some APIs consume it.
  • Permissions Policy (Permissions-Policy header, <iframe allow>) decides which frames may even ask. The header is Chromium-only, while allow works in all engines for a subset of features.
  • Chrome actively suppresses prompts: quiet UI, a 7-day embargo after 3 dismissals or 4 ignores, auto-revocation for unused sites and, since October 2025, automatic removal of notification permission from low-engagement sites. Installed web apps are exempt from that last one.
  • One-time grants ("Allow this time" in Chrome 116+ on desktop, Firefox and Safari defaults) flip back to "prompt". Treat "granted" as something that can expire.
  • On iOS and iPadOS every Home Screen web app has its own permissions, separate from Safari and from other copies of the same app. Notifications exist only there, and only after a user gesture.

How the web permission model works

The Permissions specification defines a powerful feature as one that requires the user's express permission, and a permission store that records the user's decision per feature and per key. For a top-level document the key is the origin (https://app.example). Whether an API ends up usable depends on a stack of independent checks, and failing any one of them looks like "the user said no" from your code.

flowchart TD
    A["Call a gated API"] --> B{"Secure context?"}
    B -- no --> X1["API undefined or rejects"]
    B -- yes --> C{"Permissions Policy allows this frame?"}
    C -- no --> X2["denied, no prompt"]
    C -- yes --> D{"Stored state"}
    D -- granted --> OK["Feature runs"]
    D -- denied --> X3["denied, no prompt"]
    D -- prompt --> E{"Transient activation, if required?"}
    E -- no --> X4["denied or rejected, no prompt"]
    E -- yes --> F{"Browser intervention: embargo, quiet UI?"}
    F -- embargoed --> X5["denied, no prompt"]
    F -- quiet --> Q["Small indicator instead of prompt"]
    F -- normal --> G["Prompt shown"]
    G --> H{"OS app permission granted?"}
    H -- no --> X6["denied or OS prompt"]
    H -- yes --> OK

A few consequences follow directly from the diagram:

  • "prompt" does not guarantee a prompt. The stored state can be "prompt" while the request still fails because the frame lacks activation, the policy forbids it, or the browser has quieted the site.
  • "denied" does not mean the user clicked Block. It can come from Permissions Policy, enterprise policy, an embargo, an OS-level block (a user who disabled location for the whole browser in Android settings), or a browser that auto-denies in private browsing. Your recovery instructions have to cover all of those.
  • The OS is the last gate. Chrome on Android must itself hold the Android location, camera, microphone and (on Android 13+) notification permissions. macOS asks separately whether the browser may use the camera. An installed PWA inherits whichever app the OS thinks is asking (see Permissions in installed PWAs).

The permission store is also per browser profile and per context. Incognito and private windows start empty and usually forget decisions at the end of the session. On iOS each Home Screen web app has its own store. A decision in one of these never leaks into another.

Capabilities, permission names and how they are requested

Capability Permission name How it's requested Transient activation needed? Notes
Notifications notifications Notification.requestPermission() Firefox 72+, Safari: yes. Chrome: no, but prompting without a gesture feeds the quiet UI Cross-origin iframes can't request. See Notifications API
Push push registration.pushManager.subscribe({ userVisibleOnly: true }) Same as notifications Shares the notifications decision. Query with { name: "push", userVisibleOnly: true }
Geolocation geolocation getCurrentPosition() / watchPosition() No Or the declarative <geolocation> element in Chrome 144+
Camera, microphone camera, microphone navigator.mediaDevices.getUserMedia() No Queryable in Firefox only from 132
Persistent storage persistent-storage navigator.storage.persist() No Firefox prompts. Chromium and Safari decide heuristically with no prompt. See Storage Quotas
Clipboard read clipboard-read navigator.clipboard.read() / readText() Firefox and Safari: yes, they show a "Paste" control instead of a permission Chromium shows a permission prompt
Storage access storage-access document.requestStorageAccess() Yes Embedded content only. See Privacy & Storage Partitioning
Screen wake lock screen-wake-lock navigator.wakeLock.request("screen") No Generally granted without a prompt while visible
Motion sensors (iOS) none DeviceOrientationEvent.requestPermission() Yes Safari on iOS 14.5+ per MDN, not queryable. Chrome 152 also exposes the static methods
File System Access none (per handle) showOpenFilePicker(), handle.requestPermission({ mode: "readwrite" }) Yes Chromium only. See File System Access
Bluetooth, USB, HID, Serial bluetooth (others unnamed) requestDevice() / requestPort() chooser Yes Chromium only, except Web Serial, which Firefox 151 (desktop) ships gated behind a site-permission add-on. See Hardware & Device APIs
Idle detection idle-detection IdleDetector.requestPermission() Yes Chromium only
Window management window-management window.getScreenDetails() No Chromium desktop only
Local fonts local-fonts window.queryLocalFonts() Yes Chromium desktop only
Local network local-network, loopback-network Any request from a public site to a private or loopback address No Chromium. local-network-access (Chrome 142) is now an alias for both
Periodic background sync periodic-background-sync registration.periodicSync.register() No No prompt: Chromium grants it only to installed PWAs
Background sync background-sync registration.sync.register() No No prompt. Users can turn it off in site settings

The Permissions API

The Permissions API is small. navigator.permissions exists on Navigator and WorkerNavigator and has one method that shipped everywhere:

permissions.webidl (excerpt)
[Exposed=(Window,Worker)]
interface Permissions {
  Promise<PermissionStatus> query(object permissionDesc);
};

dictionary PermissionDescriptor {
  required DOMString name;
};

[Exposed=(Window,Worker)]
interface PermissionStatus : EventTarget {
  readonly attribute PermissionState state;
  readonly attribute DOMString name;
  attribute EventHandler onchange;
};

enum PermissionState { "granted", "denied", "prompt" };

query(): descriptors, results and exceptions

query(descriptor) returns a promise for a PermissionStatus. The descriptor is a dictionary whose only common member is name. Some features extend it:

  • push accepts userVisibleOnly (default false). Chromium never grants silent push, so query({ name: "push" }) without userVisibleOnly: true rejects with a NotSupportedError ("Push Permission without userVisibleOnly:true isn't supported yet."). Firefox ignores the member and treats push as an alias of notifications.
  • midi accepts sysex (default false).
  • top-level-storage-access requires requestedOrigin (an invalid URL is a TypeError). It belongs to requestStorageAccessFor(), which Google retired with Related Website Sets in October 2025.
  • fullscreen (Chromium) accepts only allowWithoutGesture: true. Any other value is a TypeError, because a gesture-gated fullscreen has no stored state to report.

The promise rejects in two cases:

Exception When
TypeError The name is not a permission the browser knows, or the descriptor is malformed. This is the common one: query({ name: "camera" }) threw in Firefox before 132, and query({ name: "clipboard-read" }) throws in Firefox and Safari today.
NotSupportedError DOMException Chromium only: { name: "push" } without userVisibleOnly: true. Easy to miss because Firefox accepts the same descriptor.
InvalidStateError DOMException The call came from a document that is not fully active (a detached iframe, a page in the back/forward cache).

The PermissionStatus you get back is live: the spec keeps it updated while the document is active, and fires change when the state changes, for example when the user flips the setting in the page-info panel or a one-time grant expires. status.name (Chrome 97, Firefox 93, Safari 16) tells you which permission a status object belongs to when you share one handler between several.

The API aggregates every gate it can evaluate without prompting. MDN puts it this way: the permission state "effectively aggregate[s] all security restrictions for the context, including any requirement for an API to be used in a secure context, Permissions-Policy restrictions applied to the document, requirements for user interaction, and user prompts." That makes query() the right call for deciding which UI to render: a "denied" result from a frame that Permissions Policy blocks is correct, even though the user never saw a prompt.

What each state really means

"granted"
The feature will run without a prompt right now. It may be a one-time grant that reverts to "prompt" when the page is closed or backgrounded (see One-time and temporary permissions), and the OS may still refuse (Android location services off, macOS camera access withdrawn for the browser).
"prompt"
The browser has no stored decision. Calling the request method may show a prompt, if the other gates pass. It's also what you see after a one-time grant expires, after an embargo ends, or after the user clicks "Reset permission".
"denied"
A request will fail without a prompt. Sources include a user Block, Permissions Policy, enterprise policy, private-browsing defaults and Chrome's embargo. Chromium's PermissionDecisionAutoBlocker returns the status DENIED while an origin is under embargo, so a site that was dismissed three times reads "denied" for a week and then silently returns to "prompt".

There is no way to tell these sources apart from script. Design the denied state around what the user can do: open site settings, change OS settings, or use a fallback that needs no permission.

Permission names by browser

The names are defined by each capability's specification, and each engine only recognizes the ones it implements. MDN's compatibility data for the Permissions interface lists them:

Name Chrome Firefox Safari
geolocation ✅ 43 ✅ 46 ✅ 16
notifications ✅ 43 ✅ 46 (alias of push) ✅ 16.4
push ✅ 43 ✅ 46 (alias of notifications) ✅ 17
camera, microphone ✅ 64 ✅ 132 ✅ 16
persistent-storage ✅ 71 ✅ 53 ❌
screen-wake-lock ✅ 84 ✅ 126 ✅ 16.4
midi ✅ 43 ✅ 110 ❌
storage-access ✅ 119 ✅ 117 ✅ 26.2
clipboard-read, clipboard-write ✅ 64 ❌ ❌
background-sync ✅ 62 ❌ ❌
periodic-background-sync ✅ 80 ❌ ❌
local-fonts ✅ 103 (desktop) ❌ ❌
window-management ✅ 111 (desktop) ❌ ❌
accelerometer, gyroscope ✅ ❌ ❌
payment-handler ✅ 66 ❌ ❌
local-network, loopback-network ✅ 145 ❌ ❌

Support data as of September 2026. Check MDN's Permissions compatibility table for live data.

navigator.permissions itself is available in Chrome 43, Firefox 46 and Safari 16. Inside workers (including service workers) it arrived later outside Chromium: Firefox 133 and Safari 16.4.

The change event and Safari's silent status objects

In Chromium and Firefox, change fires on every live PermissionStatus for the document when the state changes. Safari 16.4 added the onchange attribute, but according to MDN's compatibility data "the event never fires" (WebKit bug 259432). If your UI depends on change, it goes stale in Safari the first time the user changes a setting in Settings and comes back. The robust pattern is to listen for change and re-query when the page returns to the foreground, which is when a user who just visited settings comes back.

permission-watch.js
// Watch a permission across browsers.
// - Handles unknown names (query() rejects with TypeError).
// - Handles Safari, where PermissionStatus "change" never fires (WebKit bug 259432),
//   by re-querying whenever the page becomes visible again.
// - Returns an unsubscribe function.

const UNSUPPORTED = "unsupported";

export async function readPermission(descriptor) {
  if (!("permissions" in navigator)) return UNSUPPORTED;
  try {
    const status = await navigator.permissions.query(descriptor);
    return status.state;
  } catch (error) {
    // TypeError: name unknown to this engine (or malformed descriptor).
    // NotSupportedError: Chromium's answer to { name: "push" } without userVisibleOnly: true.
    // InvalidStateError (document not fully active) is a real error: rethrow it.
    if (isUnsupported(error)) return UNSUPPORTED;
    throw error;
  }
}

function isUnsupported(error) {
  return error instanceof TypeError || error?.name === "NotSupportedError";
}

export function watchPermission(descriptor, onState) {
  let status = null;
  let last = null;
  let stopped = false;

  const emit = (state) => {
    if (stopped || state === last) return;
    last = state;
    onState(state);
  };

  const onChange = () => emit(status.state);

  const onVisible = async () => {
    if (document.visibilityState !== "visible") return;
    // A fresh query is the only reliable signal in Safari.
    emit(await readPermission(descriptor));
  };

  (async () => {
    if (!("permissions" in navigator)) return emit(UNSUPPORTED);
    try {
      status = await navigator.permissions.query(descriptor);
    } catch (error) {
      if (isUnsupported(error)) return emit(UNSUPPORTED);
      // Nothing awaits this IIFE, so report instead of leaving an unhandled rejection.
      console.error("Permission query failed", descriptor, error);
      return;
    }
    if (stopped) return;
    emit(status.state);
    status.addEventListener("change", onChange);
    document.addEventListener("visibilitychange", onVisible);
    // pageshow covers restores from the back/forward cache.
    window.addEventListener("pageshow", onVisible);
  })();

  return () => {
    stopped = true;
    status?.removeEventListener("change", onChange);
    document.removeEventListener("visibilitychange", onVisible);
    window.removeEventListener("pageshow", onVisible);
  };
}

Use it to drive settings toggles and banners:

settings-ui.js
import { watchPermission } from "./permission-watch.js";

const toggle = document.querySelector("#location-toggle");
const help = document.querySelector("#location-help");

watchPermission({ name: "geolocation" }, (state) => {
  toggle.disabled = state === "denied" || state === "unsupported";
  toggle.checked = state === "granted";
  help.hidden = state !== "denied"; // show "how to re-enable" instructions
});

When the Permissions API isn't enough

query() doesn't cover everything a PWA needs:

  • Capabilities without a name. File System Access handles, USB, HID and Serial devices, and iOS motion sensors have their own per-object or per-API checks: handle.queryPermission({ mode }), navigator.usb.getDevices() (which returns previously granted devices), and nothing at all for iOS motion.
  • Legacy synchronous getters. Notification.permission returns "default" where the Permissions API returns "prompt". It is exposed in workers too, which makes it the quickest check inside a service worker before calling showNotification().
  • Push-specific state. registration.pushManager.permissionState({ userVisibleOnly: true }) answers for push from the registration's point of view and is the correct check before subscribe().
  • Requesting and revoking. The spec once had request() and revoke(). MDN notes that revoke() "was proposed, but has since been removed from those browsers where it was implemented". Chromium keeps both behind a flag, and Firefox shipped revoke() only from 47 to 50. You can't revoke a grant from script, which matters for sign-out flows: if a user signs out of a shared device, the next person inherits the origin's permissions. The best you can do is unsubscribe from push and stop using the feature.

User activation: transient and sticky

Most prompts, pickers and pop-ups are gated on user activation, defined in the HTML Standard's "Tracking user activation" section. Every Window has a last activation timestamp, and two derived states:

Sticky activation
True once the user has interacted with the page (or a same-origin descendant) at any point. It is never reset. Autoplay with sound, navigator.vibrate() and the beforeunload confirmation dialog use it.
Transient activation
True while less than the transient activation duration has passed since the last activation. The spec says the duration "is expected be at most a few seconds". All three engines use 5 seconds: Chromium's user activation state, Firefox's dom.user_activation.transient.timeout preference and WebKit's defaultTransientActivationDuration constant. What differs is consumption: WebKit consumes activation for notification and push prompts, so one click buys exactly one prompt. window.open(), requestFullscreen(), navigator.share(), showOpenFilePicker(), requestDevice() and most permission prompts require it.

Which events count

An activation-triggering input event must be trusted (isTrusted === true) and one of:

  • keydown, except the Escape key and keys the browser reserves as shortcuts
  • mousedown
  • pointerdown when pointerType is "mouse"
  • pointerup when pointerType is not "mouse" (touch and pen activate on release)
  • touchend

click is not in the list, but a click is always preceded by mousedown or pointerup, so a click handler runs with activation. focus, scroll, mousemove, pointerenter and load never activate. Synthetic events (element.click() from script, dispatchEvent()) never activate, which is why you can't auto-prompt by clicking your own button programmatically.

When a user activates a window, the browser also activates all its ancestor windows and its same-origin descendants. A click inside a cross-origin iframe therefore activates the top-level page too, but a click in the top-level page does not activate a cross-origin child.

How APIs consume user activation

Some APIs consume activation: they set the last activation timestamp of every window in the page to negative infinity, so the same click can't open two pop-ups. window.open(), PaymentRequest.show() and the fullscreen request are classic consumers. WebKit consumes activation for notification and push prompts: the iOS web push page documents that calling Notification.requestPermission() and then pushManager.subscribe() from one click works only because subscribe() no longer needs activation once permission is granted.

Observing activation: navigator.userActivation

navigator.userActivation (Chrome 72, Firefox 120, Safari 16.4) exposes both states:

activation-debug.js
button.addEventListener("click", async () => {
  console.log(navigator.userActivation.isActive);      // true: transient activation
  console.log(navigator.userActivation.hasBeenActive); // true: sticky activation

  await new Promise((resolve) => setTimeout(resolve, 6000));
  console.log(navigator.userActivation.isActive);      // false in Chromium/Firefox: 5 s elapsed
});

Don't use isActive to decide whether to prompt: it's a debugging aid and a way to degrade gracefully ("tap again to continue") when you know activation has expired.

Keeping activation across async work

The most common permission bug in PWAs is an await between the click and the request. Fetching a VAPID key, reading IndexedDB or waiting for navigator.serviceWorker.ready on a cold start can take longer than the 5-second window on a slow phone or a flaky network, and any earlier call that consumes activation (a first prompt in WebKit, window.open() anywhere) leaves nothing for the next one. The failure is intermittent, which is why it survives testing on fast office Wi-Fi.

subscribe-fragile.js
button.addEventListener("click", async () => {
  // Each await eats into the transient activation window.
  const registration = await navigator.serviceWorker.ready;
  const response = await fetch("/api/push/public-key"); // network: 50 ms to 10 s
  const key = await response.text();

  // On a slow network this runs after activation has expired. Safari then
  // rejects with NotAllowedError and no prompt; the user thinks the button is broken.
  await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: key,
  });
});
subscribe-robust.js
// Resolve everything the request needs *before* the user clicks.
const ready = (async () => {
  const registration = await navigator.serviceWorker.ready;
  const response = await fetch("/api/push/public-key");
  if (!response.ok) throw new Error(`Key fetch failed: ${response.status}`);
  return { registration, key: await response.text() };
})();

let prepared = null;
ready.then((value) => {
  prepared = value;
  button.disabled = false; // only enable once the request can be synchronous
}, (error) => {
  console.error(error);
  button.hidden = true;
});

button.addEventListener("click", () => {
  if (!prepared) return;
  // First call inside the handler, with no await before it.
  prepared.registration.pushManager
    .subscribe({ userVisibleOnly: true, applicationServerKey: prepared.key })
    .then(saveSubscription, handleDenied);
});

The same rule applies to navigator.share(), file pickers and requestStorageAccess(): prepare first, then make the gated call the first thing the handler does.

Activation across frames: postMessage options

Because activation does not flow down into cross-origin iframes, an embedded widget (a payment form, a map) can't use a click in the parent. Chromium has two opt-in escape hatches on postMessage(): includeUserActivation (Chrome 72) attaches the sender's activation state to the message for the receiver to inspect, and capability delegation with { delegate: "payment" } (Chrome 100) lets the parent transfer its transient activation to the child for the Payment Request API. Neither exists in Firefox or Safari, so embedded flows there must ask the user to click inside the frame.

Permissions Policy: which frames may ask

Permissions Policy (formerly Feature Policy) is the embedder's control over powerful features. Each feature has a default allowlist, either self (top-level document and same-origin frames only) or * (every frame). The document's policy, set by the Permissions-Policy response header, can narrow that. Each <iframe> can then pass a container policy through its allow attribute. A frame may use a feature only if both its parent's policy and its container policy allow its origin, so a policy can only ever be narrowed as you go down the frame tree.

When a feature is disabled by policy, its API behaves as if the user had denied it: getCurrentPosition() calls the error callback with PERMISSION_DENIED, getUserMedia() rejects with NotAllowedError, and query() reports "denied". No prompt is shown.

The Permissions-Policy header

The header uses HTTP Structured Field dictionary syntax, which is different from the old Feature-Policy syntax:

Permissions-Policy syntax
Permissions-Policy: geolocation=(), camera=(self), microphone=(self "https://meet.example"), fullscreen=*
Allowlist Meaning
* Allowed in this document and all nested frames, whatever their origin
() Disabled everywhere, including this document
(self) This document's origin only (plus same-origin frames)
(self "https://a.example") This origin and the listed origins. Origins must be quoted strings
("https://*.example.com") Wildcard subdomains (Chrome 108+). The wildcard matches subdomains only, not the bare example.com

Syntax errors are silent. A missing quote around an origin, commas inside the parentheses, or the old 'self' spelling make that entry, or the whole header, ignored. Test the header in Chromium DevTools, where the Application > Frames view lists the "Permissions Policy" allowed and disabled features for each frame.

A PWA should disable everything it doesn't use. It shrinks what an XSS payload or a compromised third-party script can reach, and it stops embedded content from asking your users for permissions under your name. This baseline fits a typical content or productivity PWA that uses notifications and fullscreen but not hardware:

Permissions-Policy for a typical PWA (one line in production)
Permissions-Policy: accelerometer=(), ambient-light-sensor=(), autoplay=(self),
  bluetooth=(), browsing-topics=(), camera=(), display-capture=(), encrypted-media=(),
  fullscreen=(self), geolocation=(), gyroscope=(), hid=(), idle-detection=(),
  local-fonts=(), magnetometer=(), microphone=(), midi=(), payment=(),
  publickey-credentials-get=(self), screen-wake-lock=(self), serial=(), usb=(),
  web-share=(self), window-management=(), xr-spatial-tracking=()

notifications and push are not policy-controlled features. They're restricted to top-level documents by their own specs and browsers instead. Unknown feature names are ignored with a console warning, so listing a Chromium-only feature is harmless in other engines.

server/permissions-policy.js
// Adds a Permissions-Policy header to HTML responses.
// Keep the policy in one place and serialize it, so no one hand-edits the syntax.
const policy = {
  camera: [],                 // () disabled
  microphone: [],
  geolocation: ["self"],      // (self)
  fullscreen: ["self"],
  "screen-wake-lock": ["self"],
  "publickey-credentials-get": ["self"],
  payment: ["self", "https://pay.example"],
  usb: [],
  bluetooth: [],
  serial: [],
  hid: [],
  "browsing-topics": [],
};

function serialize(p) {
  return Object.entries(p)
    .map(([feature, list]) => {
      if (list === "*") return `${feature}=*`;
      const items = list.map((o) => (o === "self" ? "self" : `"${o}"`));
      return `${feature}=(${items.join(" ")})`;
    })
    .join(", ");
}

const headerValue = serialize(policy);

export function permissionsPolicy(req, res, next) {
  // Browsers only apply the header to responses that create a document (HTML).
  // Setting it on every response is harmless and keeps the middleware simple.
  res.setHeader("Permissions-Policy", headerValue);
  next();
}
nginx.conf (excerpt)
# "always" also adds the header to error pages, which are documents too.
add_header Permissions-Policy 'camera=(), microphone=(), geolocation=(self), fullscreen=(self), screen-wake-lock=(self), usb=(), bluetooth=(), serial=(), hid=(), browsing-topics=()' always;

Send the header on every HTML response, including the offline fallback page and the app shell. If your service worker serves the shell from Cache Storage, the header that applies is the one stored with the cached response, so a policy change reaches users only after the shell is re-cached. Content Security Policy has the same property, and that page explains how to version headers with the precache.

Permissions-Policy-Report-Only (Chrome 120) lets you test a stricter policy: violations are reported through the Reporting API without blocking anything.

The iframe allow attribute

The allow attribute uses the older, Feature-Policy-style syntax: feature names separated by semicolons, each followed by space-separated origins with 'self', 'src', 'none' and * in single quotes.

embeds.html
<!-- A video call widget from another origin: camera and mic for that origin only. -->
<iframe src="https://meet.example/room/42"
        allow="camera; microphone; display-capture; fullscreen"></iframe>

<!-- A map that may ask for location, but only while its src stays on maps.example. -->
<iframe src="https://maps.example/embed" allow="geolocation 'src'"></iframe>

<!-- An embedded checkout that needs Payment Request and passkeys. -->
<iframe src="https://pay.example/checkout"
        allow="payment; publickey-credentials-get"></iframe>

A feature listed without an allowlist defaults to 'src': the origin of the iframe's src URL at the time the frame was created. If the iframe navigates to another origin, it loses the feature. The attribute only grants within what the parent has. allow="camera" does nothing if the top-level document's header says camera=().

Browser support differs more than for most web platform features:

Chromium Firefox Safari
Permissions-Policy header ✅ 85 (most features from 88) ❌ ❌
<iframe allow> ✅ 60 ✅ 74, for a subset (camera, microphone, geolocation, fullscreen, payment, display-capture, and others) ✅ 11.1, for camera, microphone, display-capture, screen-wake-lock
Permissions-Policy-Report-Only ✅ 120 ❌ ❌
document.featurePolicy (introspection) ✅ 74 ❌ (behind a flag) ❌

Support data as of September 2026. See MDN: Permissions-Policy for live data per feature.

Because Firefox and Safari ignore the header, it's defense in depth rather than your only control. They still apply their own frame rules: Firefox 70 and later refuses Notification.requestPermission() from cross-origin iframes, and cross-origin iframes need allow for camera and microphone in all three engines.

Checking the policy from script

Chromium exposes the effective policy through document.featurePolicy (Chrome 74), an object with allowsFeature(feature, origin?), features(), allowedFeatures() and getAllowlistForFeature(feature). MDN's compatibility data lists a spec-named PermissionsPolicy interface with the same four methods from Chrome 152. Firefox has featurePolicy only behind a flag, and Safari has neither. Feature-detect defensively, and treat "can't tell" as "try and handle the denial":

policy-check.js
// Returns true, false, or null when the browser can't tell us (Firefox, Safari).
export function policyAllows(feature) {
  const policy = document.permissionsPolicy ?? document.featurePolicy;
  if (!policy?.allowsFeature) return null;
  return policy.allowsFeature(feature);
}

// Hide UI for features this frame can never use.
if (policyAllows("camera") === false) {
  document.querySelector("#scan-barcode").hidden = true;
}

Designing permission requests users accept

Browsers treat prompts as a scarce resource, and users treat them as interruptions. The rules below come from browser vendors' own guidance and from the interventions in the next section, which punish sites that ignore them.

  1. Never prompt on load. A prompt on first paint has no context, collects dismissals and, in Chrome, drives the site into the quiet UI and the embargo. Firefox and Safari refuse notification prompts without a gesture outright.
  2. Ask in context, from the feature. The request should follow an action whose purpose the user already understands: "Find stores near me" for location, "Scan receipt" for the camera, "Notify me when it ships" for notifications.
  3. Pre-explain with your own UI when the value isn't obvious. A dismissed in-page card costs nothing. A dismissed browser prompt counts toward an embargo and may be permanent in Safari and Firefox.
  4. Request the smallest scope. Ask for the microphone without the camera if you only need audio. Use getCurrentPosition() once instead of watchPosition() when you need one fix. Don't request persistent storage before the user has stored anything.
  5. Always have a path that works without the permission. A postcode field next to "Use my location", a file input next to the camera scanner, an in-app inbox next to push.
  6. Handle denial as a state, not an error. Show how to re-enable it (per browser, and per OS for installed apps), then stop asking.
  7. Never re-prompt in a loop. Record what happened, and only offer the in-page explanation again after the user takes a related action.
stateDiagram-v2
    [*] --> Unknown
    Unknown --> Explain: user starts feature and state is prompt
    Unknown --> Ready: state is granted
    Unknown --> Recovery: state is denied
    Explain --> Prompting: user taps Continue
    Explain --> Fallback: user taps Not now
    Prompting --> Ready: granted
    Prompting --> Recovery: denied
    Prompting --> Fallback: dismissed
    Fallback --> Explain: user starts feature again later
    Recovery --> Ready: user re-enables in settings
    Ready --> Unknown: one-time grant expires or user resets

A production location flow

This module implements the state machine for geolocation. It uses the Permissions API when available, pre-explains on first use, calls the API directly from the click, and falls back to manual entry.

location-flow.js
import { readPermission, watchPermission } from "./permission-watch.js";

const ui = {
  useLocation: document.querySelector("#use-location"),    // "Use my location" button
  explainer: document.querySelector("#location-explainer"), // <dialog> with Continue / Not now
  continueBtn: document.querySelector("#location-continue"),
  recovery: document.querySelector("#location-recovery"),   // instructions panel
  postcode: document.querySelector("#postcode"),            // manual fallback
  status: document.querySelector("#location-status"),       // aria-live region
};

const GEO_OPTIONS = {
  enableHighAccuracy: false, // coarse is enough for "stores near me" and is faster
  timeout: 15_000,
  maximumAge: 5 * 60_000,    // reuse a fix from the last 5 minutes
};

function getPosition() {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject, GEO_OPTIONS);
  });
}

async function locate() {
  ui.status.textContent = "Finding your location…";
  try {
    const { coords } = await getPosition();
    ui.status.textContent = "";
    return { lat: coords.latitude, lng: coords.longitude, accuracy: coords.accuracy };
  } catch (error) {
    // GeolocationPositionError codes: 1 PERMISSION_DENIED, 2 POSITION_UNAVAILABLE, 3 TIMEOUT
    if (error.code === 1) {
      showRecovery();
    } else {
      ui.status.textContent = "We couldn't get your location. Enter a postcode instead.";
      ui.postcode.focus();
    }
    return null;
  }
}

function showRecovery() {
  ui.recovery.hidden = false;
  ui.useLocation.disabled = true;
  ui.postcode.focus();
}

ui.useLocation.addEventListener("click", async () => {
  const state = await readPermission({ name: "geolocation" });
  if (state === "granted") return onLocation(await locate());
  if (state === "denied") return showRecovery();
  // "prompt" or "unsupported": explain first, then ask from the dialog's click.
  ui.explainer.showModal();
});

ui.continueBtn.addEventListener("click", async () => {
  ui.explainer.close();
  // getCurrentPosition() is the first call in this handler, so the prompt is tied
  // to the user's tap even in engines with short activation windows.
  onLocation(await locate());
});

// Keep the UI honest if the user changes the setting elsewhere.
watchPermission({ name: "geolocation" }, (state) => {
  ui.recovery.hidden = state !== "denied";
  ui.useLocation.disabled = state === "denied";
});

function onLocation(position) {
  if (!position) return;
  document.dispatchEvent(new CustomEvent("location", { detail: position }));
}

The recovery panel should be specific. Generic "enable location in your settings" text fails because the setting lives in different places:

Context Where the user re-enables a site permission
Chrome, Edge desktop (tab or installed app) Site information icon in the address bar or app title bar > Site settings
Chrome for Android (tab) Page info (icon left of the URL) > Permissions
Installed WebAPK on Android Long-press the app icon > App info > Notifications (notifications are delegated to the app, see below)
Firefox Permissions icon in the address bar, or Settings > Privacy & Security > Permissions
Safari on macOS Safari > Settings > Websites
Safari on iOS (tab) aA menu > Website Settings, or Settings > Apps > Safari
Home Screen web app on iOS Settings > Notifications > [app name] for notifications. Other prompts reappear on the next request

The <geolocation> element: a browser-owned button

Chrome 144 shipped the <geolocation> element, the first product of the Page-Embedded Permission Control work. A generic <permission type="..."> element ran as an origin trial from Chrome 126 to 143 before the design split into capability-specific elements. The browser renders the button, so a click on it is an unforgeable signal of intent. It can also offer a way back from a previous denial, which script-triggered prompts can't.

store-finder.html
<geolocation accuracymode="approximate" onlocation="handleLocation(event)">
  <!-- Fallback content for browsers without the element. -->
  <button type="button" id="use-location">Use my location</button>
</geolocation>

<script type="module">
  window.handleLocation = (event) => {
    const el = event.target;
    if (el.error) {
      console.warn("Location failed", el.error.code, el.error.message);
      return;
    }
    const { latitude, longitude } = el.position.coords;
    document.dispatchEvent(new CustomEvent("location", {
      detail: { lat: latitude, lng: longitude },
    }));
  };

  if (!("HTMLGeolocationElement" in window)) {
    // The element is an HTMLUnknownElement here; wire the fallback button instead.
    import("./location-flow.js");
  }
</script>

The element's surface in Chrome 144:

Member Kind Meaning
accuracymode Attribute "precise" or "approximate"
autolocate Boolean attribute Fetch a position as soon as the element loads, but only if permission is already granted. It never prompts
watch Boolean attribute Continuous updates, like watchPosition(), instead of a single fix
position Read-only property The latest GeolocationPosition, or null
error Read-only property The latest GeolocationPositionError, or null
permissionStatus, initialPermissionStatus Read-only properties The current state, and the state when the element was created
isValid, invalidReason Read-only properties Whether the element currently passes Chrome's anti-spoofing checks, and why not
location Event Fires on each position or error
promptaction, promptdismiss Events The user acted on, or dismissed, the prompt the element opened
validationstatuschange Event isValid changed, for example because the element was covered or restyled

To stop sites from disguising the button, Chrome enforces style constraints: sufficient text/background contrast (typically at least 3:1), opacity 1, minimum and maximum width, height and font size, no negative margins, and only 2D translation and proportional scaling in transforms. When a constraint is violated the element is marked invalid (isValid === false) and clicks don't open a prompt, so check invalidReason in development if nothing happens.

Chrome's announcement links the Mozilla and WebKit standards-position discussions, so check them for the current stance before counting on other engines. A <usermedia> element for camera and microphone was in a separate origin trial from Chrome 144, so treat it as experimental until it ships.

Browser interventions against permission abuse

Browsers do more than show prompts. Chrome in particular watches how users respond and changes the experience for your whole origin. None of these is visible through the Permissions API except as "denied".

Chrome's quieter notification UI and abusive-site enforcement

Chrome 80 introduced a quieter notification permission UI: a crossed-out bell in the address bar on desktop and a small infobar on Android instead of a modal bubble. Users are enrolled when they repeatedly deny notifications, or opt in under Settings > Privacy and security > Site settings > Notifications, and sites with very low acceptance rates are enrolled automatically. From Chrome 84, sites that Google's review classifies as having abusive permission requests (for example, blocking content until you allow notifications) or abusive notifications get the quiet UI with a warning, and Chrome 86 extended enforcement to abusive notification content. The Notifications API page covers these rules in detail, including the Search Console reports that tell you whether your site is affected.

The embargo

Chromium's PermissionDecisionAutoBlocker counts, per origin and per permission, how often the prompt is dismissed (closed without a choice) and ignored (left open until navigation). The constants in the current Chromium source are:

Condition Threshold Effect
Dismissals 3 7-day embargo
Ignores 4 7-day embargo
Dismissals with the quiet UI 1 7-day embargo
Ignores with the quiet UI 2 7-day embargo

During the embargo, requests resolve as denied without a prompt and query() reports "denied". The counters are per permission, so exhausting the notification budget doesn't affect location. The quiet-UI thresholds apply to the two permissions that have a quiet UI, notifications and geolocation. A few permissions use their own schedule: FedCM's embargo escalates from 2 hours after the first dismissal to 1 day, 7 days and 28 days, and on Android an experimental notification prompt design embargoes after 2 ignores of the full prompt. Prompting on page load is the quickest way to hit these thresholds, because most users ignore or dismiss a prompt they didn't ask for.

Automatic revocation

Chrome also removes grants the user has forgotten:

  • Unused sites. Chrome's Safety Check revokes permissions such as camera, microphone and location from sites the user hasn't visited in a while and tells the user it did so.
  • Notifications from low-engagement sites. On October 10, 2025, Google announced that Chrome on Android and desktop would automatically remove notification permission from sites with very low engagement and a high volume of notifications, noting that "less than 1% of all notifications receive any interaction from users". The same post states: "This feature does not revoke notifications for any installed web apps". A user can restore the permission from Safety Check or by re-enabling it on the site.
  • Abusive sites. Chrome revokes notification permission from sites Google Safe Browsing identifies as abusive.

In January 2026, Chrome also began rolling out Push API rate limits, according to Chrome's announcement of January 6, 2026. Chrome recalculates daily whether a site sends "a high volume of notifications with very little user engagement", using push volume relative to time on site, permission prompts shown relative to time on site, and the site engagement score and foreground minutes. Sites that trip it get a per-site limit that Chrome describes as "a value no less than 1000 per minute", and the push service answers pushes above it with HTTP 429. The Notifications API used from an open page is unaffected. The announcement doesn't mention an exemption for installed apps. The Push Notifications page covers backoff and retry.

Two practical consequences for a PWA: installation protects the notification permission from auto-revocation, and your server needs to handle subscriptions disappearing even when you did nothing wrong. Resubscribe when the app launches and permission is still granted, and prune endpoints that return 404 or 410.

Safari and Firefox

Firefox and Safari rely on the gesture requirement rather than on engagement heuristics. Firefox 72 and later only show the notification prompt from a user gesture. WebKit refuses notification and push prompts without transient activation and revokes push permission from sites that receive pushes without showing a notification (see Web Push on iOS & Safari).

One-time and temporary permissions

A grant is increasingly not permanent. The browsers differ in what they offer and what the default is:

Browser Behavior Features
Chrome 116+ desktop Prompt offers Allow this time and Allow on every visit Geolocation, camera, microphone
Firefox The prompt's Remember this decision checkbox. Without it, the grant is temporary and isn't stored as a site permission Geolocation, camera, microphone
Safari Per-website setting of Ask, Allow or Deny (macOS: Safari > Settings > Websites). With the default Ask, Safari stores no permanent grant, so expect to be asked again in a later session Camera, microphone, location

According to Chrome's one-time permissions announcement, a one-time grant expires when any of these happens:

  • The page is closed, navigated away from, or discarded (closing Chrome included).
  • 16 hours have passed since the grant.
  • The user revokes it or policy overrides it.
  • The page has been in the background for at least 5 minutes, unless the capability is allowed to keep running in the background, like an active camera or microphone.

While it's active, query() reports "granted". When it expires, the state returns to "prompt" and change fires. Design implications:

  • Don't cache "granted" in your own storage. Read the state when you need it.
  • Keep streams instead of re-requesting them. Holding a MediaStream open for the duration of a call is fine; releasing it and calling getUserMedia() again later may prompt again.
  • Expect a prompt on relaunch. A desktop PWA reopened the next morning is a new page. If location is central to the app, the first request of the session may prompt again, so keep it behind a user action.
one-time-aware.js
import { watchPermission } from "./permission-watch.js";

// A mapping app: when a one-time grant expires, stop the watch and
// swap the "live location" indicator for a "Resume" button.
let watchId = null;

watchPermission({ name: "geolocation" }, (state) => {
  if (state !== "granted" && watchId !== null) {
    navigator.geolocation.clearWatch(watchId);
    watchId = null;
    document.querySelector("#resume-tracking").hidden = false;
  }
});

document.querySelector("#resume-tracking").addEventListener("click", (event) => {
  event.currentTarget.hidden = true;
  watchId = navigator.geolocation.watchPosition(
    (pos) => updateMarker(pos.coords),
    (err) => console.warn("watchPosition error", err.code),
    { enableHighAccuracy: true, maximumAge: 10_000 },
  );
});

Permissions in installed PWAs

Installing a PWA changes the window, not the origin, so on most platforms it doesn't create a new permission store. The differences lie in how the OS attributes the permission and which features become available.

Chromium on desktop

An installed app on Windows, macOS, Linux or ChromeOS shares the browser profile's permission store with tabs of the same origin. Granting camera access in the app window grants it in the tab and vice versa. The app window's title bar has an app menu with App info and site settings, which is where your recovery instructions should point.

What installation adds:

  • Periodic Background Sync is only granted to installed apps (MDN: "This permission is only granted to installed Progressive Web Apps"). See Periodic Background Sync.
  • Persistent storage is more likely to be granted: Chromium's persist() heuristics favor installed apps, alongside engagement and a notification grant. See Storage Quotas & Persistence.
  • Exemption from automatic notification revocation, as quoted above.
  • Launch-time permission prompts for OS integrations. Opening a file through File Handling or a link through a protocol handler asks the user whether the app may handle it, with an option to remember the choice.

A caveat on uninstalling: Chrome's desktop uninstall dialog offers to also clear the app's data from Chrome, but a long-standing Chromium issue (41474027) reports that this hasn't always reset the site's permissions. Don't assume a reinstall starts from "prompt".

Android: WebAPKs and Trusted Web Activities

On Android, Chrome and other browsers need the OS runtime permissions first: if the user never granted Chrome location access, no site can get it. Installation adds two delegation mechanisms.

WebAPKs (PWAs installed by Chrome) have their own Android package. Chromium's WebApkServiceClient hands each notification to the WebAPK to display, so it looks like it came from your app, and it checks and requests the notification permission through the WebAPK. On Android 13 and later, that means the Android runtime notification dialog for your app, and the user can later block your app's notifications in Android's app settings, independently of Chrome's site settings. Other permissions (location, camera) remain Chrome's.

Trusted Web Activities use notification delegation, supported since Chrome 72: the TWA's Android app declares a delegation service, the browser forwards notifications to it, and the Android app's own notification permission (POST_NOTIFICATIONS on Android 13+) is what counts. The android-browser-helper library also offers location delegation. See Trusted Web Activity and Android.

iOS and iPadOS

WebKit's model is different: a Home Screen web app is a separate app, with its own storage and its own permissions, isolated from Safari and from any other copy of the same site on the device. Consequences:

  • Notifications and push exist only in Home Screen web apps (iOS 16.4+). Since iOS 26, any site added with Open as Web App (on by default) is a web app and needs no manifest display value; on iOS 16.4 to 18 the manifest display had to be standalone or fullscreen. In a Safari tab, Notification is undefined. The request must come from a user gesture: without transient activation, Notification.requestPermission() resolves "denied" without showing a prompt and pushManager.subscribe() rejects with NotAllowedError. Each web app gets its own entry in Settings > Notifications. The full details are in Web Push on iOS & Safari.
  • A grant in Safari doesn't carry over. A user who allowed location for your site in Safari is asked again inside the web app, and vice versa.
  • Camera and microphone prompts recur. Decisions for getUserMedia() in standalone web apps are often not persisted, and WebKit has open reports of recurring prompts (for example bug 215884, repeated prompts when the URL hash changes). Keep one MediaStream alive for the session instead of re-requesting, and avoid hash navigations while the camera is in use.
  • Motion and orientation need DeviceOrientationEvent.requestPermission() (and DeviceMotionEvent.requestPermission()) from a gesture, iOS 14.5+ per MDN's compatibility data. It's not queryable through navigator.permissions.
  • The Permissions API is partial. Safari 16 supports query() for geolocation, camera and microphone, adds notifications and screen wake lock in 16.4, push in 17 and storage-access in 26.2, but change never fires.
  • Every site added to the Home Screen opens as a web app on iOS 26 by default, according to WebKit's Safari 26 release notes, so more of your users now live in this separate-permission world, whether or not you planned for installation.

Web apps added to the Dock on macOS (Safari 17+) follow the same model: separate storage, permissions and push subscription from Safari. iOS & iPadOS covers the rest of the platform.

Detecting capability, policy and permission together

UI decisions need three answers: does the API exist, may this frame use it, and what did the user decide. This helper combines them into one status per feature, suitable for rendering a settings screen or deciding whether to show a feature entry point.

capability-status.js
import { readPermission } from "./permission-watch.js";

const FEATURES = {
  notifications: {
    exists: () => "Notification" in window && "serviceWorker" in navigator,
    policy: null, // not a policy-controlled feature
    descriptor: { name: "notifications" },
  },
  geolocation: {
    exists: () => "geolocation" in navigator,
    policy: "geolocation",
    descriptor: { name: "geolocation" },
  },
  camera: {
    exists: () => !!navigator.mediaDevices?.getUserMedia,
    policy: "camera",
    descriptor: { name: "camera" },
  },
  "persistent-storage": {
    exists: () => !!navigator.storage?.persist,
    policy: null,
    descriptor: { name: "persistent-storage" },
  },
};

function policyAllows(feature) {
  const policy = document.permissionsPolicy ?? document.featurePolicy;
  return policy?.allowsFeature ? policy.allowsFeature(feature) : null;
}

/**
 * @returns {Promise<Record<string, "unavailable"|"blocked-by-policy"|"granted"|"denied"|"prompt"|"unknown">>}
 */
export async function capabilityStatus() {
  const entries = await Promise.all(
    Object.entries(FEATURES).map(async ([key, f]) => {
      if (!f.exists()) return [key, "unavailable"];
      if (f.policy && policyAllows(f.policy) === false) return [key, "blocked-by-policy"];
      const state = await readPermission(f.descriptor);
      // "unsupported" means the name isn't queryable here, not that the feature is missing.
      return [key, state === "unsupported" ? "unknown" : state];
    }),
  );
  return Object.fromEntries(entries);
}

On iOS in a Safari tab, notifications correctly comes back "unavailable", which is the cue to show installation instructions instead of a "turn on notifications" button (see Install Prompts & Custom UI).

Browser support

Feature Chrome / Edge Firefox Safari (macOS) Safari (iOS/iPadOS)
navigator.permissions.query() ✅ 43 ✅ 46 ✅ 16 ✅ 16
PermissionStatus change event ✅ 43 ✅ 46 ⚠️ 16.4 ⚠️ 16.4
PermissionStatus.name ✅ 97 ✅ 93 ✅ 16 ✅ 16
permissions in workers ✅ 43 ✅ 133 ✅ 16.4 ✅ 16.4
Permissions.request() / revoke() 🧪 flag ❌ (revoke() removed in 51) ❌ ❌
navigator.userActivation ✅ 72 ✅ 120 ✅ 16.4 ✅ 16.4
Permissions-Policy header ✅ 85 ❌ ❌ ❌
<iframe allow> ✅ 60 ⚠️ 74 ⚠️ 11.1 ⚠️ 11.3
One-time grants ✅ 116 (desktop) ⚠️ ⚠️ ⚠️
<geolocation> element ✅ 144 ❌ ❌ ❌

⚠️ Safari exposes onchange but never fires the event (WebKit bug 259432). <iframe allow> in Firefox and Safari covers only a subset of features. Firefox and Safari have no "Allow this time" button, but their default grants for location, camera and microphone are temporary unless the user chooses to remember them (Firefox) or sets the website to Allow (Safari).

Support data as of September 2026. For live data, see MDN: Permissions API and caniuse: Permissions API.

Common pitfalls

  • Prompting on page load. It collects dismissals, triggers Chrome's quiet UI and embargo, and does nothing at all in Firefox and Safari for notifications.
  • Calling query() with a name the engine doesn't know, unguarded. The promise rejects with TypeError, and an unhandled rejection can break the whole settings screen in Firefox or Safari.
  • Trusting change events in Safari. They never fire. Re-query on visibilitychange and pageshow.
  • Awaiting before the gated call. Fetching a key or waiting for serviceWorker.ready inside the click handler can expire transient activation. Prepare everything first.
  • Assuming "denied" means the user said no. It may be Permissions Policy, an embargo, the OS or private browsing. Word the recovery UI accordingly.
  • Storing "permission granted" in localStorage. One-time grants, auto-revocation and users resetting permissions make it stale. Read the state from the browser.
  • Requesting permissions from a cross-origin iframe. Notifications are refused there in Chrome, Firefox and Safari. Camera, microphone and location need allow on the iframe and a parent policy that permits it.
  • Setting Permissions-Policy with Feature-Policy syntax. geolocation 'self' is not valid in the header; geolocation=(self) is. A syntax error silently drops the directive.
  • Forgetting that the service worker serves cached headers. A new Permissions-Policy doesn't apply to a cached shell until the precache updates.
  • Treating an iOS Safari grant as an app grant. The Home Screen web app starts with its own empty permission store.

Debugging

  • Chrome and Edge. The site information icon in the address bar (or in an installed app's title bar) shows and resets each permission. chrome://settings/content/siteDetails?site=https%3A%2F%2Fapp.example opens the full list. DevTools Sensors overrides geolocation, and Application > Frames shows the Permissions Policy allowed and disabled features per frame.
  • Firefox. Click the permissions icon in the address bar to clear a decision. Settings > Privacy & Security > Permissions lists per-site exceptions.
  • Safari. Safari > Settings > Websites lists per-site decisions. For iOS Home Screen apps, delete and re-add the app to reset its permissions, and use Web Inspector from a Mac to see console messages like "Notification prompting can only be done from a user gesture."
  • Automated tests. The Permissions specification defines a WebDriver extension command, Set Permission, that sets a state without a prompt. Playwright wraps this as browserContext.grantPermissions(["geolocation"], { origin }) and clearPermissions(), and Puppeteer as browserContext.overridePermissions(). See Automated Testing.
  • Checking activation. Log navigator.userActivation.isActive immediately before the gated call. If it's false, move the call earlier in the handler.

Further reading

On this site

External references