Designing a Real-Time Order Tracking and Shipper Location System
1. Overview
When ordering food or arranging delivery, customers want to know what state their order is in and where the shipper is on the map. This article designs such a system from requirements to code. It walks through data modeling, APIs, processing flows, and then tackles three hard problems: concurrency when claiming orders, data consistency, and load spikes.
By the end, you’ll have a reference architecture and Spring Boot 3.x code snippets to start implementing.
2. Requirements
2.1. Functional Requirements
- Shippers periodically send GPS location from the app.
- Customers view order status and shipper location nearly in real-time, with latency under 3 seconds.
- Shippers claim orders. Each order must be assigned to exactly one shipper.
- Order status follows a lifecycle:
PENDING → ASSIGNED → PICKED_UP → DELIVERING → DELIVERED. From some states, it can also transition toCANCELLED. - Store status history for reconciliation and dispute resolution.
2.2. Scale Estimation
| Metric | Value |
|---|---|
| Peak hour online shippers | 50,000 |
| Location send frequency | 4 seconds |
| Location writes | ~12,500 msg/s |
| Customers viewing tracking screen | ~200,000 connections |
| New orders at peak hour | ~300 orders/s |
2.3. Constraints
- Location is temporary data. Losing a few points is acceptable, but old locations must never overwrite newer ones.
- Order status is critical data. It must never be lost and must maintain order.
- Mobile networks are flaky, so the app will retry and batch sends.
This leads to a key decision: separate the two streams. The location stream needs high throughput and accepts data loss. The status stream needs transactions and durability.
3. Overall Architecture
App shipper ──HTTP batch──> Location API ──> Kafka [shipper-locations]
│
▼
Location Consumer ──> Redis (latest location, GEO)
│
└──> Redis Pub/Sub [order:{id}:loc]
│
App customer <──────── SSE ──────── Tracking Gateway <────────────┘
App shipper ──HTTP──> Order Service ──> PostgreSQL (orders, order_events, outbox)
│
└──> Outbox relay ──> Kafka [order-events]
Each component has a single responsibility:
- Kafka absorbs location write load and preserves order per shipper by using
shipperIdas the key. - Redis stores only the latest location for each shipper. The system does not write every GPS point to the relational database.
- PostgreSQL is the source of truth for order status.
- SSE pushes data one-way from server to client. SSE is simpler than WebSocket and sufficient for this use case.
4. Data Model
4.1. PostgreSQL for Orders
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
shipper_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_events (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
from_status VARCHAR(20),
to_status VARCHAR(20) NOT NULL,
actor_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_outbox_unpublished ON outbox(id) WHERE published = false;
The version column serves optimistic locking. The order_events table is append-only history. The outbox table ensures events are reliably published to Kafka. Section 7 will explain in detail.
4.2. Redis for Location
shipper:loc:{shipperId} HASH {lat, lng, ts} TTL 120s
shippers:geo:{city} GEO member = shipperId
shipper:active-order:{shipperId} STRING orderId
A 120-second TTL lets disconnected shippers automatically disappear from the system. You don’t need a separate cleanup job. GEO keys are partitioned by city, so GEOSEARCH when finding the nearest shipper doesn’t need to scan the entire country.
5. API
POST /api/v1/shippers/{shipperId}/locations body: [ {lat,lng,ts}, ... ] -> 202
POST /api/v1/orders/{orderId}/claim -> 200 | 409
POST /api/v1/orders/{orderId}/transitions body: {to: "PICKED_UP"} -> 200 | 409
GET /api/v1/orders/{orderId} -> status + current location
GET /api/v1/orders/{orderId}/track -> text/event-stream
The location API returns 202 Accepted because it only pushes data to Kafka then replies. This API accepts batch data, letting the app collect points from network outages and send them at once.
6. Location Processing Flow
You need the following dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
Configure JSON serializer for Kafka:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: 1
consumer:
group-id: location-consumer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "com.nonla.tracking"
The value acks: 1 is an intentional choice. Losing a few GPS points when a broker leader dies is acceptable in exchange for lower write latency compared to acks: all.
6.1. Receiving Location
public record LocationUpdate(long shipperId, double lat, double lng, long ts) {}
@RestController
@RequestMapping("/api/v1/shippers")
public class LocationController {
private final KafkaTemplate<String, LocationUpdate> kafka;
public LocationController(KafkaTemplate<String, LocationUpdate> kafka) {
this.kafka = kafka;
}
@PostMapping("/{shipperId}/locations")
public ResponseEntity<Void> push(@PathVariable long shipperId,
@RequestBody List<LocationUpdate> batch) {
batch.stream()
.filter(u -> u.shipperId() == shipperId)
.forEach(u -> kafka.send("shipper-locations", String.valueOf(shipperId), u));
return ResponseEntity.accepted().build();
}
}
Two things to note:
- The key is
shipperId. All points from the same shipper fall into the same partition, so the consumer processes them in send order. - The
filterremoves points withshipperIddifferent from the path. In practice, extractshipperIdfrom JWT instead of trusting the body.
6.2. Write Newest Location, Discard Old Points
Partition ordering is not enough. The app might resend an old batch after getting signal back, arriving after a newer point. So the consumer must compare ts and write only if newer. The comparison and write must be atomic, so we use a Lua script:
@Component
public class LocationConsumer {
private static final RedisScript<Long> UPSERT_IF_NEWER = RedisScript.of("""
local cur = redis.call('HGET', KEYS[1], 'ts')
if cur and tonumber(cur) >= tonumber(ARGV[3]) then return 0 end
redis.call('HSET', KEYS[1], 'lat', ARGV[1], 'lng', ARGV[2], 'ts', ARGV[3])
redis.call('EXPIRE', KEYS[1], 120)
return 1
""", Long.class);
private final StringRedisTemplate redis;
public LocationConsumer(StringRedisTemplate redis) {
this.redis = redis;
}
@KafkaListener(topics = "shipper-locations", concurrency = "6")
public void onLocation(LocationUpdate u) {
Long updated = redis.execute(UPSERT_IF_NEWER,
List.of("shipper:loc:" + u.shipperId()),
String.valueOf(u.lat()), String.valueOf(u.lng()), String.valueOf(u.ts()));
if (updated == null || updated == 0) return; // stale point, skip
redis.opsForGeo().add("shippers:geo:hcm", new Point(u.lng(), u.lat()),
String.valueOf(u.shipperId()));
String orderId = redis.opsForValue().get("shipper:active-order:" + u.shipperId());
if (orderId != null) {
redis.convertAndSend("order:" + orderId + ":loc",
"{\"lat\":%s,\"lng\":%s,\"ts\":%d}".formatted(u.lat(), u.lng(), u.ts()));
}
}
}
The processing flow is:
- The script returns
0if the point is older or equal to the stored point. The consumer stops, skips updating GEO, and doesn’t push to the customer. - The
GEOADDcommand takes arguments in order of longitude first, latitude second. This is a very common mistake. - The system only publishes when the shipper has an order. Idle shippers don’t create fan-out traffic.
Note: The script above touches only one key, so it runs on Redis Cluster. If you want to include
GEOADDin the script, keys must be in the same slot. Then use hash tags like{hcm}:loc:42and{hcm}:geo.
6.3. Push to Customer via SSE
@RestController
public class TrackingController implements MessageListener {
private final Map<String, Set<SseEmitter>> subscribers = new ConcurrentHashMap<>();
@GetMapping(value = "/api/v1/orders/{orderId}/track",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter track(@PathVariable String orderId) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
Set<SseEmitter> set = subscribers.computeIfAbsent(orderId, k -> ConcurrentHashMap.newKeySet());
set.add(emitter);
Runnable cleanup = () -> set.remove(emitter);
emitter.onCompletion(cleanup);
emitter.onTimeout(cleanup);
emitter.onError(e -> cleanup.run());
return emitter;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel()); // order:123:loc
String orderId = channel.split(":")[1];
String body = new String(message.getBody());
subscribers.getOrDefault(orderId, Set.of()).forEach(e -> {
try {
e.send(SseEmitter.event().name("location").data(body));
} catch (IOException ex) {
e.completeWithError(ex);
}
});
}
}
Register the listener with a pattern order:*:loc:
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory cf, TrackingController listener) {
var c = new RedisMessageListenerContainer();
c.setConnectionFactory(cf);
c.addMessageListener(listener, new PatternTopic("order:*:loc"));
return c;
}
Clients receive events like:
event:location
data:{"lat":10.7769,"lng":106.7009,"ts":1760342400000}
The gateway is stateless, so scale horizontally freely. Whichever instance holds the customer’s SSE connection receives the message via Pub/Sub and pushes it down. With 200,000 connections, enable virtual threads (spring.threads.virtual.enabled=true) or switch to WebFlux, so each connection doesn’t hold a platform thread.
7. Hard Problems and Solutions
7.1. Concurrency: Two Shippers Claiming the Same Order
New orders are shown to multiple nearby shippers. Two can click “Claim” at the same time. The wrong way is to read status, check PENDING, then write—there’s a gap between read and write. The right way is to use conditional UPDATE:
@Service
public class OrderClaimService {
private final JdbcTemplate jdbc;
public OrderClaimService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public boolean claim(long orderId, long shipperId) {
int rows = jdbc.update("""
UPDATE orders
SET shipper_id = ?, status = 'ASSIGNED', version = version + 1, updated_at = now()
WHERE id = ? AND status = 'PENDING'
""", shipperId, orderId);
if (rows == 0) return false; // someone else already claimed it
jdbc.update("INSERT INTO order_events(order_id, from_status, to_status, actor_id) VALUES (?, 'PENDING', 'ASSIGNED', ?)",
orderId, shipperId);
jdbc.update("INSERT INTO outbox(aggregate_id, payload) VALUES (?, ?::jsonb)",
orderId, "{\"orderId\":%d,\"status\":\"ASSIGNED\",\"shipperId\":%d}".formatted(orderId, shipperId));
return true;
}
}
PostgreSQL locks the row during UPDATE. The second shipper waits, then re-evaluates the condition status = 'PENDING', finds no match, and gets rows == 0. The controller returns 409 Conflict, and the app shows “Order already claimed”. You don’t need a distributed lock.
7.2. Consistency: State Machine and Outbox
Every status transition must be valid. Encode transition rules right in the enum:
public enum OrderStatus {
PENDING, ASSIGNED, PICKED_UP, DELIVERING, DELIVERED, CANCELLED;
public boolean canTransitionTo(OrderStatus next) {
return switch (this) {
case PENDING -> next == ASSIGNED || next == CANCELLED;
case ASSIGNED -> next == PICKED_UP || next == CANCELLED;
case PICKED_UP -> next == DELIVERING;
case DELIVERING -> next == DELIVERED;
case DELIVERED, CANCELLED -> false;
};
}
}
Tests for the main behavior:
class OrderStatusTest {
@Test
void shouldAllowHappyPath() {
assertThat(OrderStatus.PENDING.canTransitionTo(OrderStatus.ASSIGNED)).isTrue();
assertThat(OrderStatus.DELIVERING.canTransitionTo(OrderStatus.DELIVERED)).isTrue();
}
@Test
void shouldRejectCancelAfterPickup() {
assertThat(OrderStatus.PICKED_UP.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
}
@Test
void terminalStatesAreFinal() {
assertThat(OrderStatus.values())
.noneMatch(OrderStatus.DELIVERED::canTransitionTo)
.noneMatch(OrderStatus.CANCELLED::canTransitionTo);
}
}
When transitioning status, use condition WHERE id = ? AND version = ?. If no rows update, someone changed the order first. Return 409 and let the client reload.
The outbox pattern solves ‘DB write succeeds but Kafka send fails.’ Events are written to the outbox table in the same transaction as orders. A relay periodically reads unpublished rows and pushes them to Kafka:
@Scheduled(fixedDelay = 500)
@Transactional
public void relay() {
var rows = jdbc.queryForList(
"SELECT id, aggregate_id, payload::text AS payload FROM outbox WHERE published = false ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED");
for (var r : rows) {
kafka.send("order-events", r.get("aggregate_id").toString(), (String) r.get("payload")).join();
jdbc.update("UPDATE outbox SET published = true WHERE id = ?", r.get("id"));
}
}
The FOR UPDATE SKIP LOCKED clause allows multiple relays to run in parallel without processing the same row. The relay guarantees at-least-once: if it dies after sending but before marking published, the event will be resent. So the order-events consumer must be idempotent. For example, skip events with version less than or equal to already-processed version.
The order-events consumer also sets or deletes key shipper:active-order:{shipperId}. This way, the location stream knows which order to push to.
7.3. Load Spikes
Lunch hours, heavy rain, or sale days can spike load 3–5x in minutes. Here are ways to handle it:
- Kafka as buffer. Slow consumers raise lag, but the API still replies fast. Increase partitions (e.g., 48) beforehand; adding partitions while running will reorder keys.
- Drop stale points. With high lag, GPS older than 30s has no display value. Consumer can skip:
if (System.currentTimeMillis() - u.ts() > 30_000) return;. - Adaptive send interval. Server returns a hint header like
X-Next-Interval: 8to let the app stretch the interval when overloaded. Idle shippers send less often. - Batch at client. App groups 2–3 points in one request. HTTP request count drops sharply, Kafka message count stays the same.
- Rate limit per shipper. Cap each shipper to 1 request/s to block broken apps or spoofed spam.
- Separate resources. Run location stream on a Redis cluster and a separate instance group. If this stream fails, order claiming and status updates keep running.
8. Common Mistakes and When Not to Do This
- Write every GPS point to PostgreSQL. 12,500 inserts/s plus indexing will bottleneck the database. If you need to archive routes for reconciliation, have another consumer batch-write to a suitable store like TimescaleDB or object storage at lower frequency.
- Use distributed lock (Redisson) to claim orders. This is more complex and error-prone than a conditional UPDATE on the source of truth.
- Trust request arrival order. Mobile networks don’t guarantee order. Always compare
ts, and for status, compareversion. - Redis Pub/Sub for critical data. Pub/Sub is fire-and-forget. Subscriber disconnect loses messages. Fine for location, but wrong for status events. Status events should go through Kafka. When the client reconnects SSE, it calls
GET /orders/{id}for the latest snapshot.
Note: If your system has only hundreds of shippers, this architecture is overkill. A single service writing location to Redis, combined with client polling every 5s, is enough. Add Kafka and SSE gateway when real numbers force you to.
9. Conclusion
This article designed an order tracking and shipper location system following a core principle: separate temporary data from critical data.
- Location flows through Kafka into Redis. The system accepts data loss but prevents old points from overwriting new ones with a Lua script comparing timestamps.
- Order status lives in PostgreSQL. Concurrency is solved with conditional UPDATE, consistency is maintained with state machines and the outbox pattern.
- Load spikes are absorbed by Kafka, combined with dropping stale points, adaptive send intervals, and resource separation.
From this framework, you can add features like ETA calculation, order batching by location with GEOSEARCH, or route archival for reconciliation.