ESC
Type to search...
S
Soli Docs

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.

Try Live Demo

1 · Quick start

A minimal echo server in three files. Copy, paste, run.

Step 1

Declare the route

Map a path to a controller action.

Step 2

Write the handler

Return a hash describing what to send back.

Step 3

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.

10k+
Messages / sec
<1ms
Median latency
Async
Non-blocking I/O

Optimization tips

  • Prefer send over broadcast whenever the recipient is known.
  • Use rooms to scope fan-out — broadcast_room is far cheaper than global broadcast.
  • Presence diffs only fire on join/leave/state-change — they don't poll.
  • Pre-serialize broadcast payloads with hash.to_json once; don't recompute per recipient.