Module 6 / Real-time updates

Module 6 · lesson 7 of 7 · Lab 19 min

Lab: Live order tracking

Show the rider moving on the map for a whole city at dinner time.

This lab puts the whole module together. You'll design the feature customers love most and engineers find trickiest: a little rider icon gliding across the map towards your front door.

Work through each step yourself before opening the answer. The answers are one reasonable design, not the only one. If yours differs, check that you can say what you gave up.

The brief

The product lead's request:

"When the rider has picked up the order, the customer should see them moving on the map, live, all the way to the door. It has to hold up at 8pm on a Saturday."

What you know about Tiffin today:

  • 8 stateless app servers behind a load balancer, one Postgres primary with read replicas, and one Redis.
  • Peak dinner traffic: about 11 orders a second. An order takes about 30 minutes from placing to delivery, and the last 15 or so of those are the ride.
  • About 6,000 riders are online at peak. Many carry two orders at once.
  • The rider app already sends its location to the server every 10 seconds as a plain POST, for dispatch.
  • The customer app shows order status by polling every 10 seconds: about 10,000 open order screens at peak, so about 1,000 requests a second (the polling lesson's numbers).

Step 1: Pin down "live" and count the load

"Live" isn't a requirement until it has a number. Before designing anything, decide what the requirements actually are.

Write the requirements

List three or four requirements, with numbers where you can, including at least one about privacy and one about bad networks.

Show one answer
  • Freshness: the rider's position on the customer's map is at most about 5 seconds old. Status changes ("picked up", "arrived") appear within 2 seconds.
  • Smoothness: the icon moves smoothly, not in jumps. (This is a client problem, as we'll see, not a data rate problem.)
  • Privacy: the customer sees the rider's location only between pickup and delivery, and only for their own order.
  • Bad networks: if the rider loses signal, the customer sees "last updated 40 seconds ago" rather than a frozen icon pretending to be live. If the customer loses signal, the map catches up as soon as they're back.

Notice what isn't required: a full history of every point on the screen, or sub-second accuracy. A delivery map is not a racing game.

Then put numbers on it. They decide which of the module's tools you need.

Estimate the traffic

Work out (a) how many orders are on the road at peak, (b) how many location messages riders send per second if they report every 4 seconds, and (c) how many messages go out to customers per second, if half of the customers with a moving order have the map open.

Show one answer

(a) Orders on the road: 11 orders a second x 15 minutes of riding (900 seconds) = about 10,000 orders on the road at any moment.

(b) Rider messages in: 6,000 riders / 4 seconds = 1,500 location messages a second.

(c) Customer messages out: half of 10,000 is 5,000 open maps, each getting one position every 4 seconds = about 1,250 messages a second.

Add the customers watching status before pickup (about 10,000 orders are still in the kitchen, so roughly 5,000 more screens if half have the app open) and you have around 10,000 open customer connections plus 6,000 rider connections.

Each message is under 200 bytes, so bandwidth is about 300 KB a second each way. That's nothing. The hard parts here won't be throughput. They'll be connections, fan-out across servers, and failure.

Step 2: Rider to server

The rider's phone needs to send its location every 4 seconds, and it already needs to receive delivery offers with a 20-second window to accept.

Choose the rider's connection

POST every 4 seconds, or a WebSocket? Why?

Show one answer

A WebSocket. Traffic flows both ways all the time: locations up, offers and alerts down. A POST every 4 seconds would mean 1,500 full HTTP requests a second through the app servers, each with several hundred bytes of headers, and a separate channel would still be needed for offers.

The socket carries a ~70-byte location frame with no headers, and fewer radio wake-ups help a battery that has to last a 10-hour shift. The rider app gets backoff with jitter on reconnect, ping and pong every 25 seconds, and a "what's my current state" call after reconnecting.

One more decision: the phone sends its GPS reading's own timestamp with each location. If the phone was in a tunnel and sends five buffered points at once, the server can tell which is newest and ignore the old ones.

Step 3: Where locations live

1,500 location updates arrive every second. Where do they go?

Choose the storage

Should every location update be written to the Postgres orders table, or somewhere else? Think about how many rows that makes in a night and who reads them.

Show one answer

Writing every point to Postgres is possible, since 1,500 small writes a second is within reach of a good primary, but it's a poor use of it. Over a 3-hour dinner peak that's 1,500 x 10,800 = about 16 million rows a night, almost none of which anyone reads. Those writes compete with order placement on the one database that can't scale writes easily (Module 5).

What people actually read is the latest position. So:

  • Latest position in Redis: SET rider:881:loc {lat, lng, ts} EX 60. The 60-second expiry means a rider who disappears simply ages out, rather than sitting on the map forever.
  • A sampled history in Postgres: one point every 30 seconds per active delivery, for disputes like "the rider never came". That's 10,000 / 30 = about 330 rows a second, a much smaller table, and it can be written in batches.

Step 4: Server to customer

Now the other end. The customer's app needs status changes and, after pickup, a new position every 4 seconds.

Choose the customer's connection

Keep polling, long polling, SSE, or WebSocket? And how do you make a position every 4 seconds look smooth?

Show one answer

SSE. The customer only listens. The occasional action, like "call rider" or "cancel", is a normal request. SSE works through Tiffin's existing load balancer and cookies, and reconnects by itself with Last-Event-ID.

Today's 10-second poll is too slow for a 5-second freshness target. Polling every 4 seconds to meet it would mean 2,500 requests a second from all 10,000 open screens, two and a half times today's load, most of them from status screens where nothing has changed. That's possible, but SSE gives fresher updates for less work.

For smoothness, don't send more points. The app animates between the last position and the new one over 4 seconds, so the icon glides. This is how most delivery and ride-hailing apps make the car move smoothly without a high update rate. Sending a point every second would quadruple the messages for almost no visible gain.

Each message carries full state, not a delta:

id: 1042
event: rider
data: {"lat":12.9716,"lng":77.5946,"ts":1739121010,"status":"picked_up"}

Step 5: Across servers

The rider's socket is on one server. The customer's stream is on another. A rider can carry two orders for two different customers.

Connect the two

Design how a location from the rider reaches the right customer. Which channel names would you use, and where does the privacy rule from Step 1 get enforced?

Show one answer

Use Redis pub/sub with one channel per order: order:{id}.

  • When a customer opens tracking for order 123, the server holding their stream first checks that the order belongs to them. Then it subscribes to order:123 and sends the current state: the status from the database, and the rider's last position from Redis if the order has been picked up.
  • When rider 881 sends a location, their server writes it to Redis, then reads the rider's picked-up orders from a small Redis set, rider:881:orders, and publishes to each of their channels. A rider carrying two orders means two publishes. The set is updated when the rider picks up or delivers an order, which happens a few times an hour, so the 1,500 pings a second never touch Postgres. Update it only after the status change has committed in Postgres. And because a lost update could leave a delivered order in the set, the customer's stream stops forwarding rider positions once it has sent delivered.
  • Status changes from the kitchen or the rider are published to the same channel.

Privacy is enforced in two places. Only the order's customer can subscribe, checked when the stream opens. And the location is only published while the order is in the picked-up state, so nothing leaks before pickup or after delivery.

Why one channel per order rather than per rider? A channel per rider would mean a customer's server receives the rider's location even after their own order is delivered and the rider moves on to the next customer. Per order, the channel naturally goes quiet when the order is done.

Volume: one publish per picked-up order every 4 seconds, so 10,000 / 4 = 2,500 publishes a second, plus a handful for status changes. Only about half reach a subscriber (the maps that are open); the rest are dropped by Redis at almost no cost. One Redis handles that easily. Keep the connections on a small connection tier (4 servers, each holding about 4,000 connections, with room for one to fail) so app deploys during dinner don't disconnect anyone.

Here is one location update making the whole trip, from the rider's socket on one server to the customer's map on another.

%% caption: One location update. The customer's server subscribed when tracking opened, so the publish finds it.
sequenceDiagram
  autonumber
  participant RA as Rider app
  participant A as Server A
  participant R as Redis
  participant B as Server B
  participant CA as Customer app
  CA->>B: open tracking for order 123
  Note over B: checks order 123 is theirs
  B->>R: SUBSCRIBE order:123
  B-->>CA: current state
  RA-)A: location with GPS timestamp
  A->>R: SET rider:881:loc, EX 60
  A->>R: SMEMBERS rider:881:orders
  R-->>A: order 123
  A->>R: PUBLISH order:123
  R-->>B: rider position
  B-->>CA: event: rider
One location update. The customer's server subscribed when tracking opened, so the publish finds it.

Step 6: When things break

A design isn't done until you've walked through its failures.

Walk three failures

What happens, and what should happen, when (a) a connection server with 4,000 customers crashes, (b) a rider rides through a 2-minute dead zone, and (c) Redis fails over to its replica?

Show one answer

(a) Connection server crashes. About 4,000 connections drop: roughly 2,500 customer SSE streams and 1,500 rider sockets. Each customer's SSE client reconnects on its own, landing on the other 3 servers; the server's earlier retry: value of a few seconds plus client jitter spreads them out. The rider apps reconnect with backoff and jitter and ask for their current state, so an offer sent during the gap isn't lost. On reconnect, each customer stream sends current state first: one Redis read for the rider's location and a status read from a replica or cache. That's 4,000 cheap reads spread over a few seconds, and the 3 survivors now hold about 5,300 connections each, which is why the tier was sized with a spare.

(b) Rider dead zone. No locations arrive. The rider's key in Redis expires after 60 seconds. The customer's app looks at the ts on the last position, and once it's more than about 20 seconds old, shows "Rider's location last updated 40 seconds ago" (counting up) instead of pretending. When the rider reconnects, the app sends its buffered points, the server keeps only the newest by timestamp, and the map jumps forward.

(c) Redis fails over. For a few seconds, publishes fail and subscriptions drop. Servers reconnect to the new primary, re-subscribe to their channels, and re-send current state to their clients. Riders' latest positions might be a few seconds stale or missing until their next update arrives 4 seconds later. Nothing is lost that matters, because the database holds the order status, and positions are refreshed every 4 seconds anyway. This is exactly why the design treats Redis as a fast notification path, not the source of truth.

The final design

%% caption: Riders stream locations in over WebSockets, customers watch over SSE, and Redis carries both the latest position and the messages between servers.
flowchart TD
  RA([Rider app]) -- "WebSocket, every 4 s" --> CT["Connection tier<br/>4 servers"]:::hot
  KT([Kitchen tablet]) -- status --> AS[App servers]
  CT -- "latest location, publish" --> R[("Redis<br/>locations, pub/sub")]
  AS -- publish --> R
  R -- "to subscribers" --> CT
  CT -- SSE --> CA(["Customer app<br/>animates the map"])
  AS --> PG[("Postgres<br/>orders, sampled history")]:::store
  CT -. "a point every 30 s" .-> PG
Riders stream locations in over WebSockets, customers watch over SSE, and Redis carries both the latest position and the messages between servers.
Decision Choice What we gave up
Rider connection WebSocket Reconnect and resume logic to build and test
Customer connection SSE Native apps need a small streaming library
Location storage Latest in Redis, a point every 30s in Postgres A full-resolution trail of every ride
Cross-server delivery Redis pub/sub, one channel per order Messages can be lost, so every connect resends current state
Smoothness Client animates between points The icon is up to 4 seconds behind reality
Connection tier Separate from app servers More parts to run and monitor

The thing to notice: none of the numbers were large. 16,000 connections and under 3,000 messages a second fit on a handful of machines. The design work was in choosing the right connection for each side, keeping the database out of the hot path, and making every failure end with a correct screen.

Build it on AWS

Tiffin runs in Mumbai (ap-south-1): the app is an ECS service on Fargate behind an Application Load Balancer, the database is RDS for PostgreSQL with read replicas (Module 5), and Redis is the ElastiCache Serverless for Valkey cache from Module 3. Live tracking adds exactly one new thing to run: a small connection tier. The interesting question is whether AWS can run that tier for you. Prices below are us-east-1 list prices as of late 2026, which are fine for comparing options; Mumbai's differ, so check the AWS pricing calculator before quoting them.

The architecture

%% caption: Live tracking on AWS. One new ECS service holds the sockets; Valkey carries positions and pub/sub between tasks.
flowchart TD
  RA([Rider app]) -- "WebSocket" --> ALB["Application Load Balancer<br/>path /live"]
  CA([Customer app]) -- "SSE" --> ALB
  ALB --> CT["Connection service<br/>ECS on Fargate, 4 tasks"]:::hot
  ALB --> APP["App service<br/>ECS on Fargate"]
  CT --> VK[("ElastiCache for Valkey<br/>latest positions, pub/sub")]:::hot
  APP --> VK
  APP --> RDS[("RDS for PostgreSQL<br/>orders, sampled history")]:::store
  CT -. "a point every 30 s" .-> RDS
Live tracking on AWS. One new ECS service holds the sockets; Valkey carries positions and pub/sub between tasks.
Generic box AWS service The setting that matters
Connection tier Its own ECS service on Fargate, 4 tasks of 1 vCPU and 2 GB Deployed separately from the app, so app deploys at dinner don't touch sockets
Load balancer The existing ALB, a listener rule for /live Idle timeout versus heartbeats (see the exercise)
Redis The existing ElastiCache Serverless for Valkey cache One channel per order (serverless caches don't support pattern subscriptions), and clients that resubscribe
Postgres The existing RDS for PostgreSQL Sampled history written in batches, not per ping

Argument 1: shouldn't API Gateway hold the sockets for us?

API Gateway has managed WebSocket APIs: AWS holds the connections, your code only handles messages. It sounds like exactly what a small team wants. Price it on Step 1's numbers, counting only the 3-hour dinner peak:

Messages in and out:  (1,500 + 1,250) a second x 10,800 s x 30 nights  = 891 million
                      at $1.00 per million                              ~ $890
Connection minutes:   16,000 x 180 minutes x 30 nights = 86 million
                      at $0.25 per million                              ~ $20
A Lambda call for every rider location: 486 million x $0.20 per million ~ $100, plus duration

That's about $1,000 a month for three hours a day, before lunch and the rest of the evening, so realistically $1,500 to $2,000. The connection tier on Fargate is four Graviton tasks at about $29 each: about $115 a month, all day.

Money isn't the only problem, though:

  • There's no SSE. API Gateway WebSocket APIs speak WebSocket only, so the customer app would have to switch protocols and lose Last-Event-ID resume.
  • There's no pub/sub. To push a rider's position to order 123's watcher, your code must look up that customer's connection ID (in DynamoDB, say) and post to it, one API call per message. The per-order channel design from Step 5 becomes a lookup table you maintain.
  • Connections are capped at 2 hours, and close after 10 idle minutes. A rider's 10-hour shift becomes five forced reconnects, which the app must handle as normal.

Recommendation: run the connection tier yourself on ECS. API Gateway WebSockets make sense for a small or spiky product, a few hundred connections that come and go, where nobody wants to run a service at all. Tiffin is past that.

Argument 2: pub/sub on the serverless cache

Step 5's design publishes each position on a per-order channel, and the menu cache from Module 3 is ElastiCache Serverless. Serverless caches do support SUBSCRIBE and PUBLISH, and spread channels across their shards behind the scenes, so tracking can share the cache. Three things to get right:

  1. Subscriptions live on a connection, not in the cache. If a connection task's link to the cache drops, during maintenance, scaling or a node replacement, every subscription on it is gone. The tasks must notice, reconnect and run SUBSCRIBE again for every order they're watching. Keep the list of open orders in the task's memory so it can resubscribe in one pipeline, and test it by restarting connections in staging, before dinner tests it for you.
  2. No pattern subscriptions. A serverless cache doesn't support PSUBSCRIBE, so a design that listens on order:* won't work. Subscribe to each order's channel by name, which is what Step 5 does anyway.
  3. The cache isn't the record. A position held only in the cache can be lost when something fails. That's fine here: the next ping arrives within 4 seconds, which is why the design keeps Postgres, not Valkey, as the record of the order.

Sharing the cache with menus is fine at 1,500 writes and 2,500 publishes a second: each costs about one ECPU, a few dollars a month. Module 11 gives location data its own cluster when it becomes its own service (the split lab), and also covers draining sockets during deploys.

What it costs

Line Monthly, roughly
Connection service: 4 Graviton Fargate tasks (1 vCPU, 2 GB) ~$115
ALB capacity for 16,000 open connections (a rule on the existing ALB) ~$30
Valkey: positions and pub/sub on the existing cache ~$5
Traffic between Availability Zones ~$20
Total ~$170

At 200,000 orders a day, that's about 3 cents per thousand orders. Live tracking is one of Tiffin's best-loved features and one of its cheapest. The cost is in engineering: reconnect logic, resubscribe logic, and the failure drills.

The same design on GCP and Azure

Role AWS Google Cloud Azure
Connection tier ECS on Fargate Cloud Run (WebSockets and streaming supported) Container Apps
Load balancer Application Load Balancer Cloud Load Balancing Application Gateway or Front Door
Positions and pub/sub ElastiCache for Valkey Memorystore Azure Managed Redis
Managed sockets API Gateway WebSocket APIs (no direct equivalent) Web PubSub

On Cloud Run, check the request timeout: a streaming connection counts as one long request, so set it to match how long a tracking session lasts.

Streams that drop every minute

After the move to AWS, support reports that customers whose orders are still in the kitchen see their map flicker "reconnecting" about once a minute. Riders don't have the problem, and customers whose rider is moving don't either. What's happening, and what's the fix?

Show one answer

The ALB closes connections that have been idle for its idle timeout, 60 seconds by default. A rider's socket pings every 25 seconds, and a moving order sends a position every 4 seconds, so both stay busy. But an order in the kitchen may send nothing for minutes, so its SSE stream sits silent until the ALB cuts it.

The fix is a heartbeat: the connection service sends an SSE comment line (: ping) every 20 to 25 seconds on every open stream. It costs a few bytes, keeps the ALB from treating the stream as idle, and lets the server notice dead clients sooner. You could raise the ALB's idle timeout instead (it goes up to 4,000 seconds), but that only hides the problem from one hop, and any proxy between the phone and you can have its own shorter timeout.

What to remember

  1. Turn "live" into numbers first: how fresh, how smooth, and what the screen shows when data stops coming.
  2. Pick the connection per client: WebSockets where both sides talk, SSE where the client only watches.
  3. Keep high-rate, short-lived data like rider positions in Redis, and save only a sampled history to the database.
  4. Smooth movement comes from animating on the client, not from sending more updates.
  5. Use pub/sub with one channel per order, check access when subscribing, and send current state on every reconnect so lost messages heal on their own.
  6. On AWS, managed WebSockets (API Gateway) cost about ten times a small ECS connection tier at Tiffin's volume, and don't speak SSE. Run the tier yourself, with heartbeats shorter than the load balancer's idle timeout.
  7. A cache failover drops every pub/sub subscription. Reconnect through the endpoint name, resubscribe everything, and drill it before dinner does.