Your 'Just Checking' Call Is a Write: Permission Prompts and the Read/Write Trap

You're building the “find stores near you” banner. The good version explains why you want the visitor's location before the browser's blunt system dialog interrupts them — and it skips the explanation entirely if the visitor already answered. So, before rendering anything, you try to check the state. You call navigator.geolocation.getCurrentPosition(), catch the error, and branch on PERMISSION_DENIED.
Then you test in a fresh incognito window. The system dialog fires immediately — not after your banner, but instead of it. The code you wrote to avoid an untimely prompt is the thing that produces one.
Here's the uncomfortable part: that code isn't buggy. It does exactly what it promises. The bug is the assumption hiding inside the word “check.”
There is no peek mode
getCurrentPosition is not a getter. It's a request. The browser treats every call as a real ask for a real location, and there are only three states it can be in when the ask arrives:
- Already granted. No dialog. You get a position.
- Already denied. No dialog. Your error callback fires with PERMISSION_DENIED.
- Undecided. The browser shows the system dialog right then, in the same moment as your call.
Two of those three behave like a check — and both are cases where you already knew the answer. In the third case, the one you actually care about, asking is deciding. You've converted “we don't know yet” into a recorded choice the visitor didn't knowingly make, at a moment they didn't pick, with none of your context on screen.
The error callback makes this worse, not better, because it reads like a check result. PERMISSION_DENIED sounds like a fact you retrieved. It's a fact you can only obtain by first making the same call you'd make to use the feature for real.
The read you were looking for
The thing you want to know — has this origin been asked, and what did the visitor say? — isn't geolocation data. It's bookkeeping the browser keeps for every permission-gated feature, and it has its own API whose entire job is to answer without acting: the Permissions API.
Calling navigator.permissions.query({ name: “geolocation” }) returns a status object whose state is granted, denied, or prompt. It never opens a dialog. prompt means undecided — that visitor has never been shown a system dialog for your origin on this feature. That is your one safe moment to show your own explanation first.
In practice: query first. If the state is granted, call getCurrentPosition and get on with it. If it's prompt, render your explainer and only call getCurrentPosition when the visitor taps your button. If it's denied, don't ask again — show the manual city picker, and, if it's worth the space, tell them where in the browser's site settings they can change their mind.
The status is a live handle, not a snapshot
A PermissionStatus is an event target. Subscribe to its change event and you get updates without polling: if the visitor opens the padlock menu and flips geolocation while your tab is still open, the state changes and your handler fires. For a banner, that means you can swap the explainer for the manual picker mid-session instead of finding out on the visitor's next visit that you were wrong about them.
Treat every query as fallible
Which permission names a browser recognizes varies. Ask for one that isn't queryable — camera is a common casualty — and the promise doesn't resolve to some neutral “unknown” state. It rejects with a TypeError. Skip the try/catch and you get an unhandled rejection in the middle of your permission logic, which is strictly worse than having no logic at all. Wrap the query, and fall back to the most respectful default: assume undecided, explain first, call the real API only on a user action.
That fallback is also the honest answer. If you can't read the state, you don't know it — and “explain, then ask on a tap” is correct for every state you can't observe.
The trade-off you're accepting
query is asynchronous, so your decision lands a tick after first paint. Deciding what to render before the answer arrives means either reserving the space or accepting a small layout shift. That's the real cost of doing this properly — and it's much cheaper than a dialog the visitor meets before they've read a single sentence from you.
The platform isn't uniformly bad at this
Notifications solved it years ago. Notification.permission is a synchronous getter that never prompts, and the requesting call is a separate, differently named function: requestPermission(). Geolocation has no equivalent getter, which is precisely why so many developers reach for getCurrentPosition as though it were one. For clipboard reads, camera, microphone, push, and persistent storage, the question to ask is whether the platform exposes a read that is distinct from the write. Where it doesn't, people write bugs like this one, and the bugs usually ship — because the happy path in your own browser profile never exercises the undecided state.
The same trap is in your own code
This isn't a browser curiosity. It's a design failure that appears anywhere a system has exactly one entry point, and that entry point changes something. A few I keep running into:
- Health checks that do work. A /health endpoint that warms a cache, opens a fresh database connection, or nudges a retry. Your load balancer is only trying to learn a fact; now it generates load and mutates state thousands of times an hour. Probes should be free.
- Admin previews that persist. An importer with a “check this file” button that writes a staging row to prove parsing works. Every preview now needs cleanup that nobody wrote.
- GETs that act. The classic logout link. Anything that fetches with a side effect breaks retries, prefetching, and every crawler that wanders into your app.
- Agent tools named like reads. If your assistant has a get_deployment_status tool whose implementation retries the deploy when a check fails, then every speculative tool call inside the model's reasoning is now a deploy. Agents call tools far more speculatively than humans do — and they can't tell a write from a read any better than your callback names can.
In all four cases, the cause is the same as the geolocation bug: the read and the write were never separated, so the read had to borrow the write.
How to design the read you wanted
Whether you're wrapping a browser API or writing your own service, the checklist is short:
- Name by verb. query and get are reads. request, submit, create, retry are writes. If your read is called requestSomething, it will prompt.
- Make the read free. No writes, no background jobs, no cache warming, no audit rows on the read path. If a fact is expensive to produce, produce it in a separate, explicitly invoked call.
- Return three states, not two. granted, denied, undecided. A boolean silently folds “we never asked” into “no,” which is exactly the fold that produced the original bug.
- Emit changes. A read that's only accurate at the instant you asked is half a read. Let consumers subscribe.
- Test the undecided path. It's the only state you can't reach by accident in your own browser profile, which is why it's the one that ships broken. A clean profile or a fresh container belongs in the case list.
The reframe
If the only way to find out what an API will do is to make it do something, you're not checking. You're asking — and for a permission that's still undecided, asking and deciding are the same event.
The fix is small and boring. Read the state from the API whose entire purpose is answering without acting, branch on three states instead of two, and act on the visitor's terms rather than your render's.
Key Takeaways
- getCurrentPosition is a request, not a check. On an undecided visitor it opens the system dialog the moment you call it.
- Use navigator.permissions.query to read granted, denied, or prompt without prompting. prompt is your one chance to explain first.
- Subscribe to the status object's change event so mid-session permission changes update your UI without a reload.
- Treat query as fallible: unsupported permission names reject with a TypeError. Catch it and default to explain-then-ask-on-tap.
- The pattern generalizes — health checks that do work, previews that persist, GETs that act, and agent tools named like reads all borrow a write to answer a question.
- Design reads that are free, tri-state, and observable, and put the undecided path in your test list.