Blog

DATA SAFETY · OFFLINE-FIRST · FIRESTORE · INCIDENT REVIEW

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.

anuragdevon 10th Nov 2026 12 min read Building Auragika · Part 5 of 5
01

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:

js — the shape of the bug (simplified)
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.

02

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.

T+0
Site data cleared, user signs in
New-user setup runs. Its read says "missing". Defaults are written over the account.
OVERWRITE
Minutes
The fingerprint points at one code path
Bare defaults plus the Google display name: only new-user setup writes that combination.
DIAGNOSE
< 1 hour
Point-in-time read, write back, verify
The document as it was before the overwrite, restored and compared item by item.
RECOVERED
⚠ THE CLOCK IS PART OF THE RECOVERY

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.

03

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.

Get then set: a wrong "missing" read leads to a set that overwrites the server copy. A create-only transaction fails instead of overwriting.GET, THEN SETget(user)says: missingset(defaults)unconditionalserver copy replaceda wrong read is now permanentCREATE-ONLY TRANSACTIONtxn.get(user)read on the servercreate if missingelse do nothingexists by commit? → retry/failit can never replace a document

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.

04

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.

1Create-only. New-user setup runs in a transaction that creates the document only if it is missing on the server, and fails if it exists by commit time.✓
2A blank-upload guard. The save path refuses to upload a completely blank account over a server copy that had real content, logs it, and tells the user to reload. Deleting items one at a time still saves normally.✓
3A session reset. Signing out, or losing auth any other way (cleared storage, another tab), stops listeners, marks the cloud copy as not ready and clears in-memory state. A later sign-in reads the server copy before anything can upload.✓
js — create-only, as a transaction (simplified)
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.

05

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.

js — refuse to save what you never loaded (simplified)
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.

// AUDIT BY SHAPE, NOT BY FILE

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.

06

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:

  1. mark presence offline first, while the rules still know who you are;
  2. reset the session: stop every listener (workspaces, vaults, items, chats), drop the vault key and every cache, close every sheet and clear every list;
  3. 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.

07

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.

08

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 latencyBeforeAfter
300 ms per round trip2.85 s1.93 s
600 ms per round trip5.0 s3.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.

09

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:

ConsoleTypeError: Failed to execute 'clone' on 'Response': Response body is already used

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.

10

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:

✅
Create only inside a transaction that does nothing if the document exists
Never "get, then set" for anything that might already hold user data.
✅
Never save what you haven't loaded
A "loaded" flag set only by a successful read, reset when the context changes. Saving before it is an error, not an empty write.
✅
Guard the most destructive write
Refuse to upload a blank copy over a full one, and say so.
✅
Reset the whole session on sign-out and auth loss
Listeners, caches, flags. Presence offline first, sign out last.
✅
Related writes go in one batch
Moving something between documents is one atomic operation or it's a duplication bug.
✅
Let the rules be the backstop
If a field should never be updated, deny updates. A wrong client then fails instead of overwriting.
✅
No silent failures, and know your recovery window
Log and show sync errors. Practise point-in-time recovery before you need it.
Part 1
From a static quiz page to a game the client can't cheat
Part 2
Rooms that survive people leaving
Part 3
Testing a real-time app for real
Part 4
One team, five apps
Part 5
Bugs that erase data
This post.
END OF THE SERIES

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.

Contact