Module 9 / Growing with your data

Module 9 · lesson 8 of 8 · Lab 19 min

Lab: From one city to ten

Plan the growth steps, in order, with numbers.

Tiffin's board has approved the plan: over the next two years, expand from one city to ten. You're the engineer asked to answer one question before the first new city launches: what has to change, in what order, and how will we know when?

Nobody wants a rewrite. Nobody wants an outage in month eight either. Your job is a growth plan with numbers and triggers, not a dream architecture.

The brief

This lab rewinds the clock, and its numbers are deliberately its own. Forget the multi-city, 200-million-row Tiffin from earlier in this module, and the 200,000-orders-a-day city of Modules 4 to 8: here Tiffin is a smaller, made-up version of itself, one city (Pune) at 50,000 orders a day with everything from Part 1 already in place, so you get to plan the growth before it hurts. Two things do carry over, so the arithmetic matches the rest of the course: requests per order (Modules 4 to 8 saw about 3,600 requests a second at 11 orders a second, about 330 per order at the peak) and Module 4's measured server capacity. Here's where this Tiffin stands today:

Today Value
Orders per day 50,000
Dinner share 60% of orders between 7pm and 10pm
App servers 10 small stateless servers (1 vCPU each) behind a load balancer
Database One Postgres primary, 2 read replicas, connection pooler
Cache Redis for menus and sessions
Live tracking WebSockets for riders, SSE for customers (Module 6); latest rider location in Redis
Rider location history Every ping written to a rider_locations table in Postgres (support asked for full trails for delivery disputes, so the one-point-every-30-seconds rule from Module 6 was dropped)
Database size 350 GB, growing
Engineers 15, in one codebase

The plan for two years from now:

  • 10 cities, including two very large ones.
  • 400,000 orders a day in total.
  • Riders ping their location every 4 seconds while online, as they do today.
  • About 60 engineers.

Work through each step yourself before reading the reasoning.

Step 1: Project the numbers

Peak orders, riders and pings

Using the same 60% dinner share, work out for the two-year target: peak orders per second, roughly how many riders are on shift at peak, and how many location pings per second that produces. An order spends about 15 minutes of its 30 on the road with a rider (the rest is cooking), and at the rush a rider often carries two.

Show one answer

Orders: 60% of 400,000 is 240,000 orders in 3 hours. 240,000 / 10,800 s = about 22 orders a second at peak. Today it's about 3.

Requests: at about 330 requests per order at the peak, 22 orders a second is about 7,000 requests a second. Today, at 3, it's about 1,000.

Riders: orders arriving per second x time each spends on the road. 22 x 900 s = about 20,000 orders on the road at once. Two per rider is about 10,000 riders busy. Add riders waiting for their next order and say 15,000 on shift.

Pings: 15,000 riders / 4 s = 3,750 pings a second.

Now put the numbers side by side, because the comparison is the point:

                       Today      In 2 years   Growth
Peak orders/s             3           22          7x
Peak requests/s       1,000        7,000          7x
Peak order writes/s      45          330          7x
Peak ping writes/s      500        3,750          7x

Everything grows about 7 times. But the absolute numbers tell you where the trouble is. 330 order writes a second is easy for one Postgres primary. 3,750 ping writes a second, more than ten times the order writes and every one of them an insert into an indexed table, is the thing that will actually hurt. The rider pings, not the orders, are Tiffin's biggest scaling problem.

Step 2: The app servers

Do the app servers need a redesign?

Ten 1-vCPU servers handle 1,000 requests a second today. What do you need for 7,000?

Show one answer

No redesign. The servers are stateless (Module 4), so this is just more of them. Use Module 4's numbers: an 8-core server's knee was about 1,400 requests a second, so about 175 per vCPU, and you want the peak under 70% of the knee, about 120 per 1-vCPU server. Today's 100 per server fits. For 7,000: 7,000 / 120 ≈ 58, plus a spare, so about 60 small servers at the dinner peak. That's about the same 64 vCPUs as eight of Module 4's 8-core servers; the size of each box is a cost and packing choice, not a design change.

Better: set up autoscaling on CPU, with a minimum that covers lunch and a scheduled scale-up at 6:30pm before dinner, since you know exactly when the rush comes.

This is the reward for the work in Module 4. Because no server holds state, growth in the app tier is a number in a config file. The interesting decisions are all about data.

Step 3: Get the pings out of Postgres

The pings are two different things wearing one name:

  1. "Where is this rider right now?" Needed for live tracking and for matching riders to orders. Only the latest value matters, and it changes every 4 seconds.
  2. "Where was this rider during order 81?" Needed for disputes and support. Read rarely. Kept for 30 days.

Today the latest location is already in Redis, but every ping is also inserted into the rider_locations history table in Postgres: 500 inserts a second. At 3,750, it would dominate the primary's write load, its write-ahead log, its replicas and its backups.

Redesign the ping path

Where should each kind of location data live?

Show one answer

Latest location: Redis only, which Tiffin already does. 3,750 simple writes a second is comfortable for a Redis instance. Use Redis's geo commands if dispatch needs "riders within 2 km".

History: don't insert pings one by one into Postgres. The tracking servers buffer pings in memory, or put them on a queue, and a worker writes them in batches of a few thousand to cheap storage: object storage files grouped by city and hour, or a time-series database. Keep 30 days. A dispute lookup reads one rider's file for one hour.

Daily volume: if riders are online for about 12 hours with an average of 6,000 online (15,000 is only the dinner peak), that's 6,000 / 4 x 43,200 s, about 65 million pings a day. At ~60 bytes that's about 4 GB a day, roughly 120 GB for 30 days. Cheap in object storage, and now none of it is on the primary.

One ping now takes two cheap paths, and neither touches the primary:

%% caption: The latest position goes to Redis right away. History is batched into object storage.
sequenceDiagram
  autonumber
  participant RA as Rider app
  participant T as Tracking server
  participant R as Redis
  participant Q as Queue
  participant W as Batch worker
  RA-)T: ping, every 4 s
  T->>R: set latest location
  T->>Q: add to the history batch
  Note over Q: thousands of pings build up
  W->>Q: take a batch
  Note over W: one file per city and hour, into object storage
The latest position goes to Redis right away. History is batched into object storage.

Do this first, before the second city launches. It's the change with the biggest effect, and it's easier to do while the numbers are small.

Step 4: One primary, or shards?

With pings gone, here's the primary's peak write load in two years:

Order writes      22 orders/s x 15 rows   =  330/s
Carts, ratings, payouts, sessions          ~  500/s
                                           ~  850 writes/s

Shard now?

Should Tiffin shard by city before launching the new cities, "since we'll need it eventually"?

Show one answer

No. 850 writes a second is well within what one well-tuned Postgres primary handles, with plenty of room to scale the machine up. Sharding now would add cross-shard queries, a router, ten times the operational work and harder migrations, to solve a problem Tiffin doesn't have.

But do the cheap things that keep the door open:

  • Make sure every core table has a city_id, and every hot query includes it.
  • Generate order IDs that are unique without the database, such as Snowflake-style IDs, instead of a single sequence.
  • Avoid features that need transactions across cities.

And write the trigger down: "Plan sharding when peak primary CPU stays above 60% after query tuning and one machine upgrade, or when a new country requires data to stay local."

Step 5: Data size

Order data grows with orders. At 400,000 orders a day and roughly 3 KB per order across orders, order_items, order_events and their indexes:

400,000 x 3 KB = 1.2 GB a day, about 440 GB a year

The database is 350 GB today. Without action it'll be well past 1 TB in two years, with backups and restores growing to match.

Plan archiving and partitioning

When would you partition orders, and what archiving rule would you use?

Show one answer

Partition now, by month on created_at, while the table is a few hundred gigabytes. Converting a table this size is a weekend's batch copy. Converting it at 2 TB is a project.

Archive from month 12: a nightly job detaches partitions older than 12 months, exports them to Parquet in object storage, verifies them, and drops them. That keeps the main database at roughly one year of orders, around 450 GB at full size, which keeps backups, restores and new replicas fast. Add a "Load older orders" path for the few customers who want them.

Step 6: Search and teams

Search. Ten cities means roughly 15,000 restaurants by the end of the plan, but today this Tiffin has one city's worth. Searches always include a city, so each one looks at a few thousand restaurants at most. Start with Postgres full-text and trigram indexes. Trigger to move to a dedicated search engine: "search p95 above 300 ms at dinner, or product asks for typo tolerance, autocomplete and facets together." With ten cities and search as the first thing every customer does, expect this within the first year. The search lesson's Tiffin is already past that point: 15,000 restaurants, and customers who expect typo tolerance and results as they type.

Teams. Going from 15 to 60 engineers is when one codebase starts to hurt. Start the modular monolith in the first few months, before the team passes about 25: owners per folder, public interfaces, one owner per table, boundaries enforced in the build. Search, if it gets its own engine, is a natural first separate service. Trigger for more: "teams regularly wait on each other's deploys."

The plan, in order

When Step Why now
Before city 2 Move ping history out of Postgres Biggest write load. Easiest while small
Before city 2 Snowflake-style IDs, city_id everywhere Cheap now, keeps sharding possible
Before city 2 Partition orders by month Table is still small enough to convert easily
Ongoing Autoscale app servers, pre-scale for dinner Load grows 7x, servers are stateless
Months 3 to 6 Modular monolith boundaries Team growing past 25
Trigger: slow search Dedicated search index, maybe as a service The questions outgrow SQL
Month 12 Nightly archive of partitions over 12 months First year of data to move
Trigger: primary CPU over 60% Scale up, then plan sharding by city Not expected within two years
%% caption: Tiffin in ten cities. Still one primary. The biggest change is moving pings and old orders out of it.
flowchart TD
  CA([Customer app]) --> LB[Load balancer] --> AS["App servers<br/>autoscaled, modular"]:::hot
  RA([Rider app]) -- "pings, every 4 s" --> TS[Tracking servers]
  AS --> PG[("Postgres primary<br/>orders, monthly partitions")]:::store
  PG --> RR[(Read replicas)]:::store
  AS --> RD[("Redis<br/>cache, latest rider locations")]
  AS -.-> SI[("Search index<br/>when triggered")]
  TS --> RD
  TS --> Q[[Queue]] --> PA[("Ping archive<br/>object storage, 30 days")]:::hot
  PG -. "archive job, nightly" .-> OS[("Object storage<br/>orders over 12 months")]:::store
Tiffin in ten cities. Still one primary. The biggest change is moving pings and old orders out of it.

Build it on AWS

Tiffin runs in Mumbai (ap-south-1), and ten Indian cities don't need a second Region: every city is a short network hop from Mumbai. So the plan above maps onto the same AWS account, with three AWS-specific decisions: whether the database stays on RDS, how pings reach S3 without becoming a bill, and how archiving actually shrinks the database. Prices are us-east-1 list prices as of late 2026, fine for comparing options; Mumbai's differ, so check the AWS pricing calculator before quoting them.

The architecture

%% caption: Tiffin in ten cities on AWS. Still one writer. Pings and old orders go to S3 instead of the database.
flowchart TD
  CA([Customer app]) --> ALB[Application Load Balancer] --> APP["App service<br/>ECS on Fargate, autoscaled"]:::hot
  RA([Rider app]) -- "pings, every 4 s" --> TS["Tracking service<br/>ECS on Fargate"]
  APP --> AU[("Aurora PostgreSQL<br/>writer and 2 readers")]:::store
  APP --> VK[("ElastiCache Serverless for Valkey<br/>cache, latest locations")]
  TS --> VK
  TS -- "batched records" --> FH["Firehose<br/>one stream per city"]:::hot
  FH --> S3[("S3: pings, 30 days<br/>and archived orders")]:::store
  AU -. "nightly archive task" .-> S3
Tiffin in ten cities on AWS. Still one writer. Pings and old orders go to S3 instead of the database.
Generic box AWS service The setting that matters
App servers ECS on Fargate Target tracking on CPU, plus a scheduled scale-up at 13:00 UTC (18:30 in India, which has no daylight saving)
Postgres primary and replicas Aurora PostgreSQL, one writer and two readers Standard or I/O-Optimized storage, chosen from the bill
Redis ElastiCache Serverless for Valkey Unchanged since Module 3; GEO commands for "riders near here"
Queue and batch worker for pings Amazon Data Firehose, Direct PUT Many pings per record, never one
Object storage S3, with a 30-day expiry rule on pings Prefixes by city and hour

Argument 1: stay on RDS, or move to Aurora?

Until now Tiffin has run RDS for PostgreSQL: a Multi-AZ primary (with a standby you can't read from) and two read replicas, each with its own copy of the data on its own disk. This lab rewinds to a smaller Tiffin than Module 5's (one city at 50,000 orders a day), so the instances here are far smaller than the replicas lab's, but the decision is the same at either size. In two years that's about 600 GB of data, provisioned at 1 TB to leave room. Price both on the same instance size, r7g.2xlarge:

RDS for PostgreSQL
  Multi-AZ primary            $1.913/h x 730 h          ~ $1,397
  2 read replicas             2 x $0.956/h x 730 h       ~ $1,396
  Storage (gp3): Multi-AZ 1 TB at $0.23 + 2 x 1 TB at $0.115    ~ $460
                                                          ~ $3,250 a month

Aurora PostgreSQL (Standard)
  Writer + 2 readers          3 x $1.106/h x 730 h       ~ $2,422
  Storage: 600 GB used, stored once for the cluster, $0.10/GB    ~ $60
  I/O: $0.20 per million requests                         ~ depends
                                                          ~ $2,480 + I/O

Aurora is cheaper before I/O for a structural reason. Its readers are the failover targets, so there's no idle standby to pay for. And all three instances share one cluster volume, which keeps copies of the data across three Availability Zones, so you pay for 600 GB once, not three provisioned terabytes. The same design gives the operational wins Tiffin needs over the next two years:

  • A new reader takes minutes, not hours. It attaches to the existing volume instead of copying 600 GB. That matters on the night a new city launches and reads double.
  • Storage grows on its own, so nobody gets paged for a full disk.
  • Failover is to a reader that's already warm, and AWS documents it as typically under a minute.

The catch is the I/O line. On Aurora Standard you pay per million I/O requests; on I/O-Optimized the instances cost more ($1.437 an hour for this size) and I/O is free. AWS's own guidance: once I/O is 25% or more of the Aurora bill, I/O-Optimized is cheaper. Here that's about $800 a month of I/O. Start on Standard, read the first month's bill, and switch if needed. You can move from Standard to I/O-Optimized once every 30 days.

The migration doesn't need downtime for copying. Create an Aurora read replica of the RDS instance, let it catch up until its lag is zero, then promote it to a standalone Aurora cluster and move the app's connection string. AWS recommends turning off automatic minor version upgrades on the RDS instance early, so it doesn't drift to a version Aurora doesn't support yet. Do it before city 2, alongside the other "while it's small" changes.

Argument 2: getting pings to S3 without paying for air

The plan says: batch pings and write them to cheap storage. On AWS, the least code is Amazon Data Firehose with Direct PUT. The tracking service sends records, Firehose buffers them for a few minutes and writes files to S3. Use one Firehose stream per city, each writing under its own S3 prefix, and Firehose adds the date and hour to every path. A dispute lookup becomes an Athena query over one city's hour.

The trap is how Firehose bills: per GB ingested, with each record rounded up to the nearest 5 KB. A ping is about 60 bytes.

One record per ping:    65 million records/day x 5 KB = 325 GB/day billed
                        ~ 9.75 TB/month x $0.029/GB                  ~ $283 a month
Batched records:        each task sends one record a second holding
                        all its pings: ~4 GB/day of real data         ~ $3.50 a month

Neither number will sink Tiffin, but one is 80 times the other for the same data, and it scales with every city you add. Batch in the tracking service, which is buffering anyway. Then add an S3 lifecycle rule to expire ping files after 30 days, so the "keep 30 days" promise is enforced by the bucket, not by a script someone has to remember.

Why not Kinesis Data Streams? It's the right tool when several consumers need the same stream, or need to replay it. Here there's one consumer, S3, so Firehose is the stream and the writer in one.

Argument 3: archiving that actually shrinks the bill

The nightly archive job is an ECS task. It detaches the partition that turned 12 months old, writes it to S3 as Parquet (one file per month, often 5 to 10 times smaller than the rows), checks the row counts match, and drops the partition. On Aurora, dropping a partition returns its storage: on current Aurora versions the cluster volume shrinks when data is removed, so the bill follows the archive down. On older Aurora versions, freed space was reused but never shrank, so check which version you're on.

"Load older orders" becomes an Athena query against the Parquet files. It takes a few seconds and costs a tiny fraction of a cent per lookup, which is fine for a button a few customers press a day.

What it costs

At the two-year target (400,000 orders a day across ten cities):

Line Monthly, roughly
Aurora PostgreSQL: writer + 2 readers (r7g.2xlarge), 600 GB, Standard ~$2,480 + I/O
App service on Fargate, Graviton: about 16,000 task-hours (1 vCPU, 2 GB): about 60 tasks at dinner, 15 the rest of the day ~$630
Tracking service: 4 Fargate tasks, Graviton ~$115
ElastiCache Serverless for Valkey: about 2 GB stored plus a few billion ECPUs (an estimate) ~$140
Firehose and S3 for pings (batched), archive and Athena ~$15
Load balancer ~$50
Total ~$3,430 + Aurora I/O

That's about 29 cents per thousand orders, before Aurora I/O. Nearly three quarters of it is the database, which is why the plan works so hard to keep pings and old orders out of it.

The same design on GCP and Azure

Role AWS Google Cloud Azure
Database Aurora PostgreSQL AlloyDB for PostgreSQL Azure Database for PostgreSQL
App and tracking services ECS on Fargate Cloud Run Container Apps
Ping archive pipeline Firehose to S3 Pub/Sub with a Cloud Storage subscription Event Hubs Capture to Blob Storage
Queries over archives Athena BigQuery external tables Fabric or Synapse serverless SQL

The shape is the same on every cloud: one writer, readers that share or stream from it, and a cheap pipe from the hot path into object storage.

The I/O line

Two months after the move to Aurora Standard, the monthly bill shows instances $2,422, storage $60 and I/O $1,800. A colleague suggests adding a bigger reader "because Aurora is expensive". What would you do instead, and what would it save?

Show one answer

I/O is $1,800 of $4,282, about 42% of the Aurora bill, well past the 25% line. Switch the cluster to I/O-Optimized: instances become 3 x $1.437 x 730 ≈ $3,147, storage 600 GB x $0.225 ≈ $135, and I/O is free. That's about $3,282 a month, saving roughly $1,000 with no code change.

But first ask why I/O is that high. A missing index that turns a hot lookup into a scan, or a reader serving reports that read cold data, can drive I/O on its own. Fix those, then decide. If I/O falls back under 25% of the bill, Standard may still be the cheaper choice. A bigger reader would add cost and wouldn't touch the I/O line at all.

What to remember

  1. Start a growth plan with projected numbers. The biggest number, not the most famous problem, tells you what to fix first.
  2. Look for data that doesn't belong in the main database. For Tiffin, rider pings were the real scaling problem, not orders.
  3. Stateless app servers make app-tier growth a config change.
  4. Don't shard on a prediction. Do the cheap things that keep it possible: a shard-friendly key in every table and globally unique IDs.
  5. Partition big tables early, while converting them is cheap, then archive old partitions on a schedule.
  6. Every step gets a written trigger, so the plan says when, not just what.
  7. On AWS, Aurora suits a growing single-writer database: readers double as failover targets, storage is shared and grows by itself. Read the I/O line and switch to I/O-Optimized past 25% of the bill.
  8. Check how a managed pipe bills before sending it tiny records: Firehose rounds each record up to 5 KB, so batch first.