The ticket read: "location button sometimes just spins forever." Not a crash, not an error toast — just a spinner that never stopped. QA couldn't reproduce it. It worked every time on the office Wi-Fi, every time on a laptop, every time in the demo to the client.
It only happened out in the field. On a phone. Somewhere the GPS fix took its time and the Wi-Fi-based positioning fallback was slow to answer. Which meant it was exactly the one environment nobody tested in before shipping.
Here's the part that made it worse: the code had an error handler. Both arguments to getCurrentPosition() were filled in, success and failure. By every code-review instinct, this was handled.
The code that looks fully handled
function useMyLocation(onFound, onFail) { navigator.geolocation.getCurrentPosition( (pos) => onFound(pos.coords), (err) => onFail(err.message) ); } A success callback, an error callback, nothing swallowed. If you squint, it even looks defensive — most getCurrentPosition() snippets you'll find in the wild look exactly like this.
Before you keep reading: what has to happen for that error callback to fire at all? Not "what does it do" — when does the browser decide to call it?
The honest answer is: only when the browser gives up and reports a GeolocationPositionError — permission denied, position unavailable, or a timeout. And a timeout is an option you pass in. If you don't pass one, there's nothing to time out. The browser just... keeps trying. Forever, if it has to. Neither callback is under any obligation to ever run.
The default timeout is not "some reasonable number." It's Infinity.
getCurrentPosition() takes a third, optional options argument: { enableHighAccuracy, timeout, maximumAge }. Skip it — like the snippet above does — and you get the spec's own defaults:
{ enableHighAccuracy: false, timeout: Infinity, maximumAge: 0 } timeout: Infinity means precisely what it says: the call will wait as long as the device needs to produce a position, with no upper bound. On a laptop with recent Wi-Fi positioning data cached nearby, that's near-instant, so the bug never shows up in testing. On a phone with a weak signal indoors, "as long as it needs" can be minutes — or, if the underlying location provider never calls back at all, forever. There's no silent fallback, no eventual error. The spinner you built to show while waiting just keeps being accurate.
The other default nobody reads: maximumAge: 0
maximumAge controls whether the browser is allowed to hand back a cached position instead of asking the device for a brand-new fix. The default is 0 milliseconds — meaning no cache is old enough to use, ever. Every single call to getCurrentPosition(), even one fired a hundred milliseconds after the last one, forces a fresh GPS/Wi-Fi/cell fix from scratch.
That's rarely what you actually want. A "locate me" button a user double-clicks, or a component that calls getCurrentPosition() on mount right after a sibling component just did the same thing, don't need two independent fixes a tenth of a second apart. Passing a real maximumAge — "a position up to 30 seconds old is fine" — turns repeat calls into near-free cache hits instead of two full location requests:
navigator.geolocation.getCurrentPosition(onFound, onFail, { timeout: 10_000, maximumAge: 30_000, }); watchPosition() has the same shape of bug as a forgotten clearInterval
getCurrentPosition() asks once. watchPosition() asks continuously, calling your success callback every time the device's position changes meaningfully — useful for live tracking, useless the moment nobody's watching the screen anymore. It returns a watch ID, and that ID is your responsibility:
const watchId = navigator.geolocation.watchPosition(onMove, onError, { enableHighAccuracy: true, }); // later, when you're done: navigator.geolocation.clearWatch(watchId); Skip that last line — the same way it's easy to skip clearInterval() — and the watch keeps running after the component that started it is gone. In a React component, that's a watchPosition() call in a useEffect with no cleanup function: the GPS radio stays active, the callback keeps firing into a component that no longer exists, and the battery drain shows up in a support ticket that has nothing to do with location on its face.
useEffect(() => { const id = navigator.geolocation.watchPosition(onMove, onError); return () => navigator.geolocation.clearWatch(id); }, []); One line in the cleanup function. Easy to write, easy to forget, and nothing in the API warns you that you forgot it.
It's not a Promise, and that's the last gotcha
getCurrentPosition() predates fetch(), predates Promise, and was never updated to support either — it's success/error callbacks, full stop. You can't await it directly. The fix is a wrapper you write once and reuse everywhere:
function getPosition(options) { return new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 10_000, maximumAge: 30_000, ...options, }); }); } try { const pos = await getPosition(); useLocation(pos.coords); } catch (err) { if (err.code === err.PERMISSION_DENIED) showPermissionHelp(); else if (err.code === err.TIMEOUT) showRetry(); else showGenericError(); } Every default that bit the original snippet is now an explicit choice, and the whole thing reads top to bottom instead of nesting two callbacks inside each other.
The one real constraint: it needs a secure origin
This part isn't a trap, just worth stating plainly: every major browser requires a secure context — HTTPS, or localhost in development — before it will even ask for location permission. That's been true for close to a decade now, not a new restriction, and there's no workaround or flag to reach for; if a getCurrentPosition() call is silently failing in a way none of the above explains, check the protocol in the address bar first.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
The playground above simulates the GPS fix with a slider, so you get the exact same race every time instead of waiting on whatever signal your actual device has right now. Set a slow "fix," turn on "use the default options," and watch the stopwatch climb with nothing to stop it — that's timeout: Infinity, reproduced on demand. Then try the watchPosition() panel: start it, hit "simulate component unmount" without stopping it first, and watch the counter keep climbing anyway.
🧠 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.
The fix for that "spins forever" ticket was three lines: a timeout, a maximumAge, and a clearWatch() in a cleanup function that had never been written. None of it was hard once someone knew to look for it — the hard part was realizing that "sometimes it just hangs" wasn't a flaky bug report. It was the spec's own default, read literally.
Does your codebase have a bare getCurrentPosition() call anywhere without a timeout? Worth a five-minute grep before it becomes someone else's "impossible to reproduce" ticket.
📚 Read next
- Stop Losing Promise Errors.
unhandledrejectionCatches Them. - My .catch() Never Ran. The Bug Was Two Lines Above It
- JavaScript Proxy and Reflect: The Complete Guide
🚀 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___

