Real-Time Location Tracking With Change Streams and Socket.io
Ashiq Sultan8 min read ⢠Published Feb 09, 2023 ⢠Updated Aug 13, 2024
Rate this tutorial
In this article, you will learn how to use MongoDB Change Streams and Socket.io to build a real-time location tracking application. To demonstrate this, we will build a local package delivery service.
Change streams are used to detect document updates, such as location and shipment status, and Socket.io is used to broadcast these updates to the connected clients. An Express.js server will run in the background to create and maintain the websockets.
This article will highlight the important pieces of this demo project, but you can find the full code, along with setup instructions, on Github.
Connecting Express.js to MongoDB requires the use of the MongoDB driver, which can be installed as an npm package. For this project I have used MongoDB Atlas and utilized the free tier to create a cluster. You can create your own free cluster and generate the connection string from the Atlas dashboard.
I have implemented a singleton pattern for connecting with MongoDB to maintain a single connection across the application.
The code defines a singleton
db
variable that stores the MongoClient instance after the first successful connection to the MongoDB database.The dbConnect()
is an asynchronous function that returns the MongoClient instance. It first checks if the db variable has already been initialized and returns it if it has. Otherwise, it will create a new MongoClient instance and return it. dbConnect
function is exported as the default export, allowing other modules to use it.1 // dbClient.ts 2 import { MongoClient } from 'mongodb'; 3 const uri = process.env.MONGODB_CONNECTION_STRING; 4 let db: MongoClient; 5 const dbConnect = async (): Promise<MongoClient> => { 6 Â Â Â Â try { 7 Â Â Â Â Â Â Â Â if (db) { 8 Â Â Â Â Â Â Â Â Â Â Â Â return db; 9 Â Â Â Â Â Â Â Â } 10 Â Â Â Â Â Â Â Â console.log('Connecting to MongoDB...'); 11 Â Â Â Â Â Â Â Â const client = new MongoClient(uri); 12 Â Â Â Â Â Â Â Â await client.connect(); 13 Â Â Â Â Â Â Â Â db = client; 14 Â Â Â Â Â Â Â Â console.log('Connected to db'); 15 Â Â Â Â Â Â Â Â return db; 16 Â Â Â Â } catch (error) { 17 Â Â Â Â Â Â Â Â console.error('Error connecting to MongoDB', error); 18 Â Â Â Â Â Â Â Â throw error; 19 Â Â Â Â } 20 }; 21 export default dbConnect;
Now we can call the dbConnect function in the
server.ts
file or any other file that serves as the entry point for your application.1 // server.ts 2 import dbClient from './dbClient'; 3 server.listen(5000, async () => { 4 Â Â Â Â try { 5 Â Â Â Â Â Â Â Â await dbClient(); 6 Â Â Â Â } catch (error) { 7 Â Â Â Â Â Â Â Â console.error(error); 8 Â Â Â Â } 9 });
We now have our Express server connected to MongoDB. With the basic setup in place, we can proceed to incorporating change streams and socket.io into our application.
MongoDB Change Streams is a powerful feature that allows you to listen for changes in your MongoDB collections in real-time. Change streams provide a change notification-like mechanism that allows you to be notified of any changes to your data as they happen.
To use change streams, you need to use the
watch()
function from the MongoDB driver. Here is a simple example of how you would use change streams on a collection.1 const changeStream = collection.watch() 2 changeStream.on('change', (event) => { 3 // your logic 4 })
The callback function will run every time a document gets added, deleted, or updated in the watched collection.
Socket.IO is a popular JavaScript library. It enables real-time communication between the server and client, making it ideal for applications that require live updates and data streaming. In our application, it is used to broadcast location and shipment status updates to the connected clients in real-time.
One of the key features of Socket.IO is the ability to create "rooms." Rooms are a way to segment connections and allow you to broadcast messages to specific groups of clients. In our application, rooms are used to ensure that location and shipment status updates are only broadcasted to the clients that are tracking that specific package or driver.
The code to include Socket.IO and its handlers can be found inside the files
src/server.ts
and src/socketHandler.ts
We are defining all the Socket.IO events inside the
socketHandler.ts
file so the socket-related code is separated from the rest of the application. Below is an example to implement the basic connect and disconnect Socket.IO events in Node.js.1 // socketHandler.ts 2 const socketHandler = (io: Server) => { 3 Â Â io.on('connection', (socket: any) => { 4 Â Â Â Â console.log('A user connected'); 5 Â Â Â Â socket.on('disconnect', () => { 6 Â Â Â Â Â Â console.log('A user disconnected'); 7 Â Â Â Â }); 8 Â Â }); 9 }; 10 export default socketHandler;
We can now integrate the socketHandler function into our
server.ts file
(the starting point of our application) by importing it and calling it once the server begins listening.1 // server.ts 2 import app from './app'; // Express app 3 import http from 'http'; 4 import { Server } from 'socket.io'; 5 const server = http.createServer(app); 6 const io = new Server(server); 7 server.listen(5000, async () => { 8 Â Â try { 9 Â Â Â Â socketHandler(io); 10 Â Â } catch (error) { 11 Â Â Â Â console.error(error); 12 Â Â } 13 });
We now have the Socket.IO setup with our Express app. In the next section, we will see how location data gets stored and updated.
MongoDB has built-in support for storing location data as GeoJSON, which allows for efficient querying and indexing of spatial data. In our application, the driver's location is stored in MongoDB as a GeoJSON point.
To simulate the driver movement, in the front end, there's an option to log in as driver and move the driver marker across the map, simulating the driver's location. (More on that covered in the front end section.)
When the driver moves, a socket event is triggered which sends the updated location to the server, which is then updated in the database.
1 socket.on("UPDATE_DA_LOCATION", async (data) => { 2 Â Â const { email, location } = data; 3 Â Â await collection.findOneAndUpdate({ email }, { $set: { currentLocation: location } }); 4 });
The code above handles the "UPDATE_DA_LOCATION" socket event. It takes in the email and location data from the socket message and updates the corresponding driver's current location in the MongoDB database.
So far, we've covered how to set up an Express server and connect it to MongoDB. We also saw how to set up Socket.IO and listen for updates. In the next section, we will cover how to use change streams and emit a socket event from server to front end.
This is the center point of discussion in this article. When a new delivery is requested from the UI, a shipment entry is created in DB. The shipment will be in pending state until a driver accepts the shipment.
Once the driver accepts the shipment, a socket room is created with the driver id as the room name, and the user who created the shipment is subscribed to that room.
Here's a simple diagram to help you better visualize the flow.
With the user subscribed to the socket room, all we need to do is to listen to the changes in the driver's location. This is where the change stream comes into picture.
We have a change stream in place, which is listening to the Delivery Associate (Driver) collection. Whenever there is an update in the collection, this will be triggered. We will use this callback function to execute our business logic.
Note we are passing an option to the change stream watch function
{ fullDocument: 'updateLookup' }
. It specifies that the complete updated document should be included in the change event, rather than just the delta or the changes made to the document.1 const watcher = async (io: Server) => {\ 2 Â const collection = await DeliveryAssociateCollection();\ 3 Â const changeStream = collection.watch([], { fullDocument: 'updateLookup' });\ 4 Â changeStream.on('change', (event) => {\ 5 Â Â if (event.operationType === 'update') {\ 6 Â Â Â Â const fullDocument = event.fullDocument;\ 7 Â Â Â Â io.to(String(fullDocument._id)).emit("DA_LOCATION_CHANGED", fullDocument);\ 8 }});};
In the above code, we are listening to all CRUD operations in the Delivery Associate (Driver) collection and we emit socket events only for update operations. Since the room names are just driver ids, we can get the driver id from the updated document.
This way, we are able to listen to changes in driver location using change streams and send it to the user.Â
In the codebase, all the change stream code for the application will be inside the folder
src/watchers/
. You can specify the watchers wherever you desire but to keep code clean, I'm following this approach. The below code shows how the watcher function is executed in the entry point of the application --- i.e., server.ts file.1 // server.ts 2 import deliveryAssociateWatchers from './watchers/deliveryAssociates'; 3 server.listen(5000, async () => { 4 Â Â try { 5 await dbClient(); 6 socketHandler(io); 7 await deliveryAssociateWatchers(io); 8 Â Â } catch (error) { 9 Â Â Â Â console.error(error); 10 Â Â } 11 });
In this section, we saw how change streams are used to monitor updates in the Delivery Associate (Driver) collection. We also saw how the
fullDocument
option in the watcher function was used to retrieve the complete updated document, which then allowed us to send the updated location data to the subscribed user through sockets. The next section focuses on exploring the front-end codebase and how the emitted data is used to update the map in real time.I won't go into much detail on the front end but just to give you an overview, it's built on React and uses Leaflet.js for Map.
I have included the entire front end as a sub app in the GitHub repo under the folder
/frontend
. The Readme contains the steps on how to install and start the app.Starting the front end gives two options:Â
1. Log in as user.2. Log in as a driver.
Use the "log in as driver" option to simulate the driver's location. This can be done by simply dragging the marker across the map.
Logging in as driver will let you simulate the driver's location. The code snippet provided demonstrates the use of
useState
and useEffect
hooks to simulate a driver's location updates. The <MapContainer>
and <DraggableMarker>
are Leaflet components. One is the actual map we see on the UI and other is, as the name suggests, a marker which is movable using our mouse.1 // Driver Simulator 2 const [position, setPosition] = useState(initProps.position); 3 const gpsUpdate = (position) => { 4 Â Â const data = { 5 Â Â Â Â email, 6 Â Â Â Â location: { type: 'Point', coordinates: [position.lng, position.lat] }, 7 Â Â }; 8 Â Â socket.emit("UPDATE_DA_LOCATION", data); 9 }; 10 useEffect(() => { 11 gpsUpdate(position); 12 }, [position]); 13 return ( 14 <MapContainer> 15 <DraggableMarker position={position}/> 16 </MapContainer> 17 )
The position state is initialized with the initial props. When the draggable marker is moved, the position gets updated. This triggers the gpsUpdate function inside its useEffect hook, which sends a socket event to update the driver's location.
On the user app side, when a new shipment is created and a delivery associate is assigned, the
SHIPMENT_UPDATED
socket event is triggered. In response, the user app emits the SUBSCRIBE_TO_DA
event to subscribe to the driver's socket room. (DA is short for Delivery Associate.)1 socket.on('SHIPMENT_UPDATED', (data) => { 2 Â Â if (data.deliveryAssociateId) { 3 Â Â Â Â const deliveryAssociateId = data.deliveryAssociateId; 4 Â Â Â Â socket.emit('SUBSCRIBE_TO_DA', { deliveryAssociateId }); 5 Â Â } 6 });
Once subscribed, any changes to the driver's location will trigger the DA_LOCATION_CHANGED socket event. The
driverPosition
state represents the delivery driver's current position. This gets updated every time new data is received from the socket event.1 const [driverPosition, setDriverPosition] = useState(initProps.position); 2 socket.on('DA_LOCATION_CHANGED', (data) => { 3 Â Â const location = data.location; 4 Â Â setDriverPosition(location); 5 }); 6 return ( 7 <MapContainer> 8 <Marker position={driverPosition}/> 9 </MapContainer> 10 )
The code demonstrates how the user app updates the driver's marker position on the map in real time using socket events. The state driverPosition is passed to the component and updated with the latest location data from the DA_LOCATION_CHANGED socket event.
In this article, we saw how MongoDB Change Streams and Socket.IO can be used in a Node.js Express application to develop a real-time system.
We learned about how to monitor a MongoDB collection using the change stream watcher method. We also learned how Socket.IO rooms can be used to segment socket connections for broadcasting updates. We also saw a little front-end code on how props are manipulated with socket events.
If you wish to learn more about Change Streams, check out our tutorial on Change Streams and triggers with Node.js, or the video version of it. For a more in-depth tutorial on how to use Change Streams directly in your React application, you can also check out this tutorial on real-time data in a React JavaScript front end.