Tracking User Location With JavaScript Geolocation
A complete tutorial on tracking user location with JavaScript Geolocation. Covers continuous position monitoring with watchPosition, Haversine distance calculation, geofence detection, movement filtering by distance threshold, speed and bearing computation, trip recording, battery-aware tracking, and building a real-time location tracker.
Continuous location tracking goes beyond a single getCurrentPosition call. This guide covers distance calculation, geofencing, movement filtering, trip recording, and battery-efficient tracking strategies using watchPosition.
For the core Geolocation API reference, see JS Geolocation API guide: a complete tutorial.
Haversine Distance Calculation
The Haversine formula calculates the great-circle distance between two points on a sphere:
function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; // Earth's radius in meters
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // distance in meters
}
// Austin to Dallas
const distance = haversineDistance(30.2672, -97.7431, 32.7767, -96.797);
console.log(`Distance: ${(distance / 1000).toFixed(1)} km`); // ~296.5 kmBearing Calculation
function calculateBearing(lat1, lon1, lat2, lon2) {
const toRad = (deg) => (deg * Math.PI) / 180;
const toDeg = (rad) => (rad * 180) / Math.PI;
const dLon = toRad(lon2 - lon1);
const y = Math.sin(dLon) * Math.cos(toRad(lat2));
const x =
Math.cos(toRad(lat1)) * Math.sin(toRad(lat2)) -
Math.sin(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(dLon);
const bearing = toDeg(Math.atan2(y, x));
return (bearing + 360) % 360; // Normalize to 0-360
}
function bearingToCompass(degrees) {
const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
const index = Math.round(degrees / 45) % 8;
return directions[index];
}
const bearing = calculateBearing(30.2672, -97.7431, 32.7767, -96.797);
console.log(`Bearing: ${bearing.toFixed(1)} (${bearingToCompass(bearing)})`);Movement-Filtered Tracking
Avoid recording positions when the user is stationary:
class MovementTracker {
constructor(options = {}) {
this.minDistance = options.minDistance || 10; // meters
this.minTime = options.minTime || 5000; // milliseconds
this.lastPosition = null;
this.lastUpdateTime = 0;
this.watchId = null;
this.listeners = new Set();
this.stats = {
totalDistance: 0,
positionCount: 0,
startTime: null,
};
}
start() {
this.stats.startTime = Date.now();
this.watchId = navigator.geolocation.watchPosition(
(position) => this.handlePosition(position),
(error) => this.handleError(error),
{
enableHighAccuracy: true,
timeout: 20000,
maximumAge: 0,
}
);
}
handlePosition(position) {
const now = Date.now();
// Skip if too soon since last update
if (now - this.lastUpdateTime < this.minTime) return;
const { latitude, longitude } = position.coords;
if (this.lastPosition) {
const distance = haversineDistance(
this.lastPosition.latitude,
this.lastPosition.longitude,
latitude,
longitude
);
// Skip if the user hasn't moved enough
if (distance < this.minDistance) return;
this.stats.totalDistance += distance;
}
this.lastPosition = { latitude, longitude };
this.lastUpdateTime = now;
this.stats.positionCount++;
// Notify listeners
for (const listener of this.listeners) {
listener({
position,
totalDistance: this.stats.totalDistance,
positionCount: this.stats.positionCount,
});
}
}
handleError(error) {
console.error(`Tracking error (${error.code}): ${error.message}`);
}
onMove(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
stop() {
if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
}
getStats() {
return {
...this.stats,
duration: this.stats.startTime
? Date.now() - this.stats.startTime
: 0,
averageSpeed:
this.stats.startTime && this.stats.totalDistance > 0
? this.stats.totalDistance /
((Date.now() - this.stats.startTime) / 1000)
: 0,
};
}
}
// Usage
const tracker = new MovementTracker({ minDistance: 5, minTime: 3000 });
tracker.onMove(({ position, totalDistance }) => {
console.log(
`Moved to: ${position.coords.latitude}, ${position.coords.longitude}`
);
console.log(`Total distance: ${totalDistance.toFixed(0)}m`);
});
tracker.start();Geofence Detection
class GeofenceManager {
constructor() {
this.fences = new Map();
this.states = new Map();
this.watchId = null;
this.listeners = new Set();
}
addFence(id, center, radiusMeters) {
this.fences.set(id, {
id,
latitude: center.latitude,
longitude: center.longitude,
radius: radiusMeters,
});
this.states.set(id, null); // unknown state
}
removeFence(id) {
this.fences.delete(id);
this.states.delete(id);
}
start() {
this.watchId = navigator.geolocation.watchPosition(
(position) => this.checkFences(position),
(error) => console.error("Geofence watch error:", error.message),
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 5000 }
);
}
stop() {
if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
}
checkFences(position) {
const { latitude, longitude } = position.coords;
for (const [id, fence] of this.fences) {
const distance = haversineDistance(
latitude,
longitude,
fence.latitude,
fence.longitude
);
const isInside = distance <= fence.radius;
const previousState = this.states.get(id);
if (previousState === null) {
// First check, set initial state
this.states.set(id, isInside);
continue;
}
if (isInside && !previousState) {
// Entered the geofence
this.states.set(id, true);
this.emit("enter", id, fence, position);
} else if (!isInside && previousState) {
// Exited the geofence
this.states.set(id, false);
this.emit("exit", id, fence, position);
}
}
}
emit(event, fenceId, fence, position) {
for (const listener of this.listeners) {
listener({ event, fenceId, fence, position });
}
}
onFenceEvent(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
}
// Usage
const geofences = new GeofenceManager();
geofences.addFence("office", { latitude: 30.2672, longitude: -97.7431 }, 200);
geofences.addFence("home", { latitude: 30.3, longitude: -97.75 }, 100);
geofences.onFenceEvent(({ event, fenceId, position }) => {
console.log(`${event.toUpperCase()} geofence "${fenceId}"`);
console.log(
`At: ${position.coords.latitude}, ${position.coords.longitude}`
);
});
geofences.start();Trip Recorder
class TripRecorder {
constructor() {
this.points = [];
this.watchId = null;
this.isPaused = false;
this.startTime = null;
this.pausedDuration = 0;
this.lastPauseTime = null;
}
start() {
this.startTime = Date.now();
this.points = [];
this.watchId = navigator.geolocation.watchPosition(
(position) => {
if (this.isPaused) return;
this.points.push({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
altitude: position.coords.altitude,
speed: position.coords.speed,
accuracy: position.coords.accuracy,
timestamp: position.timestamp,
});
},
(error) => console.error("Trip recording error:", error.message),
{ enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }
);
}
pause() {
this.isPaused = true;
this.lastPauseTime = Date.now();
}
resume() {
if (this.lastPauseTime) {
this.pausedDuration += Date.now() - this.lastPauseTime;
this.lastPauseTime = null;
}
this.isPaused = false;
}
stop() {
if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
if (this.isPaused && this.lastPauseTime) {
this.pausedDuration += Date.now() - this.lastPauseTime;
}
}
getTotalDistance() {
let total = 0;
for (let i = 1; i < this.points.length; i++) {
total += haversineDistance(
this.points[i - 1].latitude,
this.points[i - 1].longitude,
this.points[i].latitude,
this.points[i].longitude
);
}
return total;
}
getActiveDuration() {
if (!this.startTime) return 0;
const elapsed = Date.now() - this.startTime;
return elapsed - this.pausedDuration;
}
getAverageSpeed() {
const duration = this.getActiveDuration() / 1000; // seconds
if (duration === 0) return 0;
return this.getTotalDistance() / duration; // m/s
}
getSummary() {
const distance = this.getTotalDistance();
const duration = this.getActiveDuration();
return {
pointCount: this.points.length,
distanceMeters: Math.round(distance),
distanceKm: (distance / 1000).toFixed(2),
durationMs: duration,
durationMinutes: (duration / 60000).toFixed(1),
averageSpeedMps: this.getAverageSpeed().toFixed(2),
averageSpeedKmh: ((this.getAverageSpeed() * 3600) / 1000).toFixed(1),
maxSpeed: this.points.reduce(
(max, p) => Math.max(max, p.speed || 0),
0
),
startTime: this.startTime ? new Date(this.startTime).toISOString() : null,
};
}
exportGeoJSON() {
return {
type: "Feature",
geometry: {
type: "LineString",
coordinates: this.points.map((p) => [p.longitude, p.latitude]),
},
properties: this.getSummary(),
};
}
}
// Usage
const trip = new TripRecorder();
trip.start();
// ... user moves around ...
// trip.pause(); // on break
// trip.resume();
// After trip ends
trip.stop();
console.log(trip.getSummary());Battery-Aware Tracking
| Strategy | enableHighAccuracy | maximumAge | Use Case |
|---|---|---|---|
| High precision | true | 0 | Navigation, fitness tracking |
| Balanced | true | 30000 | General tracking |
| Battery saver | false | 60000 | Background monitoring |
| Passive only | false | 300000 | Occasional location checks |
class BatteryAwareTracker {
constructor() {
this.tracker = new MovementTracker();
this.mode = "balanced";
}
async init() {
if ("getBattery" in navigator) {
const battery = await navigator.getBattery();
this.adjustForBattery(battery);
battery.addEventListener("levelchange", () => {
this.adjustForBattery(battery);
});
}
}
adjustForBattery(battery) {
if (battery.level < 0.15) {
this.setMode("passive");
} else if (battery.level < 0.3) {
this.setMode("battery-saver");
} else if (battery.charging) {
this.setMode("high-precision");
} else {
this.setMode("balanced");
}
}
setMode(mode) {
this.mode = mode;
this.tracker.stop();
const configs = {
"high-precision": { minDistance: 5, minTime: 2000 },
balanced: { minDistance: 10, minTime: 5000 },
"battery-saver": { minDistance: 50, minTime: 30000 },
passive: { minDistance: 200, minTime: 60000 },
};
const config = configs[mode];
this.tracker.minDistance = config.minDistance;
this.tracker.minTime = config.minTime;
this.tracker.start();
console.log(`Tracking mode: ${mode}`);
}
}Rune AI
Key Insights
- Filter by distance and time: Skip position updates when the user is stationary to reduce noise and save battery
- Haversine for distance: The formula is accurate to ~0.3% for Earth distances and works for most web tracking needs
- Geofences with enter/exit events: Track previous inside/outside state to detect transitions; avoid re-triggering on every position update
- Battery-aware mode switching: Drop to lower accuracy and longer intervals when battery is low; switch to high precision when charging
- Always call clearWatch: Forgetting to stop watchPosition drains battery and keeps GPS active; clean up in component unmount or page unload
Frequently Asked Questions
How often does watchPosition fire the callback?
Does watchPosition work when the screen is off?
How accurate is the Haversine formula?
Can I track location without user interaction?
How do I test geolocation in development?
Conclusion
Continuous location tracking requires movement filtering, distance calculation, and battery awareness. Use the Haversine formula for distance, geofences for proximity detection, and trip recorders for fitness and navigation apps. Always filter out stationary positions and adjust tracking precision based on battery level. For the core API reference, see JS Geolocation API guide. For storing location history, see JS localStorage API guide.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.