Bugs that erase data.
Sync lessons from four Firebase PWAs.
One afternoon, a signed-in user's whole account was replaced by a blank one. Nothing crashed and no error was logged; the app simply saved an empty account over a full one, because it believed the full one didn't exist. We got every item back. Then we went looking for the same shape of bug in every other app, and found it twice more.
The afternoon an account went blank
Cadence is our routines and to-dos app. Each account's data lives in one Firestore document per user. On 31 October, a user cleared their browser's site data, signed in again, and found an empty app: no to-dos, no routines, no goals, no history, coins back to zero.
The data wasn't lost on the device. It had been overwritten on the server, by the app, on purpose, as far as the app was concerned.
The fingerprint gave it away. The document now held bare default settings and the account's Google display name. Only one code path in the app writes exactly that: new-user setup. That path did this:
async function resolveAccount(user) { const ref = doc(db, 'users', idFor(user)); const snap = await getDoc(ref); if (!snap.exists()) { // "New user": write the defaults. // If this read was wrong for any reason, this line replaces a real account. await setDoc(ref, defaultData(user)); } return ref; }
Right after the site data was cleared, that read reported the document as missing. It wasn't. We never reproduced exactly why the read was wrong at that moment, and that turned out not to matter: the code was built so that any wrong "missing" destroys an account.
Getting it back
Recovery came first. Firestore lets you read documents as they were at a recent point in time (point-in-time reads, within roughly the last hour). We read the document as it stood a few minutes before the overwrite, wrote it back, and compared every list item by item against the recovered copy. Everything was identical. A copy was also kept outside the database.
Point-in-time reads only reach back so far. We recovered because the user noticed straight away and we knew the feature existed. Know your database's recovery window, and how to use it, before you need it.
Why get-then-set is a trap
"Read, and if it's missing, create it" is a check-then-act. Between the check and the act, the world can change, and the check itself can be wrong. In an offline-capable client the check is wrong more often than you'd think: a read can be served from an empty local cache, from a fresh session with no data yet, or during a connection that hasn't settled.
The difference isn't the read. It's whether a wrong read can turn into an overwrite.
The general lesson: a write's worst case should not depend on a read being right. If the safe outcome of a bad read is "we failed to create", that's a retry. If it's "we replaced a real account", that's an incident.
Three layers of fix
One fix would have closed this path. We wanted the class of bug closed, so the fix in Cadence PR #22 has three independent layers. Any one of them would have prevented the incident.
async function ensureAccount(user) { const ref = doc(db, 'users', idFor(user)); await runTransaction(db, async (tx) => { const snap = await tx.get(ref); // a server read, inside the transaction if (snap.exists()) return; // existing account: touch nothing tx.set(ref, defaultData(user)); // commit fails if it appeared meanwhile }); return ref; }
Layer 2 is deliberately a heuristic. It doesn't try to be clever about what "blank" means; it only refuses the single most destructive save there is. The user sees "Not saved: reload to get your data back" instead of losing everything.
Hunting the same bug elsewhere
The same day, we audited every other app for the same shape: a save that writes a whole thing, based on state that may not have loaded.
Pebble (expenses, PR #24) was partly safe already, for a reason worth copying. Its profile and Personal-space creation have the same get-then-set shape, but the security rules allow no update of a user's profile and only one field to change on a group. A wrong "missing" there produces a refused write, not an overwrite. The rules were the backstop.
It wasn't safe everywhere, though. Settlement history and subscriptions are saved as whole lists. If their first read failed (the error was caught and logged, and the group still opened), the in-memory list stayed empty, and the next settle-up saved a one-item list over the entire history. The rules allowed it, because a member saving the list is normal.
let settlementsLoaded = false; // reset whenever the group changes async function loadSettlements(group) { state.settlements = await readSettlements(group); settlementsLoaded = true; // only a successful read sets it } async function saveSettlements(group) { if (!settlementsLoaded) { throw new Error("Settlements haven't loaded yet. Reload and try again."); } await writeSettlements(group, state.settlements); }
Grove (shared spaces) didn't have the whole-blob upload at all; its profile and personal space were already created only inside transactions that do nothing if they exist, and direct-message threads are create-only. It did have the session problem, which is next.
We didn't search for "the same code" in other repos. We searched for the same shape: whole-document or whole-list saves, defaults written on "missing", and state that is empty until a read succeeds. That found a different bug in Pebble than the one in Cadence.
Sessions that end cleanly
Layer 3 of the Cadence fix, the session reset, turned out to matter everywhere. A single-page app keeps a lot of state in memory: listeners, caches, decrypted rows, "cloud is ready" flags. If sign-out only calls the auth provider's signOut(), all of that survives, and the next account in the same tab starts life with the previous account's state.
Grove's version (PR #17) made the order explicit:
- mark presence offline first, while the rules still know who you are;
- reset the session: stop every listener (workspaces, vaults, items, chats), drop the vault key and every cache, close every sheet and clear every list;
- then sign out of Firebase.
It also found a listener nobody was tracking. It kept running after sign-out and was refused by the rules. Harmless, until it isn't.
The same PR removed every silent failure: eight empty catch blocks now log, and the user-facing ones (creating, saving, deleting, sending, signing in) show a message. A sync bug you can see is a bug report. A sync bug you can't see is data loss discovered weeks later.
A new browser test signs out, then signs in as another account on the same page, and asserts that every list is empty after sign-out and none of the first account's names are left anywhere.
Writes that must land together
Pebble stores each month's expenses as one document. Editing an expense's date from 30 October to 2 November saved the new date, but the expense stayed filed under October (PR #25). The edit was written back to the month it came from, whatever the new date said.
The fix moved the decision into a pure, unit-tested function that takes the expense out of the old month and puts it into the new one, and the save writes both months in one batch. Two separate writes could leave the expense in both months, or in neither, if one failed. A batch can't.
The browser test for it fails on the old code, which we checked by putting the old code back. And writing it exposed another timezone bug, this time in the tests: the seed data used the UTC month while the app uses the local one, so for five and a half hours after midnight IST on the 1st, three tests failed locally. CI runs in UTC, so it had never seen them.
A related ordering bug showed up in Grove: opening a protected vault right after creating it showed no items, because the listener was attached before the create had reached the server, and the rules check items through their vault. The fix is a bounded retry with backoff on a refused listen, then a visible error, plus mapping the vault before attaching its chat.
Fast, but not at the cost of safety
All this caution has a cost: reading from the server before writing is slower than trusting a cache. On phones, Pebble sat on "Loading" for seconds on every reopen. We measured it in headless Chrome against the emulators with added network latency (PR #23):
| Reopen, added latency | Before | After |
|---|---|---|
| 300 ms per round trip | 2.85 s | 1.93 s |
| 600 ms per round trip | 5.0 s | 3.16 s |
Start-up was about six and a half server round trips one after another, including reading the same profile document twice. The fix kept every safety rule and only removed waiting: read the profile once, open the last group while the profile loads, and read a group's data alongside its document. If a group can't be opened, its in-flight reads settle before the next group is tried, so a late result can't leak into the wrong group.
The tempting next step, showing cached data instantly and refreshing behind it, was explicitly left out of that PR. It would make reopening near-instant, and it touches exactly the "trust a read that might be stale" behaviour this whole post is about. It deserves its own reviewed change.
The offline cache that cached nothing
One last bug, because it hid in plain sight. Cadence's service worker was meant to cache pages for offline use. It never cached anything. The console said:
It cloned each response inside caches.open().then(…), which runs after the page has already consumed the body. Clone first, then hand the original to the page (PR #23). Grove's worker had a different version of the same failure: network-first, but it never stored anything, so offline only ever had the home page (PR #16). Its fix borrowed Cadence's, and added a browser test that checks the service worker really caches the bundle.
Both workers also got new cache names so the broken caches are dropped on activate, and both now leave Firebase Auth, sign-in and database traffic entirely alone.
The rules we now follow
These became written rules in every app's project docs and in a shared skill for our Firebase PWAs, so the next app starts with them:
Thanks for reading
The apps are all on auragika.com. If you've been bitten by a get-then-set, or recovered data with a point-in-time read, I'd like to hear how it went.