You're building a "find stores near you" banner. The right move, UX-wise, is to explain why you want the visitor's location before the browser's blunt little system dialog interrupts them — but only if that dialog hasn't already fired. If they already said yes, just get the location and skip the banner. If they already said no, don't ask again; show a manual city picker instead.
So before you render anything, you write a quick check: has this visitor already decided?
navigator.geolocation.getCurrentPosition(
() => showStoresNearMe(),
(err) => {
if (err.code === err.PERMISSION_DENIED) showManualPicker();
}
);
You test it in a fresh incognito window. The system permission dialog pops up immediately — the exact one you were trying to gate behind your own friendly copy. Not after your banner. Instead of it.
Guess before you scroll: the code above isn't buggy. It's doing exactly what getCurrentPosition is supposed to do. The bug is in the assumption that calling it was a way to check something.
There's no "just checking" with the Geolocation API
getCurrentPosition doesn't have a peek mode. Every call is a real request for the visitor's location, and the browser treats it that way: if the permission state for this origin is still undecided, it shows the system prompt right then, synchronously with your call. If the visitor already granted permission, it skips straight to fetching a position. If they already denied it, your error callback fires with PERMISSION_DENIED — no dialog, but you only find that out after asking.
That last case is the closest thing to a "check" the API gives you, and it's a bad one: to learn the state, you have to make the same call you'd make to actually use the feature, and if the state happens to be "undecided," making that call is deciding it — badly, with zero context, at a moment you didn't choose.
There's a reason this feels like a design gap and not a skill issue: the thing you actually want to read — "has this origin already been asked, and what did the visitor say?" — isn't geolocation-specific data at all. It's account-keeping the browser does for every permission-gated feature, and the Geolocation API was never built to expose it. You were reaching for a getter through a function whose entire job is to act.
The API that only reads
The Permissions API is that getter. navigator.permissions.query() asks the browser "what's the current state of this permission for this origin?" and resolves with the answer — without ever triggering the system dialog itself, regardless of what the answer turns out to be:
const status = await navigator.permissions.query({ name: "geolocation" });
console.log(status.state); // "granted" | "denied" | "prompt"
if (status.state === "granted") {
showStoresNearMe(); // safe to call getCurrentPosition silently
} else if (status.state === "prompt") {
showWhyWeAskBanner(); // explain first, THEN call getCurrentPosition
} else {
showManualPicker(); // already denied — don't ask again
}
prompt means "undecided" — the browser hasn't shown this visitor a dialog for this permission on this origin yet. That's your one and only safe moment to show your own explanation first. Miss it — by calling the real API to "check" — and the browser's dialog wins the moment instead of yours.
The status object isn't a one-shot snapshot, either. It's a live handle you can subscribe to:
status.addEventListener("change", () => {
console.log("permission is now:", status.state);
});
If the visitor opens your site's permission settings via the browser's UI (the little padlock icon) and flips geolocation while your tab is still open, change fires and status.state updates — no reload, no re-query. That's useful for exactly the kind of banner you're building: if someone denies it mid-session, you can swap the banner for the manual picker on the spot instead of waiting for their next visit.
The catch: not every browser recognizes every name
The Permissions API supports more than geolocation — notifications, camera, microphone, persistent-storage, clipboard-read, and others, each queried by its string name. But which names a given browser recognizes varies, and MDN is explicit about what happens when you ask for one it doesn't: the promise doesn't resolve to some "unknown" state — it rejects with a TypeError.
try {
const status = await navigator.permissions.query({ name: "camera" });
console.log(status.state);
} catch (err) {
// Some browsers don't recognize "camera" as a queryable name.
// Fall back to your default UX rather than assuming a state.
console.log("couldn't read that permission here:", err.message);
}
Skip the try/catch and an unsupported name doesn't quietly do nothing — it throws an unhandled rejection in the middle of your permission logic. Given how much this varies by browser and by permission name, treat every query() call as something that can fail, not just something that can resolve to three states.
The lesson
"Check first, then ask" is good instinct. The mistake is reaching for the feature's own API to do the checking — getCurrentPosition, Notification.requestPermission, navigator.mediaDevices.getUserMedia — when each of those is built to act, and for a permission still in prompt state, acting and asking are the same button. The Permissions API is the one part of the platform whose entire job is to answer without acting. If you find yourself calling a feature just to see what it does before deciding whether to really call it, that's the tell: you wanted a read, and you reached for a write.
Numbers and prose only get you so far here — the difference between "reading" and "doing" is more convincing when you can trigger both yourself and watch which one leaves a system dialog behind.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Go check the permission-gated features already in your app — location, notifications, the clipboard, the camera. If any of them decide whether to show your own explanation by calling the real API first, that's a prompt firing at a moment you didn't pick. What's the worst time your app's permission prompt has ever popped up on someone?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)