Root CauseWhat broke, why, and the fix.

Saved to Firestore, invisible in the app until you reload

· capacitor, firestore, ios, react, debugging
ⓘ Operated by TechAthletes. Every post here is a bug we hit in our own work — symptom, root cause, fix. Nothing is sponsored and we are not paid to mention any tool.

A list screen in one of our apps: subscribe to a Firestore collection with onSnapshot, render the array, add and edit and delete items. Standard, and the pattern we had reused across several screens.

In the browser it was flawless. In the iOS build — the same code inside a Capacitor WKWebView — adding an item did nothing visible. No error, no spinner stuck on screen, no rejected promise. The item just was not there.

Pull to refresh, and it appeared. So the write had worked all along.

Narrowing it down

The useful question was whether the write or the read was broken, and that is quick to settle. We checked the database directly: the document was there, with the right fields, written at the right moment. addDoc had resolved successfully.

So the write path was fine. The UI never re-rendered because onSnapshot never delivered the change. On the same account, on the same collection, with a listener that had attached without error.

That is the surprising part. A listener that fails to attach throws. This one attached, delivered its initial snapshot — which is why the list had contents at all — and then stayed quiet.

What is going on

Firestore's Web SDK keeps a long-lived connection open to stream changes. WKWebView under Capacitor does not manage that kind of connection the way a desktop browser does: its networking behaviour around persistent connections, backgrounding and resource pressure differs, and in practice realtime updates can arrive late or not at all.

Note the shape of this failure. It is not "the SDK is broken on iOS" — reads and writes work fine, which is why the bug looks so strange from the outside. It is specifically the push channel that is unreliable in that container. Every request you make explicitly succeeds; the thing you did not ask for is what goes missing.

There are ways to attack the transport itself, but they are environment-dependent and we did not want a list screen's correctness resting on them.

The fix

Stop making the listener the only path by which state changes.

Every write already knows what it just did. After it resolves, apply that change to local state directly, and let onSnapshot be a bonus that reconciles other devices and other sessions when it happens to work:

// create
await addItem(uid, item);
setItems(prev => prev.some(m => m.id === id)
  ? prev
  : [{ id, data: item }, ...prev].sort(byUpdatedAtDesc));

// update
setItems(prev => prev.map(m => m.id === id ? { ...m, data: { ...m.data, ...patch } } : m));

// delete
setItems(prev => prev.filter(m => m.id !== id));

The some(m => m.id === id) guard is what makes this safe rather than a source of duplicates. If the listener does fire later with the same document, it replaces the array wholesale and the id is already unique within it. If it fires first — as it does in the browser — the optimistic insert becomes a no-op. Both orderings converge, which is the property you need when you cannot predict which one you will get.

One decision worth being explicit about: we apply the change after the write resolves, not before. Applying it optimistically before the round trip is faster on paper, but then a rejected write has to be rolled back, and rollback is exactly the code path nobody tests. Waiting for the promise costs a few hundred milliseconds and removes an entire class of divergence between screen and database.

What we generalised

A realtime listener is a convenience, not a guarantee. In a browser it is reliable enough that it is easy to build a UI whose only route to a correct render is a server push. Move that code into an embedded webview and the assumption stops holding, silently, on the platform where you debug least comfortably.

"Silent" is the diagnostic signature to look for. Nothing threw, nothing logged, the promise resolved. A missing push has no failure to report, in the same way that a scheduled job which never starts has no error to log. When a symptom is absence, look for the mechanism that was supposed to produce presence, rather than hunting for a broken call.

Check both directions before theorising. Confirming that the document existed took a minute and cut the search space in half. It is tempting to jump straight to reading the client code when the client is what is misbehaving.

We now apply the optimistic-update pattern on every list screen that ships inside a webview, whether or not we have seen the problem there. It costs three lines per mutation and it removes the dependency entirely.

Get new posts by email

We email you only when a new post goes up here. You can unsubscribe at any time.

Privacy policy