Building a Local-First Task App From Scratch· Part 1 of 4
June 5, 202622 min read

Why I'm Building a Todo App Like a Distributed System

A first-principles post on building a local-first task app around operation logs, idempotent writes, sync, and PostgreSQL as canonical state.

Local-firstDistributed SystemsPostgresSyncBackendArchitecture

A todo app sounds simple.

You create a task. You edit it. You complete it. You delete it.

That sounds like four database operations and a weekend project.

What if you are designing you todo app for a million concurrent users?

A basic online-only todo app can be a CRUD app. The app I am building is closer to a Todoist or Notion-style task system with:

  • web, mobile, and desktop clients
  • offline task creation and editing
  • multiple devices per user
  • collaborative workspaces
  • comments, labels, reminders, recurring tasks, and attachments
  • activity history, search, and notifications
  • server-mediated sync
  • future sharding by workspace
  • self-hostable infrastructure

Once those requirements appear, the problem changes shape.

This is no longer mainly about storing task rows. It becomes a problem of accepting user intent while offline, retrying it safely, ordering it across devices, syncing it back to every client, and making sure derived systems such as search, reminders, and notifications do not become accidental sources of truth.

That is why I am building this todo app like a distributed system to support a million concurrent users while being fast, responsive and robust. I could go the route of using aws and use aurora which has autoscaling, but it would get expensive fast. So, I will try my best to make it using open-source projects and still achieve the performance target of a million concurrent users.


The Influence Of Data-Intensive Applications

A big influence on this design is the way Designing Data-Intensive Applications frames systems. I wanted to push my skills beyond anything I have made uptill now and in the process learn how big application are designed and built.

The book pushes you to stop thinking only in terms of request handlers and database tables. Instead, you start thinking in terms of:

  • data as the source of truth
  • logs as a way to record change
  • replication
  • derived data
  • indexes
  • stream processing
  • consistency boundaries
  • failure recovery
  • idempotency
  • ordering
  • data as a stream of events

That is the lens I am using here.

A task app looks like a product problem:

How do users create and organize tasks?

But underneath, it is a data problem:

What is the canonical history of changes, and how do all clients and derived views converge from that history?

That question changes the architecture.

The source of truth has to be smaller and more explicit.

For this project, the core source of truth is:

PostgreSQL workspace shard
  -> workspace_events
  -> current-state tables
  -> processed_operations
  -> outbox_events

The main architectural rule is:

A task change is first a durable, idempotent, ordered operation in a workspace log. Every screen, search result, reminder, notification, and analytics view is a projection of those committed operations.

That sentence is the architecture in miniature.


The Product I Want To Build

The product is a local-first task management application.

Users should be able to open the app quickly, see their existing tasks, create new ones, edit them, and keep working when the network disappears.

When the device reconnects, the app should submit pending changes to the server, receive the canonical committed order, and reconcile local state.

At a product level, the app should support:

  • workspaces
  • projects
  • sections
  • tasks and subtasks
  • comments
  • labels
  • assignments
  • due dates
  • recurring tasks
  • reminders
  • attachments
  • activity history
  • search
  • notifications
  • offline queues
  • web, mobile, and desktop clients (web first, others later)

At a system level, every meaningful user action becomes an operation:

project.create
project.update
project.archive
section.create
task.create
task.update
task.complete
task.reopen
task.delete
task.move
comment.create
label.assign_to_task
reminder.set
reminder.delete

The operation is validated, authorized, deduplicated, ordered, committed, and then used to update current state.


Why CRUD Is Not Enough

A CRUD app usually treats the current database row as the source of truth.

A request arrives, and a handler directly mutates the current task row.

update tasks
set title = 'Renew vendor contract'
where task_id = 'task_123';

That works when:

  • the client is online
  • one device is editing
  • retries are rare
  • there is no durable sync history
  • there are no offline operation queues
  • activity history does not matter
  • missed realtime messages do not matter

This project needs all of those things.

Imagine this sequence:

  1. I create a task on my phone while offline.
  2. I edit a different task on my laptop while online.
  3. My phone reconnects.
  4. The phone retries its pending request because the first network attempt timed out.
  5. The desktop app misses a WebSocket message.
  6. A reminder worker needs to reschedule a due-date reminder.
  7. Search needs to update.
  8. The activity feed needs to show what happened.
  9. A stale client needs to learn that a deleted task really is deleted.

A direct UPDATE tasks SET ... does not give the system enough structure to handle that.

The problem is not only:

What is the current task title?

The problem is:

What did the user intend?
Was this operation already submitted?
Which device created it?
What local counter generated it?
What workspace did it target?
Was the user authorized at commit time?
What canonical order did the server assign?
What event should other clients sync?
What should search, reminders, notifications, and analytics derive from it?
What should happen if the client retries after a timeout?

CRUD does not naturally answer those questions.

An operation log does.


CRUD App vs Sync System

A CRUD app is shaped like this:

Rendering diagram…

A sync system is shaped differently:

Rendering diagram…

The sync-system version is more work.

But the structure buys the properties the product needs:

  • offline work
  • retry safety
  • multi-device ordering
  • durable catch-up
  • activity history
  • projection rebuilds
  • workspace sharding later
  • clearer failure recovery

User Intent Becomes An Operation

In this system, the client does not merely say:

Set this task row to this new state.

It says:

Here is an operation I created locally. Commit it exactly once if I am allowed to do it.

An operation has a stable envelope:

{
  "operation_id": "7c7d70f8-6f6f-4ad1-8d0c-4c6b4a7f4a11:42",
  "workspace_id": "7aeb29d7-9c55-40b7-95d0-a756c7bdfda9",
  "device_id": "7c7d70f8-6f6f-4ad1-8d0c-4c6b4a7f4a11",
  "local_counter": 42,
  "base_sequence": 1281,
  "operation_type": "task.update",
  "schema_version": 1,
  "client_created_at": "2026-06-24T15:00:00Z",
  "payload": {
    "task_id": "0bce1f52-fd90-4db8-9a2f-83f936d0b4b7",
    "changes": {
      "title": "Renew vendor contract"
    }
  }
}

The client creates this operation before the server sees it.

That is the key local-first move.

The client can durably store the operation in IndexedDB or SQLite, apply an optimistic UI update, and submit the same operation later when the network is available.


Local-First Does Not Mean Serverless

A local-first app should feel fast without a network round trip.

But for this product, local-first does not mean peer-to-peer sync, browser-to-browser sync (spoiler alert), or devices inventing their own global truth.

The client is a durable local replica with a pending operation queue.

The server is still responsible for:

  • authentication
  • authorization
  • idempotency
  • canonical operation ordering
  • workspace sequence assignment
  • canonical state mutation
  • durable sync history
  • projection fanout

The client can be ahead of the server optimistically.

The server decides what actually committed.

That gives the user fast interaction and gives the system one canonical order per workspace.

Rendering diagram…

The Correctness Loop

The first real milestone is not:

Build all the services.

It is one complete correctness loop:

  1. A client creates a task while offline.
  2. The operation is durably stored locally.
  3. The app restarts.
  4. The pending operation is still there.
  5. The client reconnects.
  6. The operation is submitted to the server.
  7. The server commits it exactly once.
  8. The client calls /sync.
  9. The committed event is applied locally.
  10. The local optimistic state reconciles with canonical state.

That flow is the smallest version of the whole system.

Rendering diagram…

If this loop works, the foundation is real.

If this loop does not work, Kafka, Kubernetes, dashboards, and UI polish do not matter yet.


The Server Write Path

The write path is the main correctness boundary in the backend.

When a client submits an operation, the server must:

  1. authenticate the caller
  2. validate the operation envelope and payload
  3. route the operation to the PostgreSQL shard that owns workspace_id
  4. open a transaction
  5. check current workspace permissions inside that transaction
  6. deduplicate by operation_id
  7. assign the next canonical workspace_sequence
  8. append a row to workspace_events
  9. update current-state tables
  10. insert a row into processed_operations
  11. insert a row into outbox_events
  12. commit
  13. return a deterministic response

The non-negotiable rule is:

The operation log row, current-state mutation, idempotency record, and outbox row commit together or roll back together.

No task row without an event.

No event without idempotency.

No committed operation without an outbox row.

No Kafka publish inside the command transaction.

Kafka is downstream.

Notifications are downstream.

Search is downstream.

WebSocket is downstream.

PostgreSQL is the canonical commit point.

Rendering diagram…

Why Current-State Tables Still Exist

A pure event log sounds clean, but reading task lists by replaying every historical event would make normal product reads expensive.

When a user opens a project, the app needs fast access to:

  • current tasks
  • sections
  • labels
  • due dates
  • completion state
  • reminders
  • assignment state

So the system keeps both:

  1. an ordered operation log
  2. current-state tables

The operation log gives durable history and sync.

The current-state tables give efficient reads.

The important point is that current-state tables are not asynchronous projections. They are updated in the same PostgreSQL transaction as the operation log.

Rendering diagram…

If the transaction commits, both the event and current state exist.

If the transaction rolls back, neither exists.


Idempotency: Retrying Without Duplicating Tasks

Distributed systems are shaped by common failures:

  • a request times out
  • a connection drops
  • a phone goes offline
  • a server commits but the response never reaches the client

From the client’s perspective, the result is unknown.

Did the task get created?

Should the client try again?

If the client sends a new request with a new ID, it might create a duplicate task.

So the client retries the same operation with the same operation_id.

Rendering diagram…

This is why processed_operations exists.

It is keyed by:

(workspace_id, operation_id)

The first valid submission commits.

A duplicate with the same request hash returns the original committed result.

A duplicate with the same operation_id but different content is rejected as an idempotency conflict.


Sync Is The Durable Recovery Path

Realtime is useful, but it is not a correctness mechanism.

WebSocket messages can be delayed, duplicated, dropped, stale, or delivered out of order.

So this system treats WebSocket as a hint layer.

A WebSocket message means:

Something may have changed. Ask the sync API.

The durable recovery path is /sync.

GET /v1/workspaces/{workspace_id}/sync?after_sequence=1281

The client stores the highest workspace sequence it has durably applied locally:

last_seen_sequence_by_workspace[workspace_id]

The cursor only moves after events are applied locally.

The client must:

  1. fetch events
  2. apply them inside a local transaction
  3. update materialized state
  4. update the cursor
  5. commit locally

Only then is the client caught up.

Rendering diagram…

Lamport Clocks, Partial Orders, And Total Orders

The moment multiple devices can edit the same workspace, ordering becomes subtle.

A single-device app has one timeline:

task.create -> task.update -> task.complete

A multi-device local-first app does not.

Imagine this:

  1. My phone is offline.
  2. My laptop is online.
  3. I create a task on my phone.
  4. I rename a project on my laptop.
  5. The phone reconnects later.

Which operation happened first?

Wall-clock time is the tempting answer.

It is also the wrong answer as we are taught in any distributed systems class.

Client clocks drift. Devices go offline. Users can change system time. Network delivery can reorder requests. An operation created at 10:00 on a phone may reach the server after an operation created at 10:05 on a laptop.

The system needs to separate three ideas:

  1. local order on one device
  2. partial order between operations that know about each other
  3. canonical total order assigned by the server

Local Order

Each client installation has a stable device_id and a monotonic local_counter.

device_A:1 -> device_A:2 -> device_A:3

On one device, that gives a clear local order.

It does not give global order.

device_A:3 does not tell us whether it happened before or after device_B:7.

Those are different timelines.

Still, local order is useful. It gives the client stable operation IDs, supports retry safety, preserves local dependencies, and lets the server detect counter reuse.


Partial Order

A partial order is an order where some things are comparable and some things are not.

If device B has applied committed workspace history through sequence 101, then creates an operation, that operation is based on sequence 101.

committed sequence 100
committed sequence 101
device_B:7, base_sequence=101

Now consider two devices that both last saw sequence 100:

device_A:3, base_sequence=100
device_B:7, base_sequence=100

From the application’s point of view, those operations are concurrent.

Device A did not know about device B’s operation.

Device B did not know about device A’s operation.

They are incomparable until the server commits them.

That is where conflict handling comes in.


Lamport Clocks

Lamport clocks are one way to reason about ordering without trusting physical time.

A Lamport clock is a logical counter.

The basic model is:

  1. each process keeps a counter
  2. before creating an event, it increments the counter
  3. when sending a message, it includes the counter
  4. when receiving a message, the receiver moves its counter forward

The key guarantee is:

If event A causally happened before event B, then A’s Lamport timestamp is less than B’s Lamport timestamp.

The reverse is not guaranteed.

If A has a smaller Lamport timestamp than B, that does not always prove A caused B. Two unrelated events can still receive ordered timestamps.

For this app, local_counter is Lamport-like, but it is not a full global Lamport clock.

It gives each device a monotonic local timeline:

device_A:3

base_sequence adds causal context:

device_A:3, base_sequence=1281

That says:

This operation was created by device A as its third local operation, while the client had applied committed workspace state through sequence 1281.

base_sequence is not canonical order.

It is the client saying:

This is what I had seen when I made this decision.

That matters for conflict detection, diagnostics, and preserving user intent.

It does not replace the server’s ordering role.


Why The Server Assigns A Total Order

A partial order helps explain causality.

But clients need to converge to one final state.

If two devices concurrently edit the same task title, the system eventually needs one canonical answer:

task.title = ?

That requires a total order.

A total order means every committed operation in a workspace is comparable:

sequence 1282 -> sequence 1283 -> sequence 1284

The server creates this total order by assigning a monotonically increasing workspace_sequence inside the PostgreSQL command transaction.

Rendering diagram…

The operations may have been concurrent when created.

After commit, the workspace has a canonical order:

101: device_B:7
102: device_A:3

Every client applies events in that same order.

That is how convergence happens.


Partial Order Before Commit, Total Order After Commit

The mental model is:

before commit: local intent + partial context
after commit:  canonical workspace sequence

Before commit:

device_A:3, base_sequence=100
device_B:7, base_sequence=100
device_C:2, base_sequence=101

After commit:

sequence 102: device_B:7
sequence 103: device_A:3
sequence 104: device_C:2

This does not mean device B’s user acted first in physical time.

It means:

For this workspace, the committed history applies B before A before C.

That is enough for deterministic replay.


Why Not Use Client Timestamps?

A tempting design is:

order operations by client_created_at

That breaks quickly.

Should the phone’s 10:00 offline operation be inserted before the laptop’s 10:05 operation, even though the laptop operation already committed and other clients may have seen it?

Usually, no.

The phone timestamp is useful for UI and diagnostics:

created on mobile while offline at 10:00

It should not decide canonical ordering.

Canonical ordering belongs to the server commit path.


Does This App Need Full Lamport Clocks?

Not for v1.

A full Lamport-clock design makes sense in some peer-to-peer or distributed database systems.

This app is server-mediated.

There is one canonical commit point for a workspace: the PostgreSQL shard that owns that workspace.

So the design is:

  • use device_id + local_counter for local identity and retry safety
  • use base_sequence to record what the client had seen
  • use server-assigned workspace_sequence as canonical total order
  • use operation-specific conflict rules when concurrent intent collides

That gives the system what it needs without pretending client clocks can decide shared truth.


Total Order Is Per Workspace

The server does not need one global order for the whole app.

It needs one canonical order per workspace.

Rendering diagram…

There is no product requirement that says a task edit in my personal workspace must be globally ordered against a comment in someone else’s workspace.

Those are independent histories.

This matters for scaling.

Because the workspace is the consistency boundary, each workspace can be routed to one PostgreSQL shard, and that shard can assign sequence numbers for that workspace without coordinating with every other shard.

That keeps normal task operations single-shard.

The ordering model is:

Clients create intent. The server orders committed intent. Clients converge by replaying the server order.


The Source-Of-Truth Boundary

The most important boundary is this:

PostgreSQL decides what happened.
Kafka distributes what already happened.
Search indexes make finding things fast. (help with agents later)
Reminder schedules decide what should fire later.
Notifications deliver user-facing messages.
Valkey caches make reads faster.
WebSocket wakes clients up.

None of those derived systems are allowed to become the source of truth for task state.

Rendering diagram…

If Kafka is down, command writes can still commit as long as PostgreSQL is healthy. The outbox backlog grows and drains later.

If search is stale, /sync still returns committed events.

If WebSocket drops a hint, the client catches up through /sync.

If a projection is corrupted, it can be rebuilt from canonical PostgreSQL state and committed events. As stated in the data driven design book, we need to categorize what can be rebuilt and what can't and design should be influenced by those factors.

The rule is:

Derived systems can lag. Canonical state cannot lie.


Workspace As The Consistency Boundary

The app is organized around workspaces.

A workspace contains:

  • projects
  • sections
  • tasks
  • comments
  • labels
  • reminders
  • members
  • permissions
  • operation history

Most user actions affect exactly one workspace.

That makes workspace_id the natural consistency boundary and future sharding key.

The goal is to keep normal writes single-shard:

task.create does not need a distributed transaction
comment.create does not coordinate multiple databases
task.move inside one workspace routes to the shard that owns that workspace

That gives the system a clean scaling path:

  • add shards as workspace volume grows
  • isolate hot workspaces
  • reduce blast radius
  • avoid distributed transactions for normal operations

I believe notion uses this architecture to scale horizontally


What Happens When Things Fail?

The architecture is designed around expected failure.

FailureWhat should happen
Client goes offlineThe client keeps reading local state and queues operations locally. When it reconnects, it submits pending operations and calls /sync.
Client retries after timeoutThe client uses the same operation_id. The server checks processed_operations. The operation commits at most once.
WebSocket message is missedThe client eventually calls /sync. No durable state depends on the WebSocket message.
Kafka is downPostgreSQL command writes still commit. Outbox rows remain pending. Projection lag grows.
Search is staleSearch results may lag. Canonical task state remains safe.
Notification worker crashesNotifications may be delayed. Task state remains correct.

The mental model is simple:

Protect the canonical log first. Rebuild or catch up everything else.


The First Vertical Slice

The first thing I want to prove is intentionally small:

Create a task while offline, persist it locally, submit it with a stable operation ID, commit it exactly once, recover it through /sync, and reconcile the client after restart.

This slice is small enough to build early and deep enough to validate the architecture.

It proves:

  • the client can create durable pending operations
  • device_id and local_counter work
  • the server can commit operations atomically
  • idempotency works
  • /sync returns committed events
  • local state converges after restart
  • WebSocket is not required for correctness

That is the foundation.

Everything else builds on top.


Why This Is Worth The Complexity

This architecture is more complex than a CRUD app.

There is no point pretending otherwise.

It needs:

  • operation schemas
  • event schemas
  • idempotency tables
  • command handlers
  • sync cursors
  • local client databases
  • transaction discipline
  • projection workers
  • migration discipline
  • replay tests
  • convergence tests

But the complexity is not random.

It buys specific properties:

  • offline work without pretending the client is canonical
  • retry safety after unknown commit outcomes
  • deterministic convergence across devices
  • durable history for sync and activity
  • rebuildable derived systems
  • a future path to workspace sharding
  • clearer failure recovery

The architecture has a spine:

User intent becomes an operation. The operation commits once. Clients converge through the committed log.


What Comes Next

In the next post, I will walk through the high-level architecture of the full system:

  • web, mobile, and desktop clients
  • the public edge
  • Go backend services
  • Keycloak for identity
  • PostgreSQL workspace shards
  • the outbox publisher
  • Kafka
  • derived projections
  • object storage
  • realtime hints

The goal is to explain how the pieces fit together without losing the source-of-truth boundary.

This project may look like a task app on the surface.

Underneath, it is a sync system. I would also like to make it into SDK so that all apps can sync using that sdk but it is a bit ambitious for now, however, I would like to visit it again soon.

More articles