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:
- Every bid writes to Postgres and then all other carriers must poll to see new bids β 500 bids/sec x 1000 watchers = 500K poll requests/sec
- Determining the current best bid requires
SELECT * FROM bids WHERE load_id=? ORDER BY price LIMIT 1on every view β index contention at write scale - When auction closes, two concurrent processes might both read the same βbest bidβ and create two bookings β double-award
- Carriers watching a load see bids with 5-10 second delay (polling interval) β in a competitive auction, stale data means unfair bidding
- No durability story β if the API server crashes between accepting a bid and writing to DB, the bid is lost permanently
- Carrier places a bid, refreshes, doesnβt see it yet β confusing UX due to replication lag
The rest of the doc evolves this into a production-grade auction system.
3. Prior Art Weβre Drawing From
- Uber Freight Carrier Marketplace β Scoring-based matching where carriers see available loads ranked by relevance. Loads can be βinstant bookβ (first-come) or auction-style where the best bid wins. (Uber Freight Engineering)
- Google Ad Exchange (AdX) β Real-time bidding at millisecond scale (100ms auction window). Uses in-memory bid aggregation and pub/sub for result delivery. The pattern of βcollect bids in a time window, pick winner, notify allβ is directly applicable to freight auctions. (Google Ad Manager Docs)
- eBay Auction Architecture β Pioneered anti-sniping (extend auction if late bid arrives), bid history fan-out to watchers, and optimistic concurrency on bid placement. (eBay Tech Blog)
- Redis Sorted Sets for Leaderboards β Gaming platforms use
ZADD/ZRANGEfor real-time leaderboards at 100K+ updates/sec. Same pattern applies to bid ranking where score = price. (Redis Best Practices) - Kafka Event Sourcing for Financial Systems β Stripe and Square use Kafka as the source of truth for financial events, enabling replay and audit trails. Bid events have similar durability requirements β you need a complete audit trail of who bid what and when.
4. Functional Requirements
Core (Top 3)
- Shipper posts a load for auction β origin, destination, weight, max budget, auction duration. Eligible carriers are notified.
- Carriers place bids in real-time β bid a price and estimated pickup time. All watchers see new bids appear live (< 500ms latency).
- 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
- Carrier can withdraw a bid before auction closes
- Shipper can set a βbuy nowβ price that instantly awards the load
- Anti-sniping: extend auction if bid arrives in last 2 minutes
- Bid history and audit trail
- Carrier reputation scoring factors into bid ranking
- Auto-bidding rules (carrier pre-sets: βbid X on any Delhi-Mumbai load under Y priceβ)
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
- 20K concurrent open auctions
- 50K connected carriers via WebSocket
- Bid placement < 200ms response time (acknowledgment)
- Auction results delivered within 5 seconds of close
6. Scale Estimation (Back-of-Envelope)
- Open auctions: 20K at any time
- Bid rate: 500 bids/sec peak (across all auctions)
- Hot auction: Popular route gets 100+ bids in 15 minutes (~0.1 bids/sec per load)
- WebSocket connections: 50K carriers, each watching 5-10 loads = up to 500K subscriptions
- Storage: Bids are small (~200 bytes each). 500/sec x 86400 = 43M bids/day x 200B = ~8.6GB/day
7. Core Entities
- Shipper β posts loads, sets auction parameters, accepts bids or lets timer decide.
- Carrier β bids on loads, has rating, truck capacity, region preferences.
- Load β cargo to move. Has origin, destination, weight, auction window, status.
- Bid β carrierβs offer on a load. Price, estimated pickup time, composite score, status.
- Auction β lifecycle wrapper around a loadβs bidding period. Start time, end time, current state.
- Booking β created when auction closes. Links winning carrier to load. Triggers payment flow.
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:
- Load Service β CRUD for loads, triggers auction creation
- Notification Service β alerts eligible carriers about new loads
- Postgres β source of truth for loads and auction metadata
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:
- Shipper calls
POST /loadswith cargo details and auction duration - Load Service validates, inserts load into Postgres with status=OPEN, auction_end = now + duration
- Publishes LOAD_POSTED event to Kafka topic
load-events - Notification Service consumes event, finds eligible carriers (geo + capacity match), sends push notifications
- Carriers open the load page, establish WebSocket connection to watch bids
FR2: Carriers Place Bids (the hot path)
New components:
- Bid Service β validates and publishes bids to Kafka
- Bid Processor β consumes from Kafka, updates Redis leaderboard, triggers Pub/Sub
- Redis Sorted Set β ranked bid leaderboard per load
- WebSocket Service β pushes live bid updates to all watchers
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:
- Carrier calls
POST /loads/{loadId}/bidswith price and estimated pickup time - Bid Service validates: auction still open? carrier eligible? price within range?
- Publishes bid event to Kafka (key = loadId, ensures ordering within auction)
- Returns 200 immediately to carrier β βbid acceptedβ (note: not yet in leaderboard)
- Bid Processor consumes from Kafka, computes composite score
- Executes
ZADD bids:{loadId} {score} {carrierId}:{bidId}β O(log N) - Gets the new rank:
ZRANK bids:{loadId} {carrierId}:{bidId} - Publishes to Redis Pub/Sub channel
live:load:{loadId}β{NEW_BID, price, rank, bidCount} - WebSocket Service (subscribed to channel) pushes update to all connected carriers watching this load
- All watchers see the new bid appear in < 500ms
FR3: Auction Closes and Winner Determined
New components:
- Auction Closer β cron job that finds expired auctions and awards them
- Booking Service β creates the final booking with ACID guarantees
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:
- Auction Closer cron runs every 10 seconds
- Queries Postgres:
SELECT load_id FROM loads WHERE status='OPEN' AND auction_end < NOW() - For each expired auction: fetch best bid from Redis
ZRANGE bids:{loadId} 0 0 - If no bids exist: mark load as EXPIRED, notify shipper
- If bids exist: begin Postgres transaction
UPDATE loads SET status='AWARDED', winner_carrier_id=?, winner_bid_id=?INSERT INTO bookings (load_id, carrier_id, bid_id, price, status) VALUES (...)- Commit transaction
- Publish LOAD_AWARDED event to Kafka
- Notification Service: notify winner (βYou won!β) + notify losers (βAuction closedβ)
- Publish AUCTION_CLOSED to Pub/Sub channel β WebSocket pushes to all watchers
- 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.
- If Redis crashes before replication, bids are permanently lost. In freight (financial context), lost bids mean legal disputes. Unacceptable.
- If Bid Service crashes after accepting the HTTP request but before Redis write β carrier thinks bid was placed, but it never was.
Good: Bid Service writes to Postgres first, then updates Redis.
- Durable. But synchronous DB write at 500 bids/sec means Postgres becomes the bottleneck. INSERT + NOTIFY per bid = high WAL pressure. Carrier sees 200ms+ response time.
Great: Bid Service publishes to Kafka, returns immediately. Consumer updates Redis.
- Carrier gets sub-50ms response (βbid acceptedβ). Kafka persists the bid durably (replicated to 3 brokers before ack).
- Bid Processor consumes and updates Redis leaderboard. If Redis dies: replay from Kafka offset 0 β rebuild entire leaderboard in seconds.
- Also writes to Postgres asynchronously (for long-term storage and queries). Redis is the hot layer, Postgres is the cold layer.
- Audit trail: Kafka retains all bid events for 30 days. Perfect for dispute resolution (βI placed a bid at 14:02:03 but wasnβt shown as winnerβ).
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.
- Result: two carriers both think they won. Operational disaster.
Good: Use SELECT ... FOR UPDATE row lock in Postgres.
SELECT * FROM loads WHERE load_id=? AND status='OPEN' FOR UPDATEβ locks the row. Second process blocks until first commits/rolls back.- Works but creates lock contention if many auctions close simultaneously.
Great: Single-partition Kafka consumer + Postgres transaction with status check.
- Auction Closer is a Kafka consumer on topic
auction-close-triggers(partitioned by loadId). - Only one consumer handles a given loadId at a time (Kafka partition = single consumer guarantee).
- Inside the handler: Postgres transaction with
UPDATE loads SET status='AWARDED' WHERE load_id=? AND status='OPEN'. If rowsAffected=0: already awarded, skip. - Even without the Kafka approach: the WHERE clause is sufficient. Two concurrent transactions: first one updates status, second oneβs WHERE clause finds no matching row. ACID prevents double-award.
Deep Dive 3: WebSocket Scaling for 50K Connected Carriers
Bad: Single WebSocket server holds all 50K connections.
- One server crash = all 50K carriers disconnected. Memory: each connection ~50KB = 2.5GB. Single point of failure.
Good: Multiple WebSocket instances behind a load balancer. Each carrier connects to one instance.
- Scale: 10 instances x 5K connections each. But problem: when Bid Processor publishes to Pub/Sub, ALL instances receive ALL messages. Each instance must filter for only its connected carriers. Wasteful at high volume.
Great: Redis Pub/Sub with per-load channels + sticky WebSocket routing.
- Each WebSocket instance subscribes only to channels for loads its connected carriers are watching.
- Carrier connects β instance subscribes to
live:load:{loadId}for each load the carrier is watching. - When Bid Processor publishes to
live:load:L1, only instances with carriers watching L1 receive it. Minimal fan-out waste. - Carrier disconnect: instance unsubscribes from channels with no remaining watchers.
- Instance crash: carriers reconnect to another instance (client-side retry). New instance subscribes to their channels. Miss at most a few seconds of updates β client fetches latest state via
GET /loads/{id}/bidson reconnect.
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.
- This is the classic eBay sniping problem. Ruins trust in the platform β carriers stop participating if they feel the auction is unfair.
Good: Fixed extension: if a bid arrives within the last 2 minutes, extend auction by 2 minutes.
- Simple rule in Bid Processor:
IF bid.timestamp > auction_end - 2min THEN UPDATE loads SET auction_end = auction_end + 2min. - Prevents sniping. Auction naturally converges when no one bids in the extension window.
- Cap extensions: max 3 extensions (prevents infinite auctions on a highly competitive load).
Great: Good + bid floor + carrier commitment deposit.
- Bid floor: shipper sets minimum acceptable price. Bids below are rejected.
- Commitment deposit: carrier must have a verified payment method. If winner cancels after award: penalty fee + reputation impact.
- Progressive reveal: show bid count and best price but NOT bidder identity. Prevents targeted sniping against specific competitors.
- Cool-down: after placing a bid, carrier must wait 30 seconds before bidding again on the same load. Prevents bid-spamming.
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