Get a quote

Building a Real-Time Delivery and Fleet Tracking System with Go and WebSockets

Logistics companies, restaurant chains, and service businesses across Lebanon and MENA face the same operational challenge: drivers in the field, customers waiting for updates, and managers who need real-time visibility. Third-party fleet tracking platforms solve this at enterprise prices with limited customization. Building a purpose-built tracking system in Go gives you full control over logic, costs, and multi-tenant data isolation.

Logistics companies, restaurant chains, and service businesses across Lebanon and MENA face the same operational challenge: drivers in the field, customers waiting for updates, and managers who need real-time visibility. Third-party fleet tracking platforms solve this at enterprise prices with limited customization. Building a purpose-built tracking system in Go gives you full control over logic, costs, and multi-tenant data isolation.

What a production fleet tracking system actually needs

The visible part of fleet tracking is straightforward: push GPS coordinates from a mobile app to a server and show them on a map. The non-obvious engineering problems are what separate a demo from a production system:

  • Drivers disconnect and reconnect frequently. The system must maintain last-known position across reconnections without losing data.
  • Location history must be stored efficiently. A fleet of 200 drivers each sending updates every 8 seconds generates significant write volume.
  • Dashboard subscribers must receive updates without blocking the ingestion path. One slow browser tab must not delay updates for everyone else.
  • In multi-tenant deployments, Company A must never see Company B's driver positions.
  • ETA calculations need to be accurate enough to be useful under variable traffic conditions in cities like Beirut, Riyadh, and Cairo.

Go handles the concurrency requirements here better than most alternatives. Each active driver connection is a goroutine. Each dashboard subscriber is a goroutine. The Go scheduler manages thousands of these with minimal memory overhead.

Architecture: three layers with clean boundaries

A fleet tracking backend separates into three layers that scale independently:

Ingestion layer: accepts location updates from mobile clients over WebSocket or HTTP POST. Validates, authenticates, and forwards to the hub. Optimized for write throughput.

Distribution hub: maintains a registry of active drivers and dashboard subscribers, routes updates to the correct tenant's subscribers, and caches last-known positions in Redis.

Persistence layer: writes location history to PostgreSQL using buffered batch inserts that reduce per-update write overhead by 80 to 90 percent compared to individual inserts.

type LocationUpdate struct {
    DriverID  string  `json:"driver_id"`
    TenantID  string  `json:"tenant_id"`
    Lat       float64 `json:"lat"`
    Lng       float64 `json:"lng"`
    Speed     float64 `json:"speed"`
    Heading   float64 `json:"heading"`
    Timestamp int64   `json:"timestamp"`
}

type TrackingHub struct {
    mu          sync.RWMutex
    subscribers map[string][]chan LocationUpdate // keyed by tenantID
    lastKnown   map[string]LocationUpdate        // keyed by driverID
}

func (h *TrackingHub) Broadcast(update LocationUpdate) {
    h.mu.Lock()
    h.lastKnown[update.DriverID] = update
    h.mu.Unlock()

    h.mu.RLock()
    subs := h.subscribers[update.TenantID]
    h.mu.RUnlock()

    for _, ch := range subs {
        select {
        case ch <- update:
        default:
            // subscriber is slow, skip this update rather than block ingestion
        }
    }
}

The non-blocking send (select default) is critical. A slow dashboard client must never block location updates from reaching other subscribers or slow down driver ingestion.

PostgreSQL schema for movement history

Storing every location update in a single table becomes a performance and maintenance problem at scale. A partitioned table organized by month is the production-safe approach:

CREATE TABLE driver_locations (
    id          BIGSERIAL,
    driver_id   UUID NOT NULL,
    tenant_id   UUID NOT NULL,
    lat         DOUBLE PRECISION NOT NULL,
    lng         DOUBLE PRECISION NOT NULL,
    speed       REAL,
    heading     REAL,
    recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (recorded_at);

CREATE TABLE driver_locations_2026_06
    PARTITION OF driver_locations
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

CREATE INDEX ON driver_locations (driver_id, recorded_at DESC);
CREATE INDEX ON driver_locations (tenant_id, recorded_at DESC);

Monthly partitions mean that dropping old data is a DROP TABLE on a single partition rather than a large DELETE that locks the table.

Buffered batch writes for high-frequency location data

With 200 active drivers each sending a location update every 8 seconds, you have 25 writes per second. That is manageable but grows quickly. Buffered batch writes reduce this to a fraction of the individual write cost:

type LocationBuffer struct {
    mu      sync.Mutex
    pending []LocationUpdate
}

func (b *LocationBuffer) Add(update LocationUpdate) {
    b.mu.Lock()
    b.pending = append(b.pending, update)
    b.mu.Unlock()
}

func (b *LocationBuffer) Flush(ctx context.Context, db *pgxpool.Pool) error {
    b.mu.Lock()
    batch := b.pending
    b.pending = b.pending[:0]
    b.mu.Unlock()

    if len(batch) == 0 {
        return nil
    }
    return bulkInsertLocations(ctx, db, batch)
}

A ticker runs Flush every 5 seconds. 200 drivers generating 1,600 updates over 5 seconds become one batch insert rather than 1,600 individual round trips.

Fast last-known position with Redis

Dashboard users need the current position of every active driver the moment they open the interface, without waiting for the next WebSocket update. Redis holds the last-known position of every driver with a TTL:

func (s *TrackingService) SetDriverLocation(ctx context.Context, update LocationUpdate) error {
    data, err := json.Marshal(update)
    if err != nil {
        return err
    }
    key := fmt.Sprintf("driver:loc:%s", update.DriverID)
    return s.redis.Set(ctx, key, data, 2*time.Hour).Err()
}

func (s *TrackingService) GetDriverLocation(ctx context.Context, driverID string) (*LocationUpdate, error) {
    key := fmt.Sprintf("driver:loc:%s", driverID)
    data, err := s.redis.Get(ctx, key).Bytes()
    if err == redis.Nil {
        // Fall back to PostgreSQL for recent history
        return s.db.GetLastDriverLocation(ctx, driverID)
    }
    if err != nil {
        return nil, err
    }
    var loc LocationUpdate
    return &loc, json.Unmarshal(data, &loc)
}

Redis handles latency-sensitive reads. PostgreSQL handles durability and queryable movement history. The 2-hour TTL means drivers that go offline are naturally cleaned from the fast cache.

ETA calculation without external API costs

For applications where Google Maps API costs matter at scale, Haversine distance with average speed gives a useful approximation:

func haversineKm(lat1, lng1, lat2, lng2 float64) float64 {
    const R = 6371.0
    dLat := (lat2 - lat1) * math.Pi / 180
    dLng := (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 R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}

func estimateETA(driverLat, driverLng, destLat, destLng, avgSpeedKmh float64) time.Duration {
    distKm := haversineKm(driverLat, driverLng, destLat, destLng)
    hours := distKm / avgSpeedKmh
    return time.Duration(hours * float64(time.Hour))
}

For high-accuracy ETA requirements in city traffic conditions across MENA, HERE Maps and TomTom both offer regional traffic data at pricing suited for deployment-scale volumes.

Automatic monthly partition creation

Monthly partitions need to exist before data arrives. A scheduled job creates the next month's partition at the start of each month:

func (m *PartitionManager) EnsureNextMonthPartition(ctx context.Context) error {
    next := time.Now().AddDate(0, 1, 0)
    start := time.Date(next.Year(), next.Month(), 1, 0, 0, 0, 0, time.UTC)
    end := start.AddDate(0, 1, 0)
    tableName := fmt.Sprintf("driver_locations_%d_%02d", start.Year(), start.Month())
    _, err := m.db.Exec(ctx, fmt.Sprintf(`
        CREATE TABLE IF NOT EXISTS %s
            PARTITION OF driver_locations
            FOR VALUES FROM ('%s') TO ('%s')
    `, tableName, start.Format("2006-01-02"), end.Format("2006-01-02")))
    return err
}

Key lessons from production

The non-blocking broadcast pattern is non-negotiable. A single slow subscriber blocking the hub will cause visible lag for every other dashboard user in the same tenant.

Redis for last-known position and PostgreSQL for history is the correct architectural split. Do not try to serve real-time dashboard requests from PostgreSQL directly.

Design buffered batch writes before going to production. Adding them after the first load test under real fleet volume is significantly more disruptive than building them from the start.

Plan partition management from day one. Monthly partitions created automatically by a scheduled job are far more reliable than remembering to create them manually at the start of each month.

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Not sure where to start?

Voxire builds production operational systems for logistics, restaurant, and delivery companies across Lebanon and the MENA region. If you are designing a fleet tracking or real-time delivery operations platform, reach out at https://voxire.com/get-a-quote/

Back to blog
Chat on WhatsApp