Is it reasonable to use a separate GenServer for each connected user to coordinate their state across multiple connections?

In many tutorials for Phoenix and LiveView, PubSub is often used to broadcast state changes between multiple connected clients. This seems reasonable when the message being broadcast is something like “You’ve Got Mail” or “New Goal Scored!”.

But if the message being broadcast is “New Message Inserted at Top” or “Message at Index 4 was Edited”, then PubSub seems like a fragile solution. If there are three clients connected, and Client 1 and Client 2 both perform operations close to each other, is the order of PubSub messages sent to Client 3 deterministic? If not, then surely you can find yourself in a situation where Client 3 is responding to messages “out of order” and thus updating its state incorrectly, no?

When the discussion turns towards the canonical example of a stateful game, then the solution seems to be to use a GenServer for each game that is dynamically supervised and available through a Registry.

Is it resonable to use a similar pattern to coordinate state for a single user across multiple connections? If I have a user logged in from several browser tabs, then each browser tab is a separate connection.

Rather than relying on PubSub between the connections, why not just spin up GenServer for that User and then funnel all requests through it? On the surface, that provides a deterministic, serialized way to access the database, update the appropriate state and broadcast back out to the connected clients.

In Phoenix, is this a reasonable design pattern or are there more idiomatic ways to approach this?

FWIW: The application in question is, for all intents and purposes, an SPA that is very loosely a cross between a LiveBook and a no-code dashboard builder. If the user is working away in one browser tab, we’d like to keep all other browser tabs in-sync. LiveView might be used, or we might just push JSON to the client.

2 Likes

Yes, it is reasonable, and I’ve personally used this solution. Albeit I also have the PubSub still there so that your chain goes like: Tab 1 (do change) → GenServer (update) → PubSub (publish update) → Tab 2 (receive change).

This also allows you to effectively cache things into the GenServer, so that when you open new tabs / connections, you don’t need to load all the things from the DB.

One thing to think about is how to shut down the GenServer when it’s no longer needed. I’m not sure if you can get a count of subscribers from PubSub. One way could be for each subscriber to also send a message to the GenServer (“hey, I’m interested”) and the GenServer would Process.monitor them. Then it can react when they go down and shut itself down.

You also need to think about what happens if the GenServer crashes. Should all the tabs also crash and reload? Or can/should you handle it gracefully?

I use this approach in lib/tracker_app_web/live/account_live.ex · c39ae78b86691acd5d780b846c37ff04e168de5e · Mikko Ahlroth / TrackerApp · GitLab but it’s quite an old app and doesn’t have the shutdown logic at all. But it shows how I use DynamicSupervisor and Registry to register the process with a given identifier and then I can try to start a new one with the same identifier, which will return the existing process if there is one.

2 Likes

I haven’t spent too much time thinking about this, but I feel like there are several solutions that might work:

  • Use Phoenix.Presence to know when all clients have disconnected.
  • Use some sort of inactive timer within the GenServer to shut itself down.

I think I would just let the GenServer crash and then only restart it if another client connection comes in. If it crashes while the user has several browser tabs open, then one of those tabs will reconnect and, upon seeing that there’s no running GenServer in the Registry, we can just start a new one. In theory, this would be no different than had the GenServer gracefully shutdown because all clients had disconnected.

All of the state I’m interested in will be backed by a persistent database. So recovering from a crash is no more involved than just repopulating whatever state we need by fetching it from the database.

Thanks for the comments and it’s reassuring to hear that this pattern is acceptable.

1 Like

In this case, maybe the issue is just how you’re using pubsub? When you say

Are you publishing operations before you’ve hit the database or afterward?

1 Like

Very likely as we’re still in the phase of researching and learning Phoenix.

If a client updates some state in our model, we then save that state to the database. We then have to decide how to notify the other clients of the state change. The simpler examples we’ve seen use PubSub. From our understanding, we could broadcast one of two types of messages:

  • “New_Item_Added_At_Index_2”
  • “Something_Changed”

The first solution broadcasts a very specific change that each subscribed client must apply to their own representation of the state. We’ve seen this solution used in some examples like the canonical ToDo list. A new ToDo is created, inserted at the top of the list, and then Phoenix.LiveView can use phx-update=append to surgically patch the DOM. But this assumes that the ordering of all broadcasted PubSub messages is deterministic across all clients, otherwise notations like “Index_2”, “Top_of_List” will get out of sync.

Alternatively, we can broadcast a more generic message that simply informs each Client that it needs to refresh its own state by querying the database. But now we have ‘n’ clients all potentially making very similar requests to the database for the same information. On the surface, we felt it might make more sense for them to query a “central authority” first, which in turn can query the database if necessary. This “central authority” is the GenServer for each user. It can do all the database caching and broadcast a consistent representation of the state to all clients.

1 Like