Nov 11, 20268 min read

Last-Mile Delivery Dispatch Backend: Assigning Drivers Without Double Booking

Design the dispatch backend for last-mile delivery: driver location pings in Redis, nearest-driver offers that expire, transactional assignment so no driver or parcel is double booked, and a sweep that redispatches, built by hand on AWS or in Go with Encore.

Illustration for Last-Mile Delivery Dispatch Backend: Assigning Drivers Without Double Booking

Dispatch is where a delivery business wins or loses minutes. A parcel waiting for a driver is a late delivery, and a parcel offered to two drivers at once is a driver who drives across town for nothing. The backend has to know where drivers are right now, pick a good one quickly, give the driver a short window to accept, and move on when they do not, while several dispatch workers run at the same time.

This guide designs that backend: location pings in Redis, nearest-driver offers that expire, row locks that prevent double booking and a sweep that redispatches every minute, first by hand on AWS and then in Go with Encore. Quiqup built its logistics platform, which connects order sources, fulfillment centers and delivery partners, on Encore, and Pallet runs an event-driven logistics platform on GCP with Encore.

The dispatch backend at a glance

JobHand-built on AWSWith Encore in your AWS account
Deliveries and driversRDS for PostgreSQLEncore database on RDS
Live driver positionsElastiCache for RedisEncore cache cluster on ElastiCache
New delivery eventsSQS queue and a workerTopic and subscription (SNS and SQS)
Redispatch sweepEventBridge Scheduler and a taskCron job
Local developmentDocker Compose with Postgres and Redisencore run

How do you build it by hand on AWS?

  1. Data: a drivers table with zone and status (off, available, busy), and a deliveries table with pickup coordinates, status, the offered driver, an offer expiry and the drivers who let the offer lapse.
  2. Positions: the driver app pings every few seconds, and each ping overwrites a Redis key per driver with a two-minute TTL, so a driver whose app died disappears from dispatch without a cleanup job.
  3. Offers: creating a delivery sends an SQS message, and a worker opens a transaction, locks the delivery and the zone's available drivers with FOR UPDATE SKIP LOCKED, reads their positions from Redis, picks the nearest and marks both rows before committing.
  4. Sweep: an EventBridge Scheduler rule runs a task every minute to expire stale offers, free those drivers and retry every pending delivery.
  5. Network and access: Redis and Postgres sit in private subnets, the worker and the API each need security group rules to reach them, and IAM roles to use the queue.

How do you build it with Encore?

Encore

Encore provisions the infrastructure a Go or TypeScript service declares in its code, in your own AWS or GCP account. Here one dispatch service declares Postgres, a Redis cluster, a topic and a schedule, and on AWS the deploy creates RDS, ElastiCache with Multi-AZ replication, and SNS and SQS, according to the AWS infrastructure docs. The schedule runs on the Encore platform without a cloud resource.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create fleet --example=hello-world$ cd fleet && mkdir -p dispatch/migrations
-- dispatch/migrations/1_create_dispatch.up.sql CREATE TABLE drivers ( id TEXT PRIMARY KEY, zone TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'off' -- off, available, busy ); CREATE TABLE deliveries ( id BIGSERIAL PRIMARY KEY, zone TEXT NOT NULL, pickup_lat DOUBLE PRECISION NOT NULL, pickup_lng DOUBLE PRECISION NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending, offered, accepted, delivered driver_id TEXT REFERENCES drivers(id), offer_expires_at TIMESTAMPTZ, declined_by TEXT[] NOT NULL DEFAULT '{}' ); CREATE INDEX deliveries_open ON deliveries (status, offer_expires_at);
// dispatch/dispatch.go package dispatch import ( "context" "errors" "math" "time" "encore.dev/cron" "encore.dev/pubsub" "encore.dev/storage/cache" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("dispatch", sqldb.DatabaseConfig{Migrations: "./migrations"}) // Driver positions change every few seconds and only matter while fresh, // so they live in Redis with a short expiry instead of in Postgres. var positions = cache.NewCluster("positions", cache.ClusterConfig{ EvictionPolicy: cache.VolatileLRU, }) type Position struct { Lat, Lng float64 At time.Time } var lastPosition = cache.NewStructKeyspace[string, Position](positions, cache.KeyspaceConfig{ KeyPattern: "driver-pos/:key", DefaultExpiry: cache.ExpireIn(2 * time.Minute), }) // Ping (omitted) is POST /drivers/:driverID/ping and writes lastPosition. type DeliveryCreated struct { DeliveryID int64 } var Created = pubsub.NewTopic[*DeliveryCreated]("delivery-created", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, }) type CreateParams struct { Zone string PickupLat, PickupLng float64 } type CreateResponse struct { ID int64 } // Create stores a delivery and hands it to the dispatcher. // //encore:api private method=POST path=/deliveries func Create(ctx context.Context, p *CreateParams) (*CreateResponse, error) { var id int64 err := db.QueryRow(ctx, ` INSERT INTO deliveries (zone, pickup_lat, pickup_lng) VALUES ($1, $2, $3) RETURNING id `, p.Zone, p.PickupLat, p.PickupLng).Scan(&id) if err != nil { return nil, err } if _, err := Created.Publish(ctx, &DeliveryCreated{DeliveryID: id}); err != nil { return nil, err // the row stays pending and the minute sweep offers it } return &CreateResponse{ID: id}, nil } var _ = pubsub.NewSubscription(Created, "offer-to-driver", pubsub.SubscriptionConfig[*DeliveryCreated]{ Handler: func(ctx context.Context, e *DeliveryCreated) error { return offer(ctx, e.DeliveryID) }, }) // offer picks the nearest available driver with a fresh position and offers them // the delivery for 60 seconds. Both updates happen in one transaction, so a driver // is never offered two deliveries and a delivery never goes to two drivers. func offer(ctx context.Context, deliveryID int64) error { tx, err := db.Begin(ctx) if err != nil { return err } defer tx.Rollback() var zone string var lat, lng float64 var declined []string err = tx.QueryRow(ctx, ` SELECT zone, pickup_lat, pickup_lng, declined_by FROM deliveries WHERE id = $1 AND status = 'pending' FOR UPDATE SKIP LOCKED `, deliveryID).Scan(&zone, &lat, &lng, &declined) if errors.Is(err, sqldb.ErrNoRows) { return nil // already offered, accepted or being handled by another worker } else if err != nil { return err } rows, err := tx.Query(ctx, ` SELECT id FROM drivers WHERE zone = $1 AND status = 'available' AND NOT (id = ANY($2)) FOR UPDATE SKIP LOCKED `, zone, declined) if err != nil { return err } best, bestKm := "", math.MaxFloat64 for rows.Next() { var id string if err := rows.Scan(&id); err != nil { rows.Close() return err } pos, err := lastPosition.Get(ctx, id) if errors.Is(err, cache.Miss) { continue // no ping in the last two minutes: treat as offline } else if err != nil { rows.Close() return err } if km := haversineKm(lat, lng, pos.Lat, pos.Lng); km < bestKm { best, bestKm = id, km } } rows.Close() if best == "" { return nil // nobody free: the sweep below retries every minute } if _, err := tx.Exec(ctx, `UPDATE drivers SET status = 'busy' WHERE id = $1`, best); err != nil { return err } if _, err := tx.Exec(ctx, ` UPDATE deliveries SET status = 'offered', driver_id = $1, offer_expires_at = now() + interval '60 seconds' WHERE id = $2 `, best, deliveryID); err != nil { return err } // ... push notification to the driver app ... return tx.Commit() } // Redispatch expires unanswered offers, frees those drivers and calls offer // for every delivery still pending. var _ = cron.NewJob("redispatch", cron.JobConfig{ Title: "Expire offers and redispatch", Every: 1 * cron.Minute, Endpoint: Redispatch, }) //encore:api private func Redispatch(ctx context.Context) error { if _, err := db.Exec(ctx, ` WITH expired AS ( UPDATE deliveries SET status = 'pending', declined_by = array_append(declined_by, driver_id), driver_id = NULL, offer_expires_at = NULL WHERE status = 'offered' AND offer_expires_at < now() RETURNING declined_by[array_length(declined_by, 1)] AS driver_id ) UPDATE drivers SET status = 'available' WHERE id IN (SELECT driver_id FROM expired) `); err != nil { return err } rows, err := db.Query(ctx, `SELECT id FROM deliveries WHERE status = 'pending' ORDER BY id LIMIT 500`) if err != nil { return err } defer rows.Close() for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return err } if err := offer(ctx, id); err != nil { return err } } return nil } func haversineKm(lat1, lng1, lat2, lng2 float64) float64 { const r = 6371.0 dLat, dLng := (lat2-lat1)*math.Pi/180, (lng2-lng1)*math.Pi/180 a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLng/2)*math.Sin(dLng/2) return 2 * r * math.Asin(math.Sqrt(a)) }

The transaction in offer prevents double booking: SKIP LOCKED means two workers handling different deliveries never block on each other or pick the same free driver, and the driver's busy status and the delivery's offered status commit together. Accept is a conditional update, so a driver who taps accept after the 60 seconds gets an error instead of a delivery that was already offered to someone else.

The driver app posts a ping every few seconds that writes lastPosition for that driver. Positions use a struct keyspace with a two-minute default expiry, as described in the caching docs, and a cache miss means the driver is skipped. If Create fails to publish or the dispatcher finds nobody free, the delivery stays pending and the per-minute job picks it up. Cron jobs do not run locally or in preview environments, so in development you call Redispatch from the local development dashboard, and the cron jobs docs cover schedules in the cloud.

encore run starts Postgres and Redis locally and runs the topic, so a full create, offer, expire and redispatch cycle can be traced on a laptop. Encore provisions an IAM role for the service, or a service account on GCP, and the Pub/Sub docs cover retries and dead letters for the subscription.

Best fit: Logistics and delivery teams who want dispatch, positions and the redispatch schedule in one Go service running in their own AWS or GCP account.

Things to decide early

  • Distance. Straight-line distance ignores rivers and one-way streets, so once dispatch quality matters, replace haversineKm with travel times from a routing engine for the top few candidates.
  • Batching. Offering one delivery at a time is simple and fair. Peak hours may need batching several drops for one driver, which turns dispatch into a small optimization problem run per zone every few seconds.
  • Driver fairness. Nearest-first sends most work to drivers who stay near busy pickups. Track recent assignments and add them to the score if that becomes a problem.
  • Zones. The zone column keeps queries small, and large cities need zones small enough that the candidate list stays in the tens.

Frequently asked questions

How do I stop two dispatchers assigning the same driver?

Lock the delivery and the candidate drivers inside one database transaction with SELECT ... FOR UPDATE SKIP LOCKED, mark the chosen driver busy and the delivery offered, then commit. A second dispatcher running at the same moment skips the locked rows instead of reading the same free driver.

Where should driver locations be stored?

In a fast key-value store such as Redis, one key per driver with an expiry of a minute or two. Positions arrive every few seconds and are only useful while fresh, so writing them to the main database adds load for data you will overwrite seconds later, and the expiry turns a silent driver into an offline one automatically.

What happens when a driver ignores a delivery offer?

Give each offer an expiry, such as 60 seconds, and only accept it while the expiry is in the future. A scheduled sweep returns expired offers to pending, records the driver as having declined, frees the driver and offers the delivery to the next nearest candidate.

How do I build a dispatch backend in Go with Encore?

Declare the dispatch database with sqldb.NewDatabase, a Redis cache cluster and keyspace for positions with cache.NewCluster, a topic for new deliveries with pubsub.NewTopic and the redispatch sweep with cron.NewJob. Encore provisions Postgres, Redis and the queue in your own AWS or GCP account.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders