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.

JavaScriptintermediate
15 min read

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:

javascriptjavascript
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 km

Bearing Calculation

Bearing tells you the compass direction from one point to another. The math uses atan2 on the longitude and latitude differences to produce an angle in degrees, which gets normalized to the 0-360 range. The helper function below also converts numeric degrees into compass labels like N, NE, E, and so on.

javascriptjavascript
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:

javascriptjavascript
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

A geofence is a virtual boundary around a real-world location. You define a center point and a radius, and the manager fires events when the user crosses that boundary in either direction. The key detail is tracking previous state: you only want to fire "enter" when the user was previously outside, and "exit" when they were previously inside. Without that check, you would get repeated triggers on every position update.

javascriptjavascript
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

The trip recorder collects GPS points over time and computes stats like total distance, duration, average speed, and max speed. It supports pausing and resuming so break time does not count toward your averages. The exportGeoJSON method outputs the recorded path in standard GeoJSON format, which you can drop straight into mapping libraries like Leaflet or Mapbox.

javascriptjavascript
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

StrategyenableHighAccuracymaximumAgeUse Case
High precisiontrue0Navigation, fitness tracking
Balancedtrue30000General tracking
Battery saverfalse60000Background monitoring
Passive onlyfalse300000Occasional location checks

This tracker listens to the Battery Status API and automatically switches between precision levels based on current charge. When the battery drops below 15%, it backs off to passive mode with large distance thresholds and long intervals. When the device is plugged in, it ramps up to high precision. You get decent tracking without draining the user's battery on a long trip.

javascriptjavascript
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

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
RunePowered by Rune AI

Frequently Asked Questions

How often does watchPosition fire the callback?

It varies by device and movement. On mobile with GPS active, it typically fires every 1-3 seconds when moving. Stationary positions may fire less frequently. The browser decides the update frequency; there is no way to set an exact interval.

Does watchPosition work when the screen is off?

Behavior varies by browser and OS. On mobile, background tracking is often throttled or suspended when the screen is off. Service Workers do not have access to the Geolocation API. For reliable background tracking, consider a native app or PWA with background sync.

How accurate is the Haversine formula?

The Haversine formula assumes a perfect sphere the size of Earth. It is accurate to within ~0.3% for most distances. For sub-meter precision, use the Vincenty formula which accounts for Earth's ellipsoidal shape. For distances under 100km, Haversine is more than sufficient.

Can I track location without user interaction?

No. The browser requires user permission for geolocation access. Even after permission is granted, mobile browsers may stop tracking when the page is backgrounded. For fitness or delivery tracking, consider native app solutions.

How do I test geolocation in development?

Use Chrome DevTools: open Sensors panel (Ctrl+Shift+P, type "sensors"), then set custom latitude/longitude. You can also override location in Firefox DevTools. For automated testing, mock `navigator.geolocation` in your test setup.

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.