TL;DR: Every Tatask app (web, iPhone, Android) keeps a full copy of your tasks in a local WatermelonDB database. You read and write locally, so the app is fast and works offline. A sync engine exchanges changes with Firestore in the background: on sign-in, when you reconnect, when you come back to the app, and whenever another device changes something. Deletes are tombstones, conflicts are last-write-wins, and signing out of the web app wipes the local copy.
Tatask is offline-first. That's a phrase that gets used loosely, so this post explains what it means in practice for Tatask: where your tasks live, how they move between devices, and the trade-offs I chose. It's written for developers and the curious. If you just want to know whether it works on a train, the answer is yes, and the offline and sync feature page is the short version.
The problem
A to-do list gets used in bad conditions: on the Underground, on a plane, in a basement gym, on hotel Wi-Fi. If every action waits for a server, the app is only as reliable as the connection. People notice quickly, and they start writing things down somewhere else.
So the requirement was: everything you do in Tatask should work with no connection at all, and should reach your other devices when a connection comes back.
A local database on every device
Each Tatask app has its own complete database of your tasks, on the device.
I use WatermelonDB, a reactive database built for apps with lots of records that need to sync.
- On iPhone and Android, the Expo app runs WatermelonDB on SQLite through its native adapter.
- In the browser, the web app runs WatermelonDB on its LokiJS adapter, which stores data in IndexedDB. The browser has no native SQLite binding, so this is WatermelonDB's official web option. The schema and sync logic are the same; only the storage engine underneath changes.
The data model is deliberately small: a tasks table and a users table. A task's position in the tree is just a parent_id pointing at another task, with a sentinel for the root. Tags are derived from hashtags in the text. Due dates, completion times and deletion times live in a metadata field. The two apps share the same contract, so a task edited on one is read correctly by the other.
Reads and writes are local
When you add, edit, complete or move a task, the app writes to the local database and the screen updates straight away. Nothing waits for the network.
The UI is reactive. Components subscribe to queries on the local database, so when a record changes, whether you changed it or a sync brought the change in, every view showing it updates. In the Svelte web app, that's a thin store wrapper around WatermelonDB's observables. In the React Native app, it's withObservables.
Sync
Sync uses WatermelonDB's built-in synchronize() protocol, which has two halves: pull and push. Both talk directly to Firestore from the client. There's no custom sync server to run.
Pull
The app asks Firestore for documents belonging to you that have changed since the last pull. Those changes are applied to the local database. If a composite index is missing, there's a slower fallback query, so sync degrades rather than fails.
Push
Local changes since the last sync are written to Firestore in batches using merge writes, so a push only touches the fields that changed. Batches are capped well below Firestore's per-batch limit.
When sync runs
There's no polling timer. Sync runs:
- when you sign in
- when the browser comes back online (web) or the app comes back to the foreground (mobile)
- when a realtime Firestore listener sees a change made on another device
That last one is what makes changes appear on your laptop a few seconds after you make them on your phone. The listener is debounced and ignores writes the device itself just made, so it doesn't trigger a sync storm.
A small queue makes sure only one sync runs at a time. If a sync is requested while one is in progress, it's folded into a single follow-up run.
Deletes are tombstones
If you delete a task on your phone while your laptop is offline, the laptop needs to find out about the delete later. A real database delete would leave nothing to find.
So deletes are soft: the task gets a deleted_at timestamp and is synced like any other change. When a device pulls a task with deleted_at set, it removes the task locally. Every device hears about every delete, however long it's been offline.
Conflicts: last write wins
If the same task is edited on two offline devices, which version wins? I use last write wins, based on the task's updated-at timestamp.
There are cleverer approaches, like CRDTs or field-level merging. For a single person's to-do list they're mostly unnecessary. Real simultaneous edits to the same task on two devices are rare, and when they happen, the most recent edit is almost always the one you meant. Simple and predictable beat clever here.
Sign-out wipes the local copy
A local database has one downside: your tasks are stored on the device. On a shared or work computer, the next person to use the browser shouldn't find them.
So on the web, signing out wipes the local database before anything else happens. Sync listeners are stopped first and any in-flight sync is awaited, so a late write can't recreate data after the wipe. If the wipe fails for any reason, the app still signs you out and does a hard navigation, rather than leaving a half-signed-out state.
Subscriptions offline
One more offline detail: a paying user should never be locked out because they're on a plane. The web app caches the last confirmed subscription status on the device and trusts it for up to 14 days without a server check. If there's no cached status at all, the app shows a neutral loading state rather than the paywall. Only a live check can newly lock someone out.
How I test it
Offline behaviour is easy to break without noticing, so it's tested end to end. The web app's test suite runs against local Firebase emulators and checks, among other things, that edits made offline reach the server when the connection returns, that one user's data never appears for another after a sign-out and sign-in on the same browser, and that a cached subscription keeps the app usable offline while a missing cache shows a loading state rather than the paywall.
What I gave up
Offline-first isn't free:
- Complexity. Sync code has to handle partial failures, timestamps in different formats from older data, and records that change during a sync.
- First load. The first time you sign in on a device, it has to pull your whole list before it's fully up to date.
- Storage. Your tasks take up space on each device. For a to-do list that's small, but it's not zero.
For a to-do list, that trade is worth it. The app is fast because nothing waits for a server, and it works wherever you are.
Related reading
- A giant upgrade to Tatask, on moving the web app to Svelte 5
- Why every developer should build a to-do app
- Why you need one list on every device
Frequently asked questions
What database does Tatask use on the device?
WatermelonDB. On iPhone and Android it runs on SQLite; in the browser it uses the LokiJS adapter, which stores data in IndexedDB.
How are conflicts resolved?
Last write wins, based on each task's updated-at timestamp. For a single person's to-do list, where true simultaneous edits are rare, that keeps things simple and predictable.
Are deleted tasks removed straight away?
They're marked as deleted with a timestamp rather than removed outright, so every device can learn about the delete when it next syncs.
What happens to my data when I sign out?
On the web, the local database is wiped on sign-out, so nothing is left behind on a shared computer.