WebSockets
Build real-time, interactive experiences. Soli ships with native WebSocket support and Phoenix-inspired presence tracking — everything you need for chat apps, notifications, dashboards, and live collaboration.
Want to see it in action?
Check out the interactive WebSocket chat demo bundled with this app.
1 · Quick start
A minimal echo server in three files. Copy, paste, run.
Declare the route
Map a path to a controller action.
Write the handler
Return a hash describing what to send back.
Connect from the browser
Standard WebSocket API — no client library required.
router_websocket("/ws/echo", "echo#handle")
def handle(event)
return { "send": "echo: " + event["message"] } if event["type"] == "message"
{}
end
const ws = new WebSocket(`ws://${location.host}/ws/echo`)
ws.onmessage = (e) => console.log(e.data)
ws.onopen = () => ws.send("hello")
// → "echo: hello"
That's the whole loop. The rest of this page explains each piece in depth.
2 · How handlers work
A WebSocket handler is a plain function that receives an event hash and returns a hash of actions. The runtime dispatches the actions and sends them down the right sockets.
def handle(event)
type = event["type"]
id = event["connection_id"]
return { "broadcast": { "type": "join", "user": id } } if type == "connect"
return { "broadcast": event["message"] } if type == "message"
{}
end
The event object
type
Event kind: "connect", "message", or "disconnect".
connection_id
Unique UUID identifying this client connection.
message
Text payload sent by the client (only set when type == "message").
params
Hash of dynamic route segments (e.g. :room_id).
query
Parsed query string of the upgrade request.
headers
HTTP headers from the original upgrade.
Return {} to do nothing
A handler must always return a hash. An empty hash means "no action" — useful for disconnect events that only need server-side cleanup, or message events you want to silently drop.
3 · Response actions
Every key in the returned hash triggers an action. You can combine multiple keys in a single return value.
send
Reply only to the client that triggered the event.
{ "send": "Hello you" }
broadcast
Send to every connected client (including the sender).
{ "broadcast": "Hello all" }
join
Subscribe this connection to a channel/room.
{ "join": "room:lobby" }
leave
Unsubscribe from a channel. Automatic on disconnect.
{ "leave": "room:lobby" }
broadcast_room
Send to everyone in the connection's most recently joined room.
{ "broadcast_room": payload }
track
Start presence tracking with metadata.
{ "track": { "channel": "...", "user_id": "..." } }
set_presence
Update presence state (typing, away, online).
{ "set_presence": { "channel": "...", "state": "typing" } }
untrack
Manually stop tracking. Automatic on disconnect.
{ "untrack": "room:lobby" }
4 · Tutorial — build a chat room
Three increments. Each one is a working app you can run; each one adds a single capability on top of the last.
4.1Broadcast to everyone
The simplest useful handler: every incoming message goes out to every connected client.
def handle(event)
return { "broadcast": event["message"] } if event["type"] == "message"
{}
end
4.2Scope to a room
Broadcasting globally doesn't scale past a single chatroom. Use a dynamic route segment plus
join + broadcast_room to isolate traffic per room.
router_websocket("/ws/room/:room_id", "chat#handle")
def handle(event)
room = "room:" + event["params"]["room_id"]
return { "join": room } if event["type"] == "connect"
return { "broadcast_room": event["message"] } if event["type"] == "message"
# leave is automatic on disconnect
{}
end
4.3Add presence and typing indicators
One more action — track on connect — and clients automatically receive
presence_state / presence_diff events whenever the user list changes.
def handle(event)
room = "room:" + event["params"]["room_id"]
user = get_current_user() # however your app authenticates
if event["type"] == "connect"
return {
"join": room,
"track": {
"channel": room,
"user_id": user["id"], # required — groups multi-device connections
"name": user["name"],
"avatar": user["avatar"]
}
}
end
if event["type"] == "message"
data = event["message"].to_h
return { "set_presence": { "channel": room, "state": "typing" } } if data["event"] == "typing"
return { "set_presence": { "channel": room, "state": "online" } } if data["event"] == "stop_typing"
return { "broadcast_room": event["message"] }
end
# disconnect auto-untracks and emits the leave diff
{}
end
5 · Presence tracking
Presence is real-time awareness of who is online. Soli's implementation is Phoenix-inspired and handles multi-device sessions, custom metadata, and state transitions out of the box.
Multi-device by default
Presence is grouped by user_id, not by connection. A user with multiple tabs or devices
appears once in the list. Join events fire only when their first connection
arrives; leave events fire only when their last connection exits.
Wire events sent to clients
Clients receive two presence message shapes:
{
"event": "presence_state",
"payload": {
"user_123": {
"metas": [
{ "phx_ref": "1", "state": "online", "name": "Alice" },
{ "phx_ref": "2", "state": "typing", "name": "Alice" }
]
},
"user_456": {
"metas": [{ "phx_ref": "3", "state": "online", "name": "Bob" }]
}
}
}
{
"event": "presence_diff",
"payload": {
"joins": { "user_789": { "metas": [{ "phx_ref": "4", "state": "online", "name": "Carol" }] } },
"leaves": { "user_456": { "metas": [{ "phx_ref": "3", "state": "online", "name": "Bob" }] } }
}
}
6 · Client-side JavaScript
No SDK — just the browser's built-in WebSocket object.
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${location.host}/ws/room/general`)
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
console.log('Received:', data)
}
// Send a chat message
ws.send(JSON.stringify({ text: 'Hello!' }))
// Typing indicators
ws.send(JSON.stringify({ event: 'typing' }))
ws.send(JSON.stringify({ event: 'stop_typing' }))
let presences = {}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.event === 'presence_state') {
presences = data.payload // initial sync — replace everything
renderUserList()
} else if (data.event === 'presence_diff') {
Object.entries(data.payload.joins).forEach(([userId, p]) => { presences[userId] = p })
Object.entries(data.payload.leaves).forEach(([userId]) => { delete presences[userId] })
renderUserList()
}
}
function renderUserList() {
const users = Object.entries(presences).map(([userId, { metas }]) => ({
userId,
...metas[0], // first meta drives display fields
connectionCount: metas.length
}))
// ...render users
}
7 · Server-side helper functions
These builtins let HTTP routes, background jobs, or other parts of your app reach into the WebSocket runtime — without having to be a WebSocket handler themselves.
Messaging
ws_send(connection_id, message)
Send a message to one specific connection. Returns null.
ws_send(conn_id, "Hello, client!")
ws_broadcast(message)
Send to every connected client. Returns null.
ws_broadcast("Server restarting in 5 min")
ws_broadcast_room(channel, message)
Send to every client subscribed to channel. Returns null.
ws_broadcast_room("room:lobby", payload)
ws_close(connection_id, reason)
Close a connection with a reason string. Returns null.
ws_close(conn_id, "Session expired")
Rooms
ws_join(channel)
Subscribe the current connection to a channel. Returns null.
ws_join("room:lobby")
ws_leave(channel)
Unsubscribe from a channel. Returns null.
ws_leave("room:lobby")
Inspection
ws_clients()
All connected client IDs. Returns Array<String>.
all = ws_clients()
print("Connected: " + str(len(all)))
ws_clients_in(channel)
Client IDs subscribed to channel. Returns Array<String>.
ws_clients_in("room:lobby")
ws_count()
Total active connections. Returns Int.
total = ws_count()
Presence
ws_list_presence(channel)
All users in a channel with their metadata. Returns Array<Hash>.
users = ws_list_presence("room:lobby")
ws_presence_count(channel)
Unique users in channel (not connections). Returns Int.
count = ws_presence_count("room:lobby")
ws_get_presence(channel, user_id)
Presence info for one user. Returns Hash or null.
user = ws_get_presence("room:lobby", "u_123")
8 · Performance & tips
Soli's WebSocket runtime is built on async Rust — async-channel fan-out, no per-message allocations on the hot path.
Optimization tips
- Prefer
sendoverbroadcastwhenever the recipient is known. - Use rooms to scope fan-out —
broadcast_roomis far cheaper than globalbroadcast. - Presence diffs only fire on join/leave/state-change — they don't poll.
- Pre-serialize broadcast payloads with
hash.to_jsononce; don't recompute per recipient.