2026-09-04 12 min read Node.js · MongoDB · React Native

Nearby Order Batching with Haversine for Courier Dispatch

Build multi-drop courier batching in Node.js: Haversine scoring, admin radius, same-restaurant preference, batchId assignment, and a one-tap driver accept flow.

Most “dispatch tutorials” stop at find the nearest free driver. That is useful — and incomplete. On a busy lunch rush, courier earnings and customer ETAs improve when a driver who just accepted Order A can also pick up Order B if the dropoffs sit inside a tight radius.

This guide walks through the pattern we ship in Good Food Pro: nearby order batching scored with Haversine, exposed as logistics APIs and called from the driver app on accept. No Redis required for city-scale fleets that still fit in a Mongo query + in-memory score pass.

What you will build

A service that, given an anchor order, returns nearby ready deliveries sorted by same-restaurant affinity then distance — then assigns them under one batchId when the courier accepts.

1. The problem multi-drop actually solves

Single-order accept is easy: set driver, flip status to out_for_delivery, done. The operational leak appears when two kitchens two blocks apart both have bags ready, and the courier drives past the second restaurant empty.

Batching flips the question from “who is closest to this order?” to “which other orders are close enough to the order I just took?”

Anchor order (accepted)
        │
        ▼
  resolve origin = dropoff || restaurant
        │
        ▼
  load pool: delivery + status ∈ {ready, out_for_delivery}
             + unassigned (or already this driver)
        │
        ▼
  score: sameRestaurant first, then haversineKm ≤ radius
        │
        ▼
  take top (MAX_BATCH_SIZE - 1) → suggest / assign

That is logistics as a product feature, not a map demo.

2. Geo model: dropoff vs restaurant anchors

Orders rarely store a single perfect “job coordinate.” In practice you need two fallbacks:

Origin resolution (pattern)

function dropoffCoords(order) {
  const loc = order?.user?.location;
  if (loc?.coordinates?.length === 2) {
    return { lng: Number(loc.coordinates[0]), lat: Number(loc.coordinates[1]) };
  }
  if (loc?.latitude != null && loc?.longitude != null) {
    return { lat: Number(loc.latitude), lng: Number(loc.longitude) };
  }
  return null;
}

function restaurantCoords(order) {
  const r = order?.restaurant;
  if (r?.latitude == null || r?.longitude == null) return null;
  return { lat: Number(r.latitude), lng: Number(r.longitude) };
}

// Prefer dropoff; fall back to kitchen if the customer pin is missing
const origin = dropoffCoords(anchor) || restaurantCoords(anchor);
GeoJSON order matters

Mongo and most map SDKs expect [longitude, latitude]. Mixing the axes once will “work” in tests near the equator and fail loudly in production maps. Keep one helper and never inline coordinates ad hoc.

Pickup-only orders exit early: there is nothing to batch on the road.

3. Haversine you can unit-test

For candidate scoring inside a few kilometers, spherical Haversine is enough. You do not need a routing engine to decide “is this dropoff inside 2.5 km?”

Distance helper

function haversineKm(lat1, lng1, lat2, lng2) {
  const toRad = (d) => (d * Math.PI) / 180;
  const R = 6371; // Earth radius in km
  const dLat = toRad(lat2 - lat1);
  const dLng = toRad(lng2 - lng1);
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

First test: same point ≈ 0. Second test: a known city pair within a few percent of ground truth. In Good Food Pro this helper also powers proof-of-delivery geofence checks (meters to dropoff), so one formula serves dispatch and completion integrity.

4. Configurable radius (and priority bonus)

Hard-coding 2 km in the service is how every city support ticket starts. Radius should live on delivery settings per restaurant (admin-tunable), with a sane default and a hard cap.

Radius resolution (pattern)

async function resolveBatchRadiusKm(restaurantId) {
  const setting = await DeliverySetting.findOne({ restaurant: restaurantId }).lean();
  const radius = Number(setting?.autoAssignmentRadius) || DEFAULT_BATCH_RADIUS_KM;
  return Math.max(0.5, Math.min(radius, 15));
}

// Optional: drivers with prioritySupport get a bonus, still capped at 15 km
if (benefits?.active && benefits?.prioritySupport) {
  radius = Math.min(15, radius + PRIORITY_BATCH_RADIUS_BONUS_KM);
}

5. Scoring a candidate pool

Query Mongo for a bounded pool, then score in memory. At marketplace volumes per city, a limit(40) pool is honest and easy to reason about.

Pool + score

const pool = await Order.find({
  _id: { $ne: anchor._id },
  'delivery.type': 'delivery',
  status: { $in: ['ready', 'out_for_delivery'] },
  $or: [
    { driver: null },
    { driver: { $exists: false } },
    ...(driverId ? [{ driver: driverId }] : []),
  ],
})
  .limit(40)
  .lean();

const scored = [];
for (const order of pool) {
  const point = dropoffCoords(order) || restaurantCoords(order);
  if (!point) continue;

  const distanceKm = haversineKm(origin.lat, origin.lng, point.lat, point.lng);
  if (distanceKm > radius) continue;

  const sameRestaurant =
    String(order.restaurant?._id || order.restaurant) ===
    String(anchor.restaurant?._id || anchor.restaurant);

  scored.push({ order, distanceKm: Number(distanceKm.toFixed(2)), sameRestaurant });
}

scored.sort((a, b) => {
  if (a.sameRestaurant !== b.sameRestaurant) return a.sameRestaurant ? -1 : 1;
  return a.distanceKm - b.distanceKm;
});

const candidates = scored.slice(0, Math.max(0, MAX_BATCH_SIZE - 1));

Why same restaurant first? One kitchen handoff beats zigzagging across brands for a marginal meter win. Distance is the tie-breaker inside that preference.

Expose this as a read API for suggestions:

HTTP

GET /api/logistics/orders/:orderId/batch-suggestions

→ {
  radiusKm: 3,
  candidates: [
    { _id, distanceKm, sameRestaurant, address, restaurant, status }
  ]
}

6. Accept one order, assign a batch

Suggestions are useless if accept still assigns a single id. The write path should:

  1. Re-run candidate discovery (never trust a stale client list alone).
  2. Create or reuse a batchId.
  3. updateMany the anchor + optional nearby orders: set driver, status out_for_delivery, shared batchId.
  4. Mark the driver on_delivery with currentOrder pointing at the anchor.

Accept with batching (pattern)

async function acceptOrderWithBatching({ orderId, driverId, includeNearby = true }) {
  const { anchor, candidates, radiusKm } = await findBatchCandidates({ orderId, driverId });
  const batchId = anchor.batchId || new ObjectId().toString();

  const toAssign = [
    anchor,
    ...(includeNearby ? candidates.map((c) => c.order) : []),
  ];
  const ids = toAssign.map((o) => o._id);

  await Order.updateMany(
    { _id: { $in: ids } },
    { $set: { driver: driverId, status: 'out_for_delivery', batchId } }
  );

  await Driver.findByIdAndUpdate(driverId, {
    currentOrder: orderId,
    status: 'on_delivery',
  });

  return {
    batchId,
    radiusKm,
    batchedCount: ids.length,
    nearbyAdded: Math.max(0, ids.length - 1),
  };
}

HTTP

POST /api/logistics/orders/:orderId/accept-batch
Body: { includeNearby: true }

→ { batchId, radiusKm, orders, batchedCount, nearbyAdded }
Idempotency mindset

If the anchor already has a batchId, reuse it. Couriers retry; your service should not mint a new batch identity on every double-tap.

7. Driver app: one tap, includeNearby

Product detail: the courier should not babysit a multi-select UI on a motorcycle. On accept, the driver app calls the batch endpoint with includeNearby: true by default, reloads active orders, and surfaces how many extra stops were attached.

React Native accept flow (pattern)

const result = await apiClient.acceptOrderBatch(orderId, {
  includeNearby: true,
});
await loadDriverOrders();

const extra = Number(result?.nearbyAdded || 0);
Alert.alert(
  'Success',
  extra > 0
    ? `Accepted with ${extra} nearby order(s) in the same batch`
    : 'Delivery accepted'
);

Active delivery UI can badge rows that share a batchId so the courier sees a multi-stop run, not three unrelated jobs.

8. When Redis GEO would enter the picture

Haversine-over-a-Mongo-pool is the right first system: debuggable, testable, zero extra infra. Reach for Redis GEOSEARCH when you need continuous driver presence queries at high write rates (thousands of location pings/sec) — for example “online drivers within 3 km of this restaurant right now.”

That is a different problem (live fleet index) than this article’s problem (cluster ready orders around an accept). Mixing them early usually means you operate Redis before you have proven the batching product rules.

Design takeaway

Ship the product rule first: same-restaurant preference, radius from admin settings, max batch size, priority bonus. Storage engines are interchangeable; bad scoring is not.

What to verify in a demo

  1. Create two ready delivery orders with dropoffs a few hundred meters apart, same restaurant.
  2. Accept from the driver app — response should show nearbyAdded ≥ 1 and a shared batchId.
  3. Move one dropoff outside the configured radius — it should disappear from suggestions.
  4. Toggle a priority driver benefit — radius should widen within the cap.

You can exercise the loop on the live demos (driver + admin delivery settings) and dig deeper in the logistics feature overview.

This article describes the batching architecture as implemented in Good Food Pro’s Node.js logistics service and driver client — teaching patterns, not a paste of the private source tree.