Optimistic updates should be free
PhaseLock is a sync engine built around a simple idea: save your events as a log, which is easy to replicate, and write a reducer that builds state from those events, which can be run anywhere. The result is one sync engine for your web app, your mobile app, and even your backend.
Imagine this nightmarish UX: a user edits a text field, then hits enter. The text immediately returns to the original value. Then a half second later, it snaps back to the updated value. This flicker happens after every single user edit.
That's exactly what happens when an app built on a sync engine doesn't solve the "optimistic updates" problem.
A sync engine is supposed to cache server state locally and render from that cache. As server updates arrive, the cache updates and so does the UI. But if the app only renders from the cache, then the client can't update the UI without a network round trip, causing the edit flicker.
"Optimistic updates" means that what renders in the UI is a combination of known server state plus in-flight client edits.
Additionally, if multiple clients submit conflicting sequences of edits, then one of them will lose and have to roll back the effect of its failed edits in its UI. And if it had multiple edits in flight, it might need to know how to rebase its subsequent edits against the new server state.
This situation is surprisingly similar to a bad git merge conflict, both in mechanism and in how bad it hurts your brain to think about it:
- How will my server resolve these conflicts?
- How will my client accurately mirror what the server does?
- How will my UI roll back the failed changes?
- How can I handle all those cases and get them right?
In PhaseLock, all of these situations are resolved automatically using business logic you already had to write.
The forecast function
A quick reminder of the two basic kinds of functions you write for PhaseLock:
precommitfunctions run on the server to process commands against current state, producing events.reducerfunctions run on both server and client to process events, updating current state.
Optimistic updates require a third kind of function:
forecast. forecast is the client-side mirror
of precommit. While precommit runs on the
server, can access global state, and emits authoritative events,
forecast runs on the client, can only see its client's view
of state, and emits optimistic events (meaning they may not match what
the server eventually produces).
"How can you claim optimistic updates are resolved automatically if I need to write a new function?" you ask. Great question. It turns out the extra function usually requires only a refactor of existing business logic.
Think of it this way. Processing a command to generate events requires answering two questions:
- authorization: who is allowed to send this command?
- event generation: what events should this command generate?
For any given command, forecast only answers event
generation, while precommit must answer both. So usually
precommit is just an auth check followed by a call to the
forecast function; no new logic is needed.
Putting it all together
Here's the whole cycle:
The optimistic path completes locally with zero latency. A PhaseLock app's UI shows the forecasted effect of a user edit immediately. Even offline mode works, because offline mode is just optimistic updates in front of a pathologically slow network.
So the implementation is roughly:
- Commands are created from user actions.
forecastgenerates forecasted events, which reducers apply to an overlay. The UI reflects optimistic state.- On the server,
precommitgenerates authoritative events, which are saved to the log. - On the client, now-persisted events arrive, and reducers apply them to the client store.
- The old overlay and matching forecasted events are discarded. The UI now reflects durable state.
If multiple commands were in flight and only the first is resolved, or if unrelated events arrive from the server, then the old overlay is discarded and a fresh forecast sequence occurs with the remaining in-flight commands and the updated client store. A new overlay is built and the UI reflects an updated optimistic state.
Conclusion
Now we have enough detail to answer every difficult question from before:
- How will my server resolve these conflicts? It runs
precommitagainst incoming commands one at a time, applying or rejecting each command in order. - How will my client accurately mirror what the server does? It runs
forecast, which is typically the event generation logic insideprecommit, to predict server decisions for in-flight commands, accurate according to the latest-known server updates. - How will my UI roll back the failed changes? The UI never needs to "roll back" anything; it reflects the current state of the client store plus the overlay, and the overlay can be cheaply discarded and rebuilt.
- How can I handle all those cases and get them right? You never need to!
All you define is what events to generate per command given current state, and PhaseLock does the rest.