Skip to content
1 of 3 project slots open
Writing
Fundamentals · part 5 of 56 min read

System design for app developers, worked through

A shared, offline-first shopping list from napkin maths to failure modes. One Postgres is plenty; sync is the hard part.

Meer Habib

Senior Mobile Engineer · Chittagong

System design sounds like something for backend teams at huge companies. It isn't. Every app with an account and a sync button has a system design, whether someone chose it or not. Here's the process I use, worked through on one example: a shared shopping list that a family edits together, live, even when someone is offline in the supermarket basement.

1. Say what it must do

Functional: create lists, add and tick items, share a list with other people, see their changes live, keep working offline.

Non-functional: a change should appear on other phones within about a second; nothing should ever be lost; it should cope with a million users.

Write both down before drawing a single box. Most bad designs solve a problem nobody had.

2. Do the napkin maths

1M

users

→

200k

active a day

→

6M

writes a day

→

~70/s

average

→

~350/s

peak, ×5

Fig. 1Back-of-the-napkin load. Round numbers on purpose: you want the order of magnitude, not the decimal.

If each active user makes about 30 changes a day, that's six million writes, or around 70 a second. Traffic isn't flat, so plan for five times that at peak: about 350 writes a second, and perhaps ten times as many reads.

That's the most important result in this whole piece: a single Postgres database handles this comfortably. No microservices, no sharding, no exotic database. The maths saves you from building for a scale you don't have.

3. Model the data

lists        (id, owner_id, title, created_at)
list_members (list_id, user_id, role)
items        (id, list_id, text, done, position, updated_at, deleted_at)
changes      (list_id, seq, item_id, op, payload, created_at)

A few choices worth explaining:

  • IDs are generated on the phone (UUIDs), so an item has its real ID the moment it's created offline.
  • deleted_at instead of deleting rows. A deleted item is a tombstone, so a phone that was offline learns that it's gone.
  • position is a sortable string, not an integer. Inserting between two items gives it a key between theirs, so nothing else has to be renumbered.
  • changes is an append-only log per list, with a sequence number the server assigns.

4. Sync: poke, then pull

Phone AAPIDatabaseRealtimePhone B1write to local DB + outbox2POST changes (batch, idempotent)3INSERT … seq 10424list 7 changed5poke: list 76GET changes after 10397changes 1040–1042
Fig. 2Phone A makes a change. The server records it and pokes Phone B, which pulls everything it missed.

Every phone keeps a local database and an outbox of changes it hasn't sent. When it's online, it sends the outbox in batches, each with an idempotency key so retries can't duplicate anything.

The realtime channel doesn't carry the data. It only says "list 7 changed". Each phone then asks for everything after the last sequence number it saw. This is the trick that makes sync reliable: a phone that missed ten pokes while offline still gets every change, in order, with one request.

5. Decide what happens in a conflict

Two people tick the same item, or one renames it while the other deletes it. For a shopping list, simple rules are enough:

  • Last writer wins, per field, using the server's order, never the phones' clocks.
  • Deletes win over edits made before them.
  • Positions never collide, because of the sortable keys.

If this were a shared document with two people typing in the same sentence, you'd need something stronger, like CRDTs. Most apps don't. Pick the simplest rule that users won't notice.

6. Grow in the right order

When load really does grow, add things in this order, and only when a measurement asks for them:

  1. Indexes on what you query: items(list_id), changes(list_id, seq).
  2. A read replica for the heavy reads.
  3. A cache for the hottest lists.
  4. A queue for slow work: push notifications, emails.
  5. Partitioning by list_id, much later, if ever.

7. List the ways it breaks

What happensWhat the design does
A request is sent twiceThe idempotency key returns the first result.
Phone clocks disagreeOrder comes from the server's sequence, not timestamps.
A phone was offline for monthsIts cursor is too old; it downloads a fresh snapshot.
Someone is removed from a list while offlineTheir queued changes are rejected, and the list disappears from their phone.
One user sends a flood of requestsRate limits at the gateway.

The process, in one line each

Requirements. Napkin maths. Data model. Read and write paths. Conflicts. Growth. Failure modes. You'll notice the boxes came last. For what each box is, see the twelve boxes behind every app.

Building something like this?

Booking new projects for Q4. Replies within 24h.