The Crystal Programming Language Forum

Understanding Websockets

I’m trying to understand how the websockets works. I’m building a basic chat with the least amount of setup code.

ws_handler = HTTP::WebSocketHandler.new do |ws, ctx|
  ws.send({text: "Connecting"}.to_json)
  ws.on_message do |message|
     ws.send(message)
  end
end
server = HTTP::Server.new [ws_handler]

When I open up 2 browser tabs, I send a message and I see they both hit the server, the server sends back to the browser, but only the tab that sent the message gets the message back. Is there something else I have to do to tell all connected clients to get the message?

1 Like

Websockets are connections to single clients. So if you receive a message and send it back, it only arrives at the sender.
You need some kind of message distribution if you want to reach different clients. Typically a chat server would keep track of all the websockets connected to it and relay incoming messages to all of them.
A very basic implementation can be found in kemal-chat shard: https://github.com/sdogruyol/kemal-chat/blob/688f0388f461ab68b83dfe0d81a04065d4832b6a/src/kemal_chat.cr
Or a more elaborate version with different channels in bifrost shard: https://github.com/alternatelabs/bifrost/blob/cbad1701ad2fa8537b2b35d20fef98c809245fcb/src/bifrost.cr

1 Like

Ah! Ok. I was seeing something like that in other implementations and didn’t understand. That makes sense now. Thanks for the explanation!

Ok, one more just out of curiosity (and I know it’s a loaded question), but in that Kemal chat example, it’s shoving each websocket instance in to an Array. How would you handle that at scale?

Like 1 million connections. Just have an array (or maybe hash for some organization) with 1million instances in it? I guess you’d just throw more RAM at the machine at that point and try to cache and manage which connections are still alive and such at that level, right?

Each server in the cluster would have to maintain the list of clients in a chat, as in the example
and then you probably want to load balance between servers.

So you would use redis or a service mesh to distribute messages between servers so they can forward them to users connected to those servers.

Once you move to HA or scalable multi-server infrastructure the complexity of the application has to rise

1 Like

Yeah, it makes sense that you’d eventually move to Redis or some other messaging system. The glue part that I’m missing is when you have a websocket connect, and you need to store something so you can reference that websocket later, what is that “something” if it’s not the websocket instance object? Since you can only shove Strings in redis, what are you shoving in to redis so when you pull it back out you can say “hey, this is WebSocket 456”? Or is that answer a bit too complicated? :laughing:

I’m more just wondering if with Crystal, is there some identifier you can store to reference an open connection later, or does it become that you drop websockets, and start using a whole different system at that level?

I suppose you would just start assigning uids to every web socket. Then you can address them.

2 Likes

This was actually very helpful. Thanks!

In Kemal, you can define the URL that the clients use to create a websocket connection.

What I’ve done is asked the client to pass in a unique username to identify each connection:

ws "/actions/:username" do |socket, context|
    username = context.ws_route_lookup.params["username"]
    @socket_hash["#{username}"] = socket
    socket.on_message do |message|
        ...
    end
end
2 Likes

I also recommend you to have something like this to gracefully close active connections:

# method in a class
def close_connections
  @connections.each_value &.close :GoingAway, "Server going down."
end

Signal::INT.trap { close_connections }
Signal::TERM.trap { close_connections }
3 Likes

I agree with this approach, but you’ve gotta be careful with it. If a user has multiple tabs open and each have a WebSocket connection, you want to make sure one doesn’t overwrite the other. I recognize that you may have accounted for this in your app but simply cut it here for brevity, but others who aren’t as familiar with WebSockets may be surprised at the bugs this can cause. :slightly_smiling_face:

I’m currently working on a Pusher-like app and the WebSocket service is easily the most complicated part when it comes to mapping channel subscriptions to individual connections and, especially, cleaning up when channels and connections are closed.

Agreed! This solution becomes more complex as you consider more use cases. Right, how would the app handle multiple browser connects from the same username? Good point!

1 Like

I don’t find it really complicated (at least for my game + chat), if a user can be only connected once. Define a on_close which will remove the connection or set to nil.
A Set can be used for each user if multiple connections are allowed.

What I have is a Hash of Users, which can hold one nillable connection (or a set if you want multiples).

1 Like