Every unnecessary or badly-timed permission prompt trains users to reflexively deny the next one. The Permissions API lets an application check current state and design deliberate UX around it, instead of firing a browser prompt the instant a page loads.
1query(): A Pure Read, Never A Prompt
navigator.permissions.query({ name: "geolocation" }) returns a promise resolving to a PermissionStatus object whose .state property is one of three values: "granted" (already allowed), "denied" (explicitly blocked), or "prompt" (undecided ā the browser would show its native permission dialog if the feature were actually requested). Calling query() itself never shows any UI to the user; it purely reports the current state.
This distinction ā checking versus requesting ā is the entire value of the API. Without it, the only way to learn a permission's state was to actually attempt to use the feature, which for many permission-gated APIs is indistinguishable from requesting it, meaning you couldn't check state without risking triggering (or re-triggering) a prompt.
2Designing Distinct UX For Each State
The real value of knowing state ahead of time is being able to design a genuinely different experience for each case, rather than firing the same prompt blindly. "granted" means you can proceed directly, with no explanatory UI needed. "prompt" is the moment to show context first ā a brief explanation of *why* the feature needs this access, presented as an in-page UI element the user chooses to interact with, immediately before the actual request (which is what most successfully-converting permission flows do, versus a jarring immediate prompt on page load).
"denied" deserves particular care: most browsers won't re-show their native prompt once a user has explicitly denied a permission, so calling the requesting API again typically produces an automatic, silent denial. The correct response is guiding the user toward manually re-enabling it through the browser's own site settings UI, rather than silently failing or repeatedly attempting a request that cannot succeed.
3PermissionStatus Stays Live ā The change Event
The object returned by query() isn't a one-time snapshot of state at the moment it was called ā it remains live for as long as a reference to it is held, firing its own change event whenever the underlying permission is updated, whether from the user adjusting it through the browser's site settings UI in another tab, or an automatic system-level change. Listening for change lets a page's UI update immediately and correctly, without polling on an interval or requiring the user to reload.
This is particularly relevant for long-lived pages ā a video call app open in a background tab whose camera permission gets revoked mid-session should be able to detect and react to that immediately, rather than only discovering the loss of access the next time it attempts to use the camera and receives an error.
4A UX Layer, Not A Security Boundary ā And Coverage Varies
It's essential to understand that query() is informational only ā it never replaces the actual, independent permission check the underlying API (getCurrentPosition(), getUserMedia(), the Notification constructor, etc.) performs at the moment it's actually called. State can change between a query() call and the subsequent real request, so the real API's own success/error handling must always still be implemented, even after checking a "granted" state moments earlier.
Coverage of which permission names can be queried also varies across browsers ā not every permission-gated feature supports query() everywhere, and calling it with an unsupported name rejects the returned promise. Wrapping the call in error handling and falling back to simply requesting the permission directly (accepting a slightly less-optimized UX in that specific browser) is the appropriate defensive pattern.
5Step-by-Step Breakdown
Ask Before You Ask. Calling geolocation or the camera directly the moment a page loads is exactly how users learn to reflexively click 'Block' on every permission prompt. The Permissions API lets you check whether access is already granted, denied, or undecided ā before you ever trigger a prompt.
query() Returns state Without Triggering A Prompt. navigator.permissions.query({ name: "geolocation" }) resolves with a PermissionStatus object whose state is "granted", "denied", or "prompt" ā critically, calling query() itself never shows a permission dialog to the user; it only reports the current state.
What query() Does. Does calling navigator.permissions.query() ever trigger a browser permission prompt on its own?
- āNo ā it only reports the current permission state, without any side effect
- āYes, every call shows a prompt if the state isn't already decided
- āOnly for camera and microphone permissions specifically
Use state To Design Better UX Before Requesting Access. Checking state first lets you skip an unnecessary prompt entirely when already "granted", show a clear explanatory UI ("Enable location to see nearby stores") before actually requesting when the state is "prompt", and offer a helpful fallback or settings link instead of a doomed repeat prompt when "denied".
Designing Around Permission State. A permission's state is "denied". What's the appropriate UX response, given that browsers won't re-prompt once explicitly denied?
- āShow instructions for manually re-enabling it in browser settings, instead of calling the API again
- āCall the permission-requesting API anyway, since it might succeed this time
- āDo nothing and silently disable the feature with no explanation
PermissionStatus Is Live ā Listen For change. The object returned by query() isn't a one-time snapshot: it fires a change event whenever the user updates that permission (via the browser's own site settings UI) while your page is open, letting the UI react immediately without requiring a reload or a re-query on a timer.
Reacting To Live Permission Changes. A user opens their browser's site settings in another tab and revokes camera access while your page is still open. How can your page detect this without polling?
- āListen for the change event on the PermissionStatus object returned by query()
- āIt's impossible without the user reloading the page
- āRepeatedly call query() on a setInterval timer
Coverage Varies By Permission Name And Browser. Not every permission-gated API is queryable ā coverage (geolocation, camera, microphone, notifications, clipboard-read, and others) varies by browser, and querying an unsupported name rejects the returned promise, so feature-detecting support before relying on it is necessary defensive practice.
Permissions API Coverage. What happens if you call query() with a permission name your browser doesn't support querying?
- āThe returned promise rejects, which should be caught
- āIt always resolves with state "prompt" as a safe default
- āIt throws synchronously before returning a promise at all
Querying Is A UX Optimization, Not A Security Boundary. The Permissions API only informs your UI decisions ā the actual access-granting API (getCurrentPosition, getUserMedia, etc.) independently and authoritatively enforces the real permission check at call time regardless of what query() previously reported, so state must never be treated as a substitute for handling the real API's own success/failure outcome.
query() Is Not A Security Guarantee. If navigator.permissions.query() reports state "granted", is it safe to skip handling the error callback of the actual permission-requesting API?
- āNo ā the actual API call still independently enforces the permission and can still fail
- āYes, a "granted" state guarantees the subsequent call will succeed
- āIt depends entirely on which specific browser is being used
Permissions API Mastered. You now know how to check a permission's state before requesting it, design distinct UX paths for granted/prompt/denied, react to live permission changes with the change event, and understand that query() informs UX without replacing the real API's own access enforcement.
Add A Permission-Request Trigger. Add a data attribute naming which permission this button will request.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Explaining A Permission Request Before Triggering It Benefits All Users, Especially Those Using Assistive Technology
An unexpected native browser dialog can be particularly disorienting for screen reader users navigating linearly ā showing an in-page explanation first, using the Permissions API's state, gives clear context before the interruption occurs.
Best Practices
Never Trigger A Permission-Requesting API On Page Load Without User-Initiated Context
Use query() to check state first, and only request access following a deliberate user action (clicking a clearly-labeled 'Enable Location' button), which also tends to produce meaningfully higher grant rates.
Always Still Handle The Real API's Own Error/Denial Path, Even After A 'Granted' Query Result
State can change between the query and the actual call, and query() itself is never the authoritative security check ā only the real API is.
Frequent Bugs
A page repeatedly triggers a permission prompt on every load for a permission the user already explicitly denied, since the state was never checked first.
Query state before requesting, and for a denied state, show manual re-enable instructions instead of calling the requesting API again.
Code checks permissions.query() once on load and assumes that state is accurate for the entire session.
Attach a change listener to the returned PermissionStatus object to react to permission updates that happen while the page remains open.
Real-World Examples
A Location Feature With Deliberate, State-Aware UX
A store-locator feature that only prompts for geolocation after explaining why, and never re-prompts a user who already denied it.
const status = await navigator.permissions.query({ name: "geolocation" });
if (status.state === "granted") {
findNearbyStores();
} else if (status.state === "prompt") {
showEnableLocationButton(); // requests only on click
} else {
showManualEnableInstructions();
}
status.addEventListener("change", () => location.reload());