Learning Goal: By reading this project you should understand exactly how Uber, Zomato, Swiggy, and similar apps show a driver's live location to a customer. Every concept used in production is demonstrated here with real, working code.
- How Live Location Tracking Works
- The WebSocket Protocol — Why Not HTTP?
- The Server Registry — Who Is Online?
- The Fan-Out Pattern — Broadcasting Positions
- The Ride System — Peer-to-Peer Tracking
- Common Bugs and How We Fixed Them
- Production Differences (Uber/Zomato Scale)
- Running the Project
The basic flow — same in every food-delivery / rideshare app:
DRIVER BROWSER SERVER CUSTOMER BROWSER
│ │ │
│── GPS update (lat,lng) ──▶│ │
│ │── fan-out to all ────────▶│
│ │ other clients │
│ │ │ map marker moves
│── GPS update (lat,lng) ──▶│ │
│ │── fan-out ───────────────▶│
-
Driver's browser calls the Geolocation API (
navigator.geolocation.watchPosition) which fires every time the GPS moves. See:client/src/hooks/useLocation.js -
Driver sends the coordinates to the server over a persistent WebSocket connection. See:
client/src/services/SocketService.js→sendLocation() -
Server receives the update, stores the latest position, and fans out the message to every other connected client. See:
server/index.js→fanOut() -
Customer's browser receives the message and updates the map marker. See:
client/src/hooks/useRealtimeUsers.js→LOCATION_UPDATEhandler -
Map marker moves with a smooth animation. See:
client/src/components/map-engines/GoogleMapEngine.jsx→animateMarker()
Why not just poll with fetch every second?
| Approach | Latency | Server load | Real-time? |
|---|---|---|---|
| HTTP polling every 1s | ~1000ms | High (N clients × 1 req/s) | No |
| HTTP long-polling | ~100ms | Medium | Approximate |
| WebSocket ✅ | < 10ms | Low (one persistent connection) | Yes |
| Server-Sent Events | ~10ms | Low | One-direction only |
A WebSocket is a persistent, bidirectional TCP connection. Once established, either side can push data at any time with no HTTP overhead.
Client Server
│── HTTP Upgrade req ──▶│ (standard HTTP handshake)
│◀─ 101 Switching ───────│ (server agrees to upgrade)
│ │
│◀══ WebSocket frame ════│ (now both sides can push anytime)
│══ WebSocket frame ════▶│
│◀══ WebSocket frame ════│
In this project:
- Server creates a WebSocket server on the same port as Express:
server/index.js - Client connects and manages reconnection:
client/src/services/SocketService.js - Vite proxies
/wsto the backend in development:client/vite.config.js
The server maintains a single Map called registry:
// server/index.js
const registry = new Map();
// Key: userId (string, e.g. "a3f2-bc19...")
// Value: { ws, name, lastPosition: {lat,lng} | null, joinedAt }Rule: A user is ONLINE if and only if they are in this Map.
When a user disconnects (tab closed, network drop), the WebSocket close event fires
and we registry.delete(userId). We then broadcast the updated user list.
No timers. No "stale" flags. The socket state is the truth.
This is exactly how Uber's presence system works at its core — the only production difference is Redis instead of a JavaScript Map, so it works across multiple servers.
User connects → registry.set(userId, ...) → broadcast USER_LIST
User disconnects → registry.delete(userId) → broadcast USER_LIST
User moves → registry.get(userId).lastPosition = {lat,lng}
→ fan-out LOCATION_UPDATE
When a new user joins, the server sends them the complete USER_LIST which
includes every other user's lastPosition. This means the map is fully populated
immediately — no waiting for everyone to move.
When driver A moves, we want ALL other connected customers to see it:
// server/index.js
function fanOut(excludeId, payload) {
registry.forEach(({ ws }, id) => {
if (id !== excludeId && ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
});
}This is called a fan-out: one message in → N messages out.
At Zomato/Uber scale this runs through a message broker (Redis Pub/Sub, Kafka) so that the fan-out can happen across multiple server instances:
Driver App → Server 1 (Redis PUBLISH)
↓
Redis Pub/Sub
↓
Server 1 (sends to customers on Server 1)
Server 2 (sends to customers on Server 2)
Server 3 (sends to customers on Server 3)
The Ride feature teaches peer-to-peer messaging — when two specific users want to share location with each other (like a delivery boy and the customer who ordered).
Message flow:
User A (customer) Server User B (delivery boy)
│ │ │
│── RIDE_REQUEST ─────────▶│ │
│ │── RIDE_REQUEST ─────────▶│
│ │ │ (popup appears)
│ │ │
│ │◀── RIDE_ACCEPT ──────────│
│◀── RIDE_ACCEPT ──────────│ │
│ (map switches to │ │
│ tracking mode) │ │
│ │ │
│◀══ LOCATION_UPDATEs ═════╪═══ LOCATION_UPDATEs ════│
│ (path polyline draws) │ (normal broadcasts) │
The server is just a router for ride messages — it doesn't store ride state.
Each client tracks their own ride state using useRide.js.
Ride states:
IDLE → REQUESTING (sent request, waiting) → ACTIVE (tracking live)
↑ │
└─────────────── IDLE ◀────────────── cancel/end
Symptom: WebSocket connects, immediately disconnects, reconnects, loops forever.
Root cause:
// OLD CODE — DO NOT USE
_connect() {
if (this.ws) this.ws.close(); // ← THIS fires onclose
// onclose → _scheduleReconnect() → _connect() → this.ws.close() → ∞
}Fix: Three-layer defense in client/src/services/SocketService.js:
// 1. Flag: don't reconnect if WE closed intentionally
this._isIntentionalClose = true;
this.ws.onclose = null; // 2. Remove handler before closing
this.ws.close();
// 3. Only reconnect if the close was unexpected
this.ws.onclose = (e) => {
if (!this._isIntentionalClose) this._scheduleReconnect();
};Symptom: Ride partner's marker doesn't move even though they're sending updates.
Root cause:
// OLD CODE — useEffect with deps [ridePartner]
useEffect(() => {
SocketService.on("LOCATION_UPDATE", ({ userId }) => {
if (userId !== ridePartner.userId) return; // ← ridePartner is STALE here!
// ridePartner was null when this closure was created
// It never sees the updated value
});
}, [ridePartner]); // re-registers on EVERY position update → drops messages mid-flightFix: Use a useRef to give the closure a live reference:
// NEW CODE — client/src/hooks/useRide.js
const ridePartnerRef = useRef(null);
ridePartnerRef.current = ridePartner; // always up-to-date
useEffect(() => {
SocketService.on("LOCATION_UPDATE", ({ userId }) => {
const partner = ridePartnerRef.current; // ← reads CURRENT value, never stale
if (!partner || userId !== partner.userId) return;
// ...update position
});
}, []); // registers ONCE, no re-subscription stormsSymptom: When User A joins, User B can see them in the list but they have no position until A moves again.
Root cause: USER_LIST arrives slightly after LOCATION_UPDATE. Old code dropped
updates for unknown users:
// OLD CODE
if (!existing) return prev; // silently dropped!Fix: Add unknown users as placeholders (client/src/hooks/useRealtimeUsers.js):
if (existing) {
// Normal: update known user's position
next.set(userId, { ...existing, lastPosition: newPosition });
} else {
// Race condition: add placeholder, USER_LIST will fill in the name
next.set(userId, { name: `User-${userId.slice(0,6)}`, status:"online", lastPosition: newPosition });
}Symptom: Ride button disappears after 30 seconds even though user is still online.
Root cause: Old code had a setTimeout that set isStale = true after 30s of no
GPS updates. This hid the Ride button.
Fix: Removed entirely. Online = in the registry. The server deletes users on socket close. No client-side timer can know better than the server.
Here's what separates this educational demo from a production system like Zomato:
| Feature | This Project | Production (Zomato/Uber) |
|---|---|---|
| Presence tracking | In-memory JS Map | Redis with key expiry |
| Fan-out | Loop over Map | Redis Pub/Sub / Kafka |
| Horizontal scaling | Single server | Multiple servers behind load balancer |
| Authentication | None | JWT on WS handshake |
| Location history | MongoDB | TimescaleDB / InfluxDB |
| GPS accuracy | Browser Geolocation API | Mobile SDK (higher accuracy) |
| Path routing | Straight-line polyline | Road-snapped route (Google Directions API) |
| ETA | Speed × distance | ML model (traffic, driver patterns) |
| Geofencing | None | Server-side polygon checks |
- Node.js 18+
- MongoDB (local or Atlas)
- Google Maps JavaScript API key (or use without for a blank map)
cd server
cp .env.example .env # edit MONGO_URI if needed
npm install
npm run dev # starts on http://localhost:4000cd client
cp .env.example .env # add VITE_GOOGLE_MAPS_API_KEY
npm install
npm run dev # starts on http://localhost:5173- Open
http://localhost:5173in Tab 1 — grant location permission - Open
http://localhost:5173in Tab 2 — grant location permission - Each tab gets a random username (e.g. "Swift Fox", "Nova Hawk")
- Both appear in each other's sidebar immediately
- Click Ride on the other user — they get a popup
- Accept → both see the live path trail and ETA
Both tabs show the same physical location (your computer). To simulate movement, open browser DevTools → Sensors → override geolocation and change the lat/lng values — the map marker will animate to the new position.
server/
index.js ← WebSocket server, registry, fan-out, ride routing
models/LocationEvent.js ← MongoDB schema for path replay
routes/location.js ← REST API: history, latest, delete
client/src/
services/
SocketService.js ← WS connection, reconnect logic, ride message methods
hooks/
useLocation.js ← Geolocation API wrapper (watchPosition + distance gate)
useRealtimeUsers.js ← Live user registry from WS events (no stale timers)
useRide.js ← Full ride lifecycle state machine
components/
Dashboard.jsx ← Main layout, connect-once guard
MapContainer.jsx ← Bridges map engine ↔ real-time data
MapWrapper.jsx ← Swap Google Maps / MapLibre / Leaflet here
map-engines/
GoogleMapEngine.jsx ← Dark map, animated markers, ride path polyline
UserCard.jsx ← User row with always-visible Ride/End button
RidePanel.jsx ← Bottom sheet: request / waiting / active ride UI
ConnectionBadge.jsx ← WS status indicator