Real-time order tracking looks simple from the outside: a little map, a moving dot, a status label. "Order confirmed → Preparing → On the way."
Then you build it, ship it, and watch it break at 8 PM on a Friday when 200 riders are online — and you learn that geospatial state is a stream, not a point.
I work on MealApp, a restaurant marketplace in Antwerpen that handles delivery, pickup, and table reservations in one app. The live rider tracking feature is the most deceptively difficult thing we've built. This post is the tutorial I wish I'd had: the architecture, the bug we shipped twice, and the fix.
The naive version (what everyone builds first)
The obvious approach:
// rider app — send location every 2 seconds
setInterval(() => {
socket.emit('rider:location', {
riderId,
lat: position.coords.latitude,
lng: position.coords.longitude,
});
}, 2000);
// server — store latest location, broadcast to the customer
const riderLocations = {}; // { riderId: { lat, lng } }
socket.on('rider:location', (data) => {
riderLocations[data.riderId] = { lat: data.lat, lng: data.lng };
io.to(`order:${data.orderId}`).emit('rider:moved', riderLocations[data.riderId]);
});
This works. It works in dev, it works in staging, it works in your demo to the team.
It fails in production.
The bug we shipped twice: racing location updates
Under real load, customers reported riders "teleporting" — stuck at a corner for 30 seconds, then jumping three streets. The pattern was inconsistent, which made it miserable to debug.
Root cause: we treated rider.location as a single mutable object. Two problems followed:
-
Out-of-order delivery. Networks don't guarantee ordering. A location sent at
t=2scan arrive after one sent att=4s. Our server blindly overwrote state with whichever arrived last — even if it was older. - Two concurrent updates racing. With horizontal scaling (multiple Node instances behind a load balancer), two events for the same rider could hit different servers with different "latest" states.
The mental model shift that fixed everything:
A rider's location is not a value. It's a sequence of events.
The fix: append-only event log + timestamped projection
Instead of storing "the location," we store every location as an immutable event, and let the consumer project the newest valid one:
// server — append, never overwrite
socket.on('rider:location', async (data) => {
const event = {
riderId: data.riderId,
orderId: data.orderId,
lat: data.lat,
lng: data.lng,
clientTimestamp: data.sentAt, // trust the client's clock for ordering
serverTimestamp: Date.now(), // trust ours for expiry
};
// append-only store (Redis stream in our case)
await redis.xAdd(`rider:${data.riderId}:locations`, '*', event);
// only broadcast if this is the NEWEST event we've seen
const latest = await getLatestTimestamp(data.riderId);
if (event.clientTimestamp >= latest) {
await setLatestTimestamp(data.riderId, event.clientTimestamp);
io.to(`order:${data.orderId}`).emit('rider:moved', {
lat: event.lat,
lng: event.lng,
at: event.clientTimestamp,
});
}
// older event arriving late? logged, stored, never broadcast
});
And on the customer side, the same discipline — reduce the stream, don't trust arrival order:
// customer app — keep only the newest position
let lastSeenAt = 0;
socket.on('rider:moved', (pos) => {
if (pos.at <= lastSeenAt) return; // stale update, ignore
lastSeenAt = pos.at;
updateRiderMarker(pos.lat, pos.lng);
});
Result: teleporting eliminated. Stale-location reports dropped to zero. The system now degrades gracefully — worst case, the customer sees a position that's 2 seconds old, never one that's wrong.
Status labels are events too
The same lesson applied to order status. Our first implementation had a status field on the order row:
// ❌ before — a flag that two services can disagree about
order.status = 'preparing';
Two services once raced on this — one wrote preparing, another wrote on_the_way 50ms later based on a stale read, and the customer watched their order go backwards from "On the way" to "Preparing." Trust us: nothing generates support tickets like time-traveling food.
The fix was identical — statuses became an event stream:
// ✅ after — append-only, ordered, auditable
await emitOrderEvent(orderId, {
type: 'status.changed',
from: 'preparing',
to: 'on_the_way',
at: Date.now(),
});
// the customer UI subscribes to the stream and only moves forward
const ORDER = ['confirmed', 'preparing', 'on_the_way', 'delivered'];
if (ORDER.indexOf(newStatus) > ORDER.indexOf(currentStatus)) {
render(newStatus); // never go backwards
}
The bonus nobody predicted: this event log became our analytics goldmine — average prep time per restaurant, rider wait times, delivery bottlenecks — all free byproducts of fixing a bug.
Scaling notes from production
A few things that mattered once we went past toy scale:
-
One room per order, not per rider.
order:{id}rooms mean a customer only receives updates for their delivery — bandwidth stays flat as rider count grows - Batch server-side broadcasts to 1–2s intervals. Riders emit every 2s, but customers don't need 60 updates/minute — coalescing cut our websocket egress by ~40%
- Heartbeats with expiry. If no location event arrives for 30s, the UI shows "signal lost, last known position" instead of silently freezing a stale dot — honesty beats false precision
- Kill polling completely. Every status label the customer sees ("Order confirmed", "Preparing food", "On the way") is pushed, never polled
What I'd do differently on day one
-
Write the event schema first, the API second. We lost a week reconciling two services that disagreed on what
preparingmeant (one used a boolean, one a string enum). Codify events before anything else. - Trust the client clock for ordering, never for security. Client timestamps fix out-of-order arrival; server timestamps guard against abuse. You need both.
- Don't model real-time state as mutable fields. If it changes over time and users watch it change — it's a stream. Store it like one.
Try it in the wild
If you want to see this system running in production — order tracking, live rider map, status progression, plus table reservations and bill-splitting on the same event backbone — it's live in Antwerpen inside MealApp. The Friday-night load test is free. 🍔
If you're building anything with live location or order state, I'd genuinely love to hear how you solved the ordering problem — drop it in the comments. 👇
Top comments (0)