I'm building an app with no backend. No accounts, no sync, no API. The database is a SQLite file on the phone, and that's the whole system.
That sounds simpler than a client/server app. In some ways it is. But removing the server doesn't remove the hard problems — it relocates them.
The device is the source of truth
With no server, there's no authority to reconcile against and no "refresh from the backend" escape hatch. If the local data is wrong, it's wrong permanently. Every bug becomes a data-integrity bug.
This changes how you treat writes. You stop thinking "I'll fix it in a migration later" and start thinking about what the row should have been in the first place.
The mistake I made: recomputing history
The app summarises a period, and the summary is derived from underlying records. Naturally, I computed it on demand.
Then I edited a figure that had been recorded months earlier — a correction to a current value. Every past period silently changed. Records I'd already finalised now showed different numbers, because they were being recalculated from data that had since moved.
Nothing crashed. There was no error. History just quietly rewrote itself, and I only noticed because a total I remembered was different.
The fix: freeze at the boundary
When a period closes, compute the summary once and store it. From then on, that period is read, never recalculated:
const plan = month.closed
? readSnapshot(month.period) // frozen at close
: computePlan(month); // still live, safe to recompute
One rule, enforced everywhere: a closed period is read from its snapshot, never recomputed. Any screen showing those figures checks closed before letting anything change.
This is just event-sourcing intuition applied small. Derived data is fine while its inputs are still in play. The moment a period is done, the derivation becomes a fact and needs storing like one.
Backup, and the parts that shouldn't come back
No server also means no backup unless you write one. Mine dumps every table to a file the user controls.
Two things that weren't obvious:
Keep the deleted rows. Soft-deleted records stay in the dump. Restoring a backup that silently resurrects things the user deleted is worse than not restoring at all.
Not every setting is data. Theme and language belong to the device, not the backup. Restoring on a new phone shouldn't drag the old phone's dark-mode preference along. Everything else restores; those two are deliberately skipped.
The failure mode to design against
Here's the one that will bite you: add a new table, forget to add it to the dump-and-restore routine, and it vanishes on restore. No error. The app works fine — the data is just gone.
I don't have a clever solution, only a blunt one: the backup routine has a single explicit list of tables, and adding a table means touching that list in the same commit. Making the omission visible in code review is the whole defence.
Worth it?
Yes — for this app. No server means no hosting, no auth, no breach surface, no subscription to keep the lights on, and it works on a train.
But "offline-first" isn't "backend-free and therefore easy". You're still running a database, still versioning a schema, still writing backup and restore. You've just moved all of it onto a device you don't control, where the user can uninstall your entire production environment by accident.
Top comments (0)