Limited time: AI code review, hints, mock interviews, whiteboard analysis, and all Pro features are unlocked. Enroll
⏱️ 21 min read

Designing a Real-Time Load Bidding Platform (Uber Freight)

Difficulty: Advanced Topics: Real-Time Auction, WebSocket, Event-Driven, Concurrency, Sorted Sets Asked at: Uber Freight, Convoy, Loadsmart, Amazon Logistics, Flexport Prerequisites: Message Queues, WebSockets, Caching, and Distributed Locking


1. Understanding the Problem

A load bidding platform is a real-time auction marketplace for freight. Shippers post loads (cargo that needs to move from A to B), and carriers (truck owners) compete by placing bids – offering a price and pickup time. All bidders see new bids appear live without refreshing. When the auction closes (time expires or shipper accepts a bid), exactly one carrier wins and gets the booking. The challenge is handling concurrent bids at scale while maintaining real-time fan-out and ensuring exactly-one-winner semantics at auction close.


2. Naive First Cut

flowchart LR
    Shipper["Shipper App"]:::client
    Carrier["Carrier App"]:::client
    API["API Server"]:::service
    DB["Postgres DB"]:::data

    Shipper --> API
    Carrier --> API
    API --> DB

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
Color Meaning
Purple Client apps
Green Backend services
Yellow Data stores

How this breaks:

The rest of the doc evolves this into a production-grade auction system.


3. Prior Art We’re Drawing From


4. Functional Requirements

Core (Top 3)

  1. Shipper posts a load for auction – origin, destination, weight, max budget, auction duration. Eligible carriers are notified.
  2. Carriers place bids in real-time – bid a price and estimated pickup time. All watchers see new bids appear live (< 500ms latency).
  3. Auction closes and winner is determined – when time expires or shipper accepts a bid, exactly one carrier wins. Booking is created atomically.

Below the Line


5. Non-Functional Requirements

Core

NFR Target
Bid-to-display latency < 500ms from bid placed to all watchers seeing it
Auction close consistency Exactly one winner – no double-award under any failure
Bid durability Zero bid loss even on service crash (financial audit requirement)
Concurrent bidders 500 bids/sec total, up to 200 carriers watching a single hot load
Availability 99.9% – carriers can always place bids

Below the Line

6. Scale Estimation (Back-of-Envelope)


7. Core Entities


8. API / System Interface

POST /api/v1/loads
  Body: { shipperId, originLat, originLng, destLat, destLng, weightTons, goodsType, maxPrice, auctionDurationMin }
  Response: { loadId, auctionEndsAt, status: "OPEN" }
  Auth: JWT Bearer (shipper)
  Note: Idempotent via X-Idempotency-Key header.

POST /api/v1/loads/{loadId}/bids
  Body: { carrierId, price, estimatedPickupTime }
  Response: { bidId, rank, currentBestPrice, status: "ACCEPTED" }
  Auth: JWT Bearer (carrier)
  Note: Validates auction is still open, price > 0, carrier is eligible.

DELETE /api/v1/loads/{loadId}/bids/{bidId}
  Response: { status: "WITHDRAWN" }
  Auth: JWT Bearer (carrier -- must own this bid)

POST /api/v1/loads/{loadId}/accept
  Body: { bidId }
  Response: { bookingId, winnerCarrierId, price }
  Auth: JWT Bearer (shipper -- must own this load)
  Note: Early acceptance -- closes auction immediately.

GET /api/v1/loads/{loadId}/bids
  Response: { bids: [{bidId, carrierId, price, rank, placedAt}], bestPrice, bidCount, auctionEndsAt }
  Auth: JWT Bearer (shipper or participating carrier)

WebSocket /ws/v1/loads/{loadId}/live
  Server pushes: { type: "NEW_BID", bidId, price, rank, bidCount }
  Server pushes: { type: "AUCTION_CLOSED", winnerId, winnerPrice }
  Auth: JWT ticket in connection params

9. High-Level Design

FR1: Shipper Posts a Load for Auction

Components introduced:

flowchart LR
    S["Shipper App"]:::client
    GW["API Gateway"]:::edge
    LS["Load Service"]:::service
    PG["Postgres"]:::data
    NS["Notification Service"]:::service
    KF["Kafka: load-events"]:::async

    S --> GW
    GW --> LS
    LS --> PG
    LS --> KF
    KF --> NS

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1a3652,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef async fill:#3b2a4c,stroke:#a78bfa,color:#e2e8f0

Flow:

  1. Shipper calls POST /loads with cargo details and auction duration
  2. Load Service validates, inserts load into Postgres with status=OPEN, auction_end = now + duration
  3. Publishes LOAD_POSTED event to Kafka topic load-events
  4. Notification Service consumes event, finds eligible carriers (geo + capacity match), sends push notifications
  5. Carriers open the load page, establish WebSocket connection to watch bids

FR2: Carriers Place Bids (the hot path)

New components:

flowchart LR
    C["Carrier App"]:::client
    GW["API Gateway"]:::edge
    BDS["Bid Service"]:::service
    KF["Kafka: bid-events"]:::async
    BP["Bid Processor"]:::service
    RD["Redis Sorted Set"]:::data
    PS["Redis Pub/Sub"]:::async
    WSS["WebSocket Service"]:::service
    Others["Other Carriers"]:::client

    C --> GW
    GW --> BDS
    BDS --> KF
    KF --> BP
    BP --> RD
    BP --> PS
    PS --> WSS
    WSS --> Others

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1a3652,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef async fill:#3b2a4c,stroke:#a78bfa,color:#e2e8f0

Flow:

  1. Carrier calls POST /loads/{loadId}/bids with price and estimated pickup time
  2. Bid Service validates: auction still open? carrier eligible? price within range?
  3. Publishes bid event to Kafka (key = loadId, ensures ordering within auction)
  4. Returns 200 immediately to carrier – β€œbid accepted” (note: not yet in leaderboard)
  5. Bid Processor consumes from Kafka, computes composite score
  6. Executes ZADD bids:{loadId} {score} {carrierId}:{bidId} – O(log N)
  7. Gets the new rank: ZRANK bids:{loadId} {carrierId}:{bidId}
  8. Publishes to Redis Pub/Sub channel live:load:{loadId} – {NEW_BID, price, rank, bidCount}
  9. WebSocket Service (subscribed to channel) pushes update to all connected carriers watching this load
  10. All watchers see the new bid appear in < 500ms

FR3: Auction Closes and Winner Determined

New components:

flowchart LR
    CRON["Auction Closer Cron"]:::service
    PG["Postgres"]:::data
    RD["Redis"]:::data
    BK["Booking Service"]:::service
    NS["Notification Service"]:::service
    KF["Kafka: load-events"]:::async

    CRON --> PG
    CRON --> RD
    CRON --> BK
    BK --> PG
    BK --> KF
    KF --> NS

    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef async fill:#3b2a4c,stroke:#a78bfa,color:#e2e8f0

Flow:

  1. Auction Closer cron runs every 10 seconds
  2. Queries Postgres: SELECT load_id FROM loads WHERE status='OPEN' AND auction_end < NOW()
  3. For each expired auction: fetch best bid from Redis ZRANGE bids:{loadId} 0 0
  4. If no bids exist: mark load as EXPIRED, notify shipper
  5. If bids exist: begin Postgres transaction
  6. UPDATE loads SET status='AWARDED', winner_carrier_id=?, winner_bid_id=?
  7. INSERT INTO bookings (load_id, carrier_id, bid_id, price, status) VALUES (...)
  8. Commit transaction
  9. Publish LOAD_AWARDED event to Kafka
  10. Notification Service: notify winner (β€œYou won!”) + notify losers (β€œAuction closed”)
  11. Publish AUCTION_CLOSED to Pub/Sub channel – WebSocket pushes to all watchers
  12. Cleanup: DEL bids:{loadId} from Redis (leaderboard no longer needed)

10. Technology Choices

Tier Purpose Stores Access Pattern Primary Alternatives
Bid Leaderboard (hot) Ranked bids per load carrier + price + timestamp Insert + get top + get rank Redis Sorted Set Postgres index (too slow under contention)
Event Bus Bid events and auction lifecycle All bid placements Append + consume + replay Kafka Kinesis, Redpanda
Auction DB Loads, auctions, bookings Relational with ACID CRUD by loadId, bookingId Postgres CockroachDB
Real-time Delivery Push new bids to watchers WebSocket messages Fan-out per load channel WebSocket + Redis Pub/Sub SSE, gRPC streaming
Cache Auction metadata, carrier profiles Frequently read data High-QPS reads with TTL Redis Memcached
Notification Service Alert carriers of new loads and results Push + email + SMS Async delivery Kafka consumer + FCM/Twilio SQS + workers

Why Redis Sorted Set for bid ranking, not Postgres ORDER BY? At 500 bids/sec with carriers constantly querying β€œwhat’s the current best bid” and β€œwhat’s my rank,” Postgres would be doing constant index scans on a hot table. Redis Sorted Set gives: ZADD O(log N) for new bids, ZRANGE 0 0 O(1) for best bid, ZRANK O(log N) for carrier’s position. All in-memory, sub-millisecond. We still persist to Postgres via Kafka for durability – Redis is the hot read layer.

Why Kafka between Bid Service and Redis? Bid placement is financially significant – losing a bid is unacceptable. If Bid Service writes directly to Redis and Redis crashes, bids vanish. Kafka guarantees: once published, the bid is durable (replicated to 3 brokers). Consumer rebuilds Redis from Kafka on failure. Also provides a complete audit trail required for dispute resolution.

Why WebSocket over Server-Sent Events (SSE)? Carriers both receive updates (new bids from others) AND send actions (place their own bid). WebSocket is full-duplex – single connection handles both. SSE is one-directional (server-to-client only), requiring a separate HTTP endpoint for bid submission. WebSocket consolidates both into one connection, reducing overhead.


11. Data Modeling

Redis Sorted Set (Bid Leaderboard – per load):

Key: "bids:{loadId}" -> Sorted Set
  Member: "{carrierId}:{bidId}"
  Score: composite_score (lower is better)
    Simple: score = price
    Advanced: score = 0.7*price + 0.2*distance_penalty + 0.1*(1/carrier_rating)

Key: "auction:{loadId}" -> Hash
  { status, endsAt, bidCount, bestPrice, bestCarrierId, shipperId }
  TTL: none (cleaned up by Auction Closer after completion)

Key: "carrier:bids:{carrierId}" -> Set
  Members: [loadId1, loadId2, ...]  (loads this carrier has active bids on)

Postgres (Auction DB):

CREATE TABLE loads (
    load_id UUID PRIMARY KEY,
    shipper_id UUID NOT NULL,
    origin_lat DECIMAL(10,7),
    origin_lng DECIMAL(10,7),
    dest_lat DECIMAL(10,7),
    dest_lng DECIMAL(10,7),
    weight_tons DECIMAL(6,2),
    goods_type VARCHAR(50),
    max_price DECIMAL(10,2),
    auction_start TIMESTAMP NOT NULL,
    auction_end TIMESTAMP NOT NULL,
    status VARCHAR(20) NOT NULL,  -- OPEN, BIDDING, AWARDED, EXPIRED, CANCELLED
    winner_carrier_id UUID,
    winner_bid_id UUID,
    created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_loads_status ON loads(status, auction_end);
CREATE INDEX idx_loads_shipper ON loads(shipper_id, created_at DESC);

CREATE TABLE bids (
    bid_id UUID PRIMARY KEY,
    load_id UUID NOT NULL REFERENCES loads(load_id),
    carrier_id UUID NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    estimated_pickup_time TIMESTAMP,
    status VARCHAR(20) NOT NULL,  -- ACTIVE, OUTBID, WON, LOST, WITHDRAWN
    placed_at TIMESTAMP NOT NULL DEFAULT NOW(),
    composite_score DECIMAL(10,4)
);
CREATE INDEX idx_bids_load ON bids(load_id, composite_score ASC);
CREATE INDEX idx_bids_carrier ON bids(carrier_id, placed_at DESC);

CREATE TABLE bookings (
    booking_id UUID PRIMARY KEY,
    load_id UUID NOT NULL UNIQUE,  -- one booking per load
    carrier_id UUID NOT NULL,
    bid_id UUID NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    status VARCHAR(20) NOT NULL,  -- CONFIRMED, CANCELLED, COMPLETED
    created_at TIMESTAMP DEFAULT NOW()
);

Kafka Topics:

Topic: bid-events (partitioned by loadId -- ordering preserved per auction)
  Key: loadId
  Value: { bidId, loadId, carrierId, price, estimatedPickup, timestamp, eventType }
  EventTypes: BID_PLACED, BID_WITHDRAWN, AUCTION_CLOSED, WINNER_DETERMINED
  Retention: 30 days (audit trail)

Topic: load-events (partitioned by loadId)
  Key: loadId
  Value: { loadId, eventType, shipperId, details, timestamp }
  EventTypes: LOAD_POSTED, AUCTION_EXTENDED, LOAD_CANCELLED, LOAD_AWARDED
  Retention: 30 days

Access Patterns:

Query Data Source How
Place a bid Kafka (write) + Redis (read after consume) Publish to Kafka, consumer does ZADD bids:{loadId}
Get current best bid Redis ZRANGE bids:{loadId} 0 0 WITHSCORES
Get my rank Redis ZRANK bids:{loadId} "{carrierId}:{bidId}"
Get all bids for a load Redis (hot) or Postgres (historical) ZRANGE bids:{loadId} 0 -1 WITHSCORES
Close auction and pick winner Redis + Postgres Read best from Redis, write booking in Postgres (ACID)
Live bid updates Redis Pub/Sub Subscribe to channel live:load:{loadId}
Carrier’s active bids Redis Set SMEMBERS carrier:bids:{carrierId}
Audit trail Kafka + Postgres Replay Kafka topic or query bids table

12. Deep Dives

Deep Dive 1: Why Kafka Between Bid Service and Redis

Bad: Bid Service writes directly to Redis Sorted Set.

Good: Bid Service writes to Postgres first, then updates Redis.

Great: Bid Service publishes to Kafka, returns immediately. Consumer updates Redis.

Trade-off acknowledged: There’s a 100-200ms gap between β€œbid accepted” response and the bid appearing in the leaderboard (Kafka consumer lag). Carrier sees a brief β€œprocessing” state. Acceptable for freight (not a stock exchange). If needed: optimistic client-side update (show bid locally immediately, confirm when WebSocket echoes it back).

Deep Dive 2: Preventing Double-Award at Auction Close

Bad: Two Auction Closer instances run simultaneously, both see load L1 as expired, both create bookings.

Good: Use SELECT ... FOR UPDATE row lock in Postgres.

Great: Single-partition Kafka consumer + Postgres transaction with status check.

Deep Dive 3: WebSocket Scaling for 50K Connected Carriers

Bad: Single WebSocket server holds all 50K connections.

Good: Multiple WebSocket instances behind a load balancer. Each carrier connects to one instance.

Great: Redis Pub/Sub with per-load channels + sticky WebSocket routing.

Deep Dive 4: Anti-Sniping and Auction Fairness

Bad: Carrier places a bid at T-1 second. Other carriers have no time to respond. Winner is always the last-second bidder.

Good: Fixed extension: if a bid arrives within the last 2 minutes, extend auction by 2 minutes.

Great: Good + bid floor + carrier commitment deposit.


13. Design Self-Audit

Potential weakness Assessment
Stale reads after bid placement? Carrier sees β€œaccepted” immediately. Leaderboard updates in 100-200ms. Acceptable – show β€œprocessing” indicator briefly.
Single point of failure? Redis: cluster with replicas. Kafka: 3 brokers, replication factor 2. Auction Closer: runs on 2+ instances but only one processes each loadId (Kafka partition guarantee).
Dead-letter for failed bids? Kafka consumer retries 3x. On persistent failure: send to DLQ topic. Alert ops. Carrier is notified β€œbid processing failed – please retry.”
Data freshness between Redis and Postgres? Redis is the hot layer (leaderboard). Postgres is cold (audit/history). Bid Processor writes both. On Redis crash: rebuild from Kafka. Postgres is always eventually consistent (< 5s lag).
Cost at scale? Redis: 20K sorted sets x 50 bids avg x 100 bytes = 100MB. Trivial. Kafka: 500 msg/sec x 200 bytes x 30 days retention = ~250GB. Moderate.
What if Kafka consumer lags? Bids pile up, leaderboard becomes stale. Monitor consumer lag. Scale consumers horizontally (more partitions). Alert if lag > 2 seconds.

14. Core Flows

Bidding Flow (end-to-end)

sequenceDiagram
    participant C as Carrier
    participant GW as API Gateway
    participant BDS as Bid Service
    participant KF as Kafka
    participant BP as Bid Processor
    participant RD as Redis
    participant PS as Pub/Sub
    participant WSS as WebSocket Service
    participant W as Watching Carriers

    C->>GW: POST /loads/L1/bids {price: 5000}
    GW->>BDS: Validate auction open
    BDS->>KF: Publish {BID_PLACED, L1, carrier7, 5000}
    BDS-->>C: 200 {bidId: B42, status: ACCEPTED}
    KF->>BP: Consume bid event
    BP->>RD: ZADD bids:L1 5000 carrier7:B42
    BP->>RD: ZRANK bids:L1 carrier7:B42
    RD-->>BP: rank = 2
    BP->>PS: PUBLISH live:load:L1 {NEW_BID, price=5000, rank=2}
    PS->>WSS: Forward
    WSS->>W: WebSocket push to all watchers
    Note over W: See "New bid: 5000 - Rank 2" appear live

Auction Close Flow

sequenceDiagram
    participant CRON as Auction Closer
    participant PG as Postgres
    participant RD as Redis
    participant KF as Kafka
    participant NS as Notification
    participant PS as Pub/Sub
    participant WSS as WebSocket
    participant All as All Carriers

    CRON->>PG: SELECT expired auctions
    PG-->>CRON: [load L1 expired]
    CRON->>RD: ZRANGE bids:L1 0 0 (best bid)
    RD-->>CRON: carrier3:B12 score=4500
    CRON->>PG: BEGIN TX
    CRON->>PG: UPDATE loads SET status=AWARDED winner=carrier3
    CRON->>PG: INSERT booking (L1, carrier3, 4500)
    CRON->>PG: COMMIT
    CRON->>KF: Publish LOAD_AWARDED event
    KF->>NS: Notify winner + losers
    CRON->>PS: PUBLISH live:load:L1 {AUCTION_CLOSED, winner=carrier3}
    PS->>WSS: Forward
    WSS->>All: Push final result
    CRON->>RD: DEL bids:L1 (cleanup)

Auction Lifecycle State Machine

OPEN --> BIDDING --> AWARDED --> BOOKING_CONFIRMED
  |         |           
  v         v           
CANCELLED  EXPIRED (no bids)

15. Final Architecture

flowchart TB
    SA["Shipper App"]:::client
    CA["Carrier App"]:::client
    GW["API Gateway"]:::edge

    LS["Load Service"]:::service
    BDS["Bid Service"]:::service
    BP["Bid Processor"]:::service
    AC["Auction Closer"]:::service
    BK["Booking Service"]:::service
    WSS["WebSocket Service"]:::service
    NS["Notification Service"]:::service

    KF["Kafka"]:::async
    PS["Redis Pub/Sub"]:::async
    PG["Postgres"]:::data
    RD["Redis Sorted Sets + Cache"]:::data

    SA --> GW
    CA --> GW
    CA <--> WSS

    GW --> LS
    GW --> BDS

    LS --> PG
    LS --> KF

    BDS --> KF
    KF --> BP
    KF --> NS
    BP --> RD
    BP --> PS
    PS --> WSS

    AC --> PG
    AC --> RD
    AC --> BK
    BK --> PG

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1a3652,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef async fill:#3b2a4c,stroke:#a78bfa,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
Color Component Type
Purple Clients
Blue Edge (API Gateway)
Green Backend Services
Dark Purple Async (Kafka, Pub/Sub)
Yellow Data Stores

Discussion

Newest first
You

Free system design + DSA prep. If it helped you crack an interview, consider supporting.

SensAI SensAI
Beta
Listening...
Tap mic to stop voice mode

Shape what we build next

Every piece of feedback is read by the team and directly influences our roadmap.

What type of feedback?

Install SystemCraft

Add to your home screen for instant access, offline reading, and a distraction-free experience.

Offline reading Faster loads No browser tabs App-like feel

Unlock AI Features

One click to activate - no payment, no credit card. Just sign in and you're in.

AI code review and hints
SensAI chat assistant
AI mock interviews
Whiteboard analysis
100% free during early access