
CMU SCS '26.5
Phoenix LiveView has built-in fire-and-forget PubSub functionality, making it easy to add real-time features to web apps. However, maintaining the same shared state between multiple clients is a bit tricky: How can new clients snapshot the current state and subscribe to the stream of updates without dropping any messages in-between?
The answer is to persist before publishing, and to subscribe before snapshotting.
We can prove that we won’t drop any updates by working through the subscribe-to-snapshot timeline for a new LiveView process, assuming the above constraints. I added circles to represent all the timings for when a PubSub message can be received:
Elixir processes are single-threaded, so the fetching and initial snapshot blocks happen together. The server will actually process the events in the following order:
We can show that our final state accurately reflects the shared live state by working through updates 1, 2, and 3, which cover all possible cases.
Update 1 is already included in the initial snapshot, due to our constraint of persisting before publishing.
Update 2 may or may not have been included in the snapshot, depending on database transaction isolation rules and other race conditions. If it wasn’t, we’ll process it normally and update the snapshot. If it was included, we need to make sure that the update is a no-op. In some cases, like an append-only chat, this is very simple to do if you have message ids at hand.
Update 3 happened after the initial snapshot was taken, and it directly updates the state.
In each of the cases above, we did not miss an update or incorrectly mutate the original snapshot. The proof that both constraints are necessary, not just sufficient, has been left as an exercise for the reader.
A race condition caveat…
PubSub updates might not be received in the correct order. In a live chat app, for instance, update 2 may actually refer to a message that was sent later than the one in update 3. A quick fix is to provide message creation timestamps alongside the message so that the subscriber can sort them.
What this looks like in practice
For The Game, we have a live chat component. We also have user reputations, which are displayed next to their usernames in each message they send. Users can add kudos to in-game messages, which updates the message UI itself and may also update the recipient’s reputation. (Pedantically, a player can bump up another player’s reputation by at most 1 per round. Currently, you can also kudos messages sent in earlier rounds, making it look like you can increase the reputation by more than 1 in a round.) Here’s a short demo:
For memory efficiency, messages are sent using LiveView streaming and are not persisted in server-side memory, while user reputations are tracked with a lightweight map from user_id to reputation in each LiveView process. (Phoenix differs from other fullstack frameworks, like Svelte, Tanstack Start, and Next.js, in that all client state (not just initial state) is stored on the server by default. If you have 100 users, that’s 100 copies of user state on your server.) Here was my initial implementation:
Snapshotting
Getting the initial chat state
We load the 20 latest messages, their relevant metadata (kudos count, sender, etc.), and the users’ reputations, fetched directly from the db.
User reputations are saved with a timestamp indicating when their reputation was fetched.
Loading past messages when the user scrolls up
- We do the same thing, while augmenting the reputation score map for any new users.
user_reputation_map =
fetch_missing_user_reputations(new_messages, socket.assigns.user_reputation_map)
new_messages =
new_messages
|> add_user_reputation_to_messages(user_reputation_map) Events
New message
After saving the message to the db, the sender publishes a
{message, reputation+ts}tuple, with the message already containing the latest metadata. The reputation+timestamp data is fetched iff it’s not already in memory.The receiver updates its reputation map with the new reputation, iff the timestamp on the received reputation is later than the record it already has. Using manual DOM patching, we update the reputation score for all messages sent by that user. (Recall that messages are not stored in-memory server-side, so we can’t just loop through the existing list of messages.) The new message is then sent over the LiveView stream.
New kudos
When a user gives a message a kudos, we persist the data and send out an updated
{message, reputation+ts}tuple event, this time never using the cached reputation value, since kudosing directly affects reputation.The receiver does the same thing as above, ± some cosmetic details.
Is this right?
Well, there are two problems here, the first of which I didn’t fix, because it didn’t affect correctness too much. Fable 5 pointed out neither issue during code review, unfortunately.
Messages may be displayed in different orders on different clients. (See the race condition caveat mentioned above.) We can’t sort the messages server-side based on timestamp, so the only solution is to write some JS to sort the messages client-side. This may just be personal opinion, but I don’t think the code overhead is worth it here. (User reputation updates do have timestamp ordering implemented, however, because that is definitely important.)
While the message list does correctly implement subscribing before snapshotting, you may notice that we don’t ever take a “full snapshot” of user reputations. We only fetch user reputations if they appear in the initial 20 messages or for any scrollback loading. For all other users, we blindly accept the reputation sent in the
{message, reputation}tuple. This can result in dropped updates, like in the following example:
Here, Client 1 and Client 2 kudos the same user, increasing the user’s final reputation to 2. Both clients publish their respective events to the PubSub channel.
Client 3 opens the chat window at the same time, subscribing to the chat, and, crucially, misses the later new_kudos event, which is dropped because it arrived before subscription. (This is possible - Erlang doesn’t guarantee PubSub event ordering across different publishers). Client 3 also did not load in user_1’s reputation as part of its snapshot, since (let’s say for the sake of example) user_1 did not appear in the last 20 messages. Despite user_1 now having a reputation of 2, Client 3 thinks user_1 has a reputation of 1. If Client 3 is user_1, any time they send a new message, the outdated reputation score of 1 is shown and also sent to new clients.
Here’s another race condition, this time even more glaring since Client 3 doesn’t even need to be user_1 to see an outdated reputation for user_1.
The Fix
By never taking a snapshot of user_1’s reputation ourselves, we ended up accepting outdated information from other processes. To fix this, we’ll fetch the live reputation from the database any time we receive a PubSub message with a new user. Just the first time though - we can accept updates afterwards as usual.
In our example above, Client 3 will take a snapshot at new_kudos sub (rep: 1, ts: 1), and find out that user_1 now has (rep: 2, ts: 3). (guaranteed by our persist-before-publish rule!) Client 3 will then use the new snapshot, since it has a higher timestamp.
In code, we only need to add half a line to fix this.
defp update_reputation_map(reputation_map, user_id, new_reputation) do
# the right part of the || is new! It's a live db query
prev_reputation = Map.get(reputation_map, user_id) || Chat.get_user_reputation(user_id)
merged_reputation =
merge_reputations(
prev_reputation,
new_reputation
)
Map.put(
reputation_map,
user_id,
merged_reputation
)
end (Sidenote: The other, less efficient fix is to always fetch the user’s reputation ourselves every time we receive a message that the user’s reputation has changed.)