JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
History, location, and geolocation APIs
Read and rewrite the current URL with location and history.pushState, respond to popstate, and request the device's position with the Geolocation API.
What you will learn
- Read URL parts from location and decode the query string with URLSearchParams
- Add or overwrite history entries with pushState and replaceState without a page load
- Re-render from event.state or location.search inside a popstate listener
- Handle getCurrentPosition asynchronously, with a timeout and an error-code branch
Understanding History, location, and geolocation APIs
location is not a string; it is a live object whose properties are the parsed pieces of the current URL (protocol, host, pathname, search, hash), and writing to any of them starts a navigation. That is the mental model to hold: reading location asks the browser where it is, while assigning to location.href or calling location.assign() tells it to go somewhere else, which tears down every variable your script was holding. location.search hands you the raw query string, leading question mark included and still percent-encoded, so feed it to URLSearchParams instead of splitting on & and = yourself. location.replace() also navigates, but it reuses the current entry, so the page the user arrived from is no longer one Back press away.
The session history is the list of entries behind the Back button for this one tab, and the API is deliberately thin for privacy: you get history.length and history.state and relative movement through back(), forward(), and go(n), but you can never read another entry's URL. pushState(state, '', url) appends an entry and rewrites the address bar with no network request at all, so the server is never told; replaceState overwrites the current entry, which is what transient UI state wants so the user does not have to press Back five times to escape a filter panel. The event that belongs to this pair is popstate, and it fires only when an entry is traversed, never when you push it yourself. That asymmetry is the source of most router bugs: after pushState you must call your own render code.
navigator.geolocation is a different kind of API, because nothing is sitting there to be read. A fix has to be requested from the operating system, it needs the user's permission, and it takes time, so getCurrentPosition(success, error, options) returns undefined immediately and delivers a GeolocationPosition to a callback later, with numeric coords.latitude, coords.longitude, and coords.accuracy plus a millisecond timestamp. The options carry real weight: timeout stops a silent device from leaving the request pending forever, maximumAge lets a cached fix answer instead of waking the radio, and enableHighAccuracy permits GPS at a cost in battery and latency. watchPosition takes the same arguments but keeps calling success as the device moves and returns an id for clearWatch, and both functions require a secure context, meaning https or localhost.
// Run in the console of https://example.com/shop/items?page=2&sort=asc#reviews
const params = new URLSearchParams(location.search);
console.log(location.pathname, '| page =', params.get('page'), '| hash =', location.hash);
window.addEventListener('popstate', (event) => {
console.log('popstate ->', location.search, 'state =', event.state);
});
params.set('page', '3');
history.pushState({ page: 3 }, '', location.pathname + '?' + params + location.hash);
console.log('pushed ->', location.search, 'state =', history.state);
history.back();location and history are synchronous views of where the tab is and how it got there, while geolocation is an asynchronous, permission-gated request whose answer may never arrive.
Worked examples
Asking for a position and being refused
getCurrentPosition returns nothing and reports the outcome through callbacks, including a numeric error code.
// https or localhost. The output below is from clicking Block on the prompt.
const result = navigator.geolocation.getCurrentPosition(
(pos) => {
console.log('types:', typeof pos.coords.latitude, typeof pos.coords.accuracy);
},
(err) => {
console.log('failed with code', err.code, err.code === err.PERMISSION_DENIED);
},
{ timeout: 5000, maximumAge: 60000, enableHighAccuracy: false }
);
console.log('returned:', result);Example explained
Line 1The last line prints first and prints undefined, because getCurrentPosition hands back no value; the position exists only inside a callback.
Line 2The error callback receives a GeolocationPositionError whose code is 1 for PERMISSION_DENIED, 2 for POSITION_UNAVAILABLE, and 3 for TIMEOUT.
Line 3timeout: 5000 is what prevents a silent device from leaving the request pending with neither callback ever running.
Line 4maximumAge: 60000 allows a fix up to a minute old to be reused instead of powering up the location hardware again.
Keeping filters in the URL with replaceState
Rewriting the query string in place keeps the URL shareable without stacking intermediate states behind the Back button.
// Run in the console of https://example.com/search?q=chairs
function setFilter(name, value) {
const url = new URL(location.href);
url.searchParams.set(name, value);
history.replaceState({ ...history.state, [name]: value }, '', url);
return url.search;
}
console.log(setFilter('color', 'green'));
console.log(setFilter('max', '200'));
console.log(location.href);
console.log(history.state);Example explained
Line 1new URL(location.href) is a mutable copy of the current address, so searchParams.set can add or overwrite one key without disturbing q.
Line 2replaceState overwrites the current entry, so both calls leave history.length untouched and Back still leads to the page before the search.
Line 3Spreading history.state works on the first call, when it is still null, because { ...null } evaluates to an empty object.
Line 4A URL object is accepted as the third argument and stringified, but it must stay same-origin or the call throws a SecurityError.
Following the user with watchPosition
watchPosition keeps delivering fixes until you cancel it with the id it returned synchronously.
// https or localhost, permission granted, device moving.
let updates = 0;
const watchId = navigator.geolocation.watchPosition(
(pos) => {
updates += 1;
console.log('fix', updates, 'age(ms) >= 0:', Date.now() - pos.timestamp >= 0);
if (updates === 2) {
navigator.geolocation.clearWatch(watchId);
console.log('watch cleared');
}
},
(err) => console.log('watch error', err.code)
);
console.log('watchId is a number:', typeof watchId === 'number');Example explained
Line 1The id comes back synchronously, before any fix exists, which is why the bottom line of the script logs first.
Line 2The success callback runs again on every new reading, so the counter plus clearWatch(watchId) is what ends the stream.
Line 3pos.timestamp is an epoch in milliseconds like Date.now(), so subtracting it gives the age of that reading.
Line 4Skipping clearWatch keeps the location hardware and your callback alive for the whole lifetime of the page.
Important notes
pushState and replaceState accept only same-origin URLs and throw SecurityError otherwise, the state object is structured-cloned and size-limited (so no DOM nodes or functions), and the second argument is ignored by browsers, so pass an empty string.
Geolocation needs a secure context, and the permission decision is remembered per origin: once you have clicked Block while testing, the prompt will not return until you reset the permission in site settings.
Common mistakes
Waiting for popstate after your own pushState: the address bar updates and Back and Forward work, but the first click never repaints because the render code lives only in the listener.
Treating location.search as a ready-made map: split('=') leaves the leading '?' glued to the first key and keeps values percent-encoded, so a filter receives 'New%20York' instead of 'New York'.
Writing const pos = navigator.geolocation.getCurrentPosition(...) and reading pos.coords on the next line: the call returns undefined, so you get a TypeError before the device has answered.
Try it yourself
Change, predict, then run
Build a page with three tab buttons that push '?tab=' plus the tab name with history.pushState and render the active tab from location.search, then verify that Back and Forward switch tabs through a popstate listener. Add a Locate me button that prints the latitude and longitude to two decimals, or the error code when the request fails.
Open the JavaScript workspaceCheck your understanding
Your tab switcher calls history.pushState({ tab: 'b' }, '', '?tab=b'). The address bar updates and Back and Forward move between tabs correctly, but the first click leaves the old tab on screen. What explains this?
- The browser refetched ?tab=b from the server and the response replaced your rendered view.
- The state object must be a string, so the browser ignored the call and kept the previous state.
- pushState changes the URL and adds an entry but never fires popstate, so a render that only runs in the popstate listener is skipped.
- pushState requires an absolute same-origin URL, so the relative '?tab=b' was rejected and only the hash changed.
Show answer
popstate fires only when an entry is traversed by Back, Forward, or history.go, which is exactly why those buttons work while your own push does not: you have to call the render function directly after pushState. The last option is tempting because pushState really does reject cross-origin URLs, but a relative query string resolves against the current document, so it is same-origin and the call plainly succeeded, as the updated address bar shows.