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.

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.
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.FOR UPDATE SKIP LOCKED, reads their positions from Redis, picks the nearest and marks both rows before committing.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.

Run in your terminal to get started locally.
-- 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.
haversineKm with travel times from a routing engine for the top few candidates.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.
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.
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.
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.