Courier tracking and delivery0/66 done
Part 3 · System design · Real products

Courier tracking and delivery

Merchants send parcels. Each parcel goes from pickup to hub to a delivery rider to the customer. Merchants and customers track it, and delivery needs proof.

In plain words

A Facebook shop in Mirpur sends you a parcel. A rider picks it up and scans it. At the hub it's scanned again in, then out, then onto a delivery rider's bike. Each scan adds a line to the parcel's history, which is what you see when you open the tracking link.

The delivery rider's phone sends its location every few seconds. On a weak network, the 2:03 ping arrives after the 2:05 one, and the 2:05 one arrives twice. The map keeps the newest one as the moving dot, puts the late one in the trail where it belongs, and ignores the copy. At your door, the rider asks for the 4 digit code from your SMS. Wrong code, no delivery.

Examples

  • Courier companies like DHL and FedEx work from scan events at each hand off.
  • Phones batch GPS pings and send them every 30 s to save battery, more often near the customer.
  • The delivery code is stored only as a hash, so staff can't read it from the database.
  • Failed attempts are events too: not home, reschedule, and after 3 tries, return to the merchant.

1. Requirements

Functional

Non functional

Questions to ask

2. Estimates

parcels300k a day, about 4 per second, mostly created in the evening by merchants
scansabout 6 scan events per parcel: under 2M a day
rider pings10k riders, one GPS ping every 10 s: about 1k per second

3. API

POST /parcels { from, to, cod }                  { parcelId, trackingCode }
POST /scans { parcelId, hubId, kind }            204   (pickup, arrive hub, leave hub, out for delivery)
POST /riders/me/pings [{ t, lat, lng }]          204   (batched from the phone)
POST /parcels/{id}/deliver { otp, photo? }        200, or 403 wrong code
GET  /track/{trackingCode}                       status, history, rider position if out for delivery

4. Data model

parcels(id, tracking_code, merchant_id, status, cod_amount, otp_hash)
parcel_events(parcel_id, kind, hub_id, at)   append only: the tracking history
rider positions: Redis, newest per rider
rider trails: object storage per day, for disputes

5. High level design

Follow one request at a time, top to bottom.

Scan a parcel

  1. Hub or rider appPOST /scans with parcel, hub and kind
  2. Parcel servicechecks the scan fits the route
  3. parcel_eventsappends the event: the tracking history
  4. parcelsstatus updated from the event
  5. NotificationsSMS at key steps

Live position

  1. Rider phonebatched GPS pings with phone time
  2. Tracking servicenewest per rider wins, late ones go to the trail, duplicates dropped
  3. Redislive dot per rider
  4. Tracking pageshows the dot while out for delivery

Deliver

  1. Customerreads out the SMS code
  2. Rider appPOST /parcels/1/deliver with the code and a photo
  3. Parcel servicecompares against the stored hash
  4. parcel_eventsdelivered, with time and position

6. Deep dives

What is the source of truth for status?

The scan events. Each scan appends an event, and the parcel's status is updated from it. The tracking page reads the event list, so customers see the full history.

Pings arrive late and twice. What then?

Each ping carries the phone's timestamp. Keep the newest per rider as the live dot; ignore older ones for the dot but keep them in the trail; drop exact duplicates.

How do you prove delivery?

The customer gets a one time code by SMS. The rider must enter it; only a hash is stored. Add a photo and the GPS position at that moment for disputes.

How does the phone save battery and data?

Batch pings and send every 30 s, send more often only when the rider is close to the customer, and keep sending offline pings once the network is back.

See the core idea run

Core logic (JavaScript, tested)

// Phones send GPS pings out of order and twice. Keep only the newest per courier,
// and keep the trail for the map without duplicates.
class Tracker {
  constructor() {
    this.last = new Map(); // courier to newest ping
    this.trail = new Map(); // courier to pings in time order
  }
  ping(courier, p) {
    const seen = this.last.get(courier);
    const trail = this.trail.get(courier) ?? [];
    if (trail.some(x => x.t === p.t)) return "duplicate";
    trail.push(p);
    trail.sort((a, b) => a.t - b.t);
    this.trail.set(courier, trail);
    if (seen && seen.t > p.t) return "late"; // stored for the trail, not the live dot
    this.last.set(courier, p);
    return "live";
  }
  where(courier) {
    return this.last.get(courier) ?? null;
  }
}

// Proof of delivery: the parcel is only "delivered" with the right one time code from the customer.
const confirmDelivery = (parcel, code) => (parcel.status === "out_for_delivery" && code === parcel.otp ? ((parcel.status = "delivered"), true) : false);

7. Failures

Rider's phone is offlinePings and scans are queued on the phone with their times and sent later; order by the phone's time, not arrival.
Wrong hub scanScans are checked against the expected route; a surprise scan raises an alert instead of silently changing status.
Customer not homeA failed attempt event, a reschedule, and after 3 tries a return to merchant flow.

8. Trade-offs

Wrap up

Say out loud: Scan events are the source of truth: each scan appends to the parcel's history and moves its status. Rider GPS is separate, hot data in Redis, newest ping wins, late pings only go to the trail. Delivery needs the customer's one time code, stored as a hash, plus a photo and position for disputes.