ESC
Type to search...
S
Soli Docs

WebSockets

Build real-time, interactive experiences. Soli provides native WebSocket support with Phoenix-inspired presence tracking for chat apps, notifications, and live collaboration.

Want to see it in action?

Check out the interactive WebSocket chat demo included in this app.

Try Live Demo

Defining a Route

Register WebSocket endpoints in config/routes.sl.

# Map path to handler function
router_websocket("/ws/chat", "websocket#chat_handler");

# With dynamic segments
router_websocket("/ws/room/:room_id", "websocket#room_handler");

Handling Events

Handlers receive connection events and return actions.

def chat_handler(event: Any)    let type = event["type"];
    let id = event["connection_id"];

    if (type == "connect")
        return { "broadcast": json_encode({ "type": "join", "user": id }) };
    end

    if (type == "message")
        let msg = event["message"];
        return { "broadcast": msg };
    end

    {}
end

The Event Object

type

Event kind: "connect", "message", or "disconnect".

connection_id

Unique UUID string identifying the client connection.

message

The text payload sent by the client (only for "message").

Response Actions

Broadcast

Send a message to ALL connected clients (including sender).

{"broadcast": "Hello All"}

Send (Reply)

Send a message ONLY to the client who triggered the event.

{"send": "Hello You"}

Rooms & Channels

Organize connections into channels for targeted messaging. Use join and leave actions to manage room membership.

def room_handler(event: Any)    let room = "room:" + event["params"]["room_id"];

    if (event["type"] == "connect")
        # Join the room channel on connect
        return { "join": room };
    end

    if (event["type"] == "message")
        let data = JSON.parse(event["message"]);
        # Broadcast only to users in this room
        return { "broadcast_room": json_encode(data) };
    end

    # Leave is automatic on disconnect
    {}
end
join

Subscribe to a channel: {"join": "room:lobby"}

leave

Unsubscribe from a channel: {"leave": "room:lobby"}

broadcast_room

Send to all in the joined channel.

Presence Tracking

Phoenix-inspired presence tracking enables real-time awareness of users in rooms. Track who's online, their status (typing, away), and handle multi-device scenarios gracefully.

Multi-Device Support

Presence is grouped by user_id, not connection. A user with multiple tabs/devices appears once in the user list, and join events only fire when their first connection enters. Leave events only fire when their last connection exits.

def chat_handler(event: Any)    let room = "room:general";
    # Get user from session/auth (example)
    let user = get_current_user();

    if (event["type"] == "connect")
        return {
            "join": room,
            "track": {
                "channel": room,
                "user_id": user["id"],      # Required - groups connections
                "name": user["name"],       # Extra metadata
                "avatar": user["avatar"]
            }
        };
    end

    if (event["type"] == "message")
        let data = JSON.parse(event["message"]);

        # Handle typing indicators
        if (data["event"] == "typing")
            return { "set_presence": { "channel": room, "state": "typing" } };
        end
        if (data["event"] == "stop_typing")
            return { "set_presence": { "channel": room, "state": "online" } };
        end

        # Regular messages
        return { "broadcast_room": json_encode(data) };
    end

    # Disconnect auto-untracks and broadcasts leave diff
    {}
end

Presence Actions

track

Start tracking presence with metadata. Requires channel and user_id.

{"track": {"channel": "room:lobby", "user_id": "123", "name": "Alice"}}
untrack

Manually stop tracking (auto on disconnect).

{"untrack": "room:lobby"}
set_presence

Update state (typing, away, online).

{"set_presence": {"channel": "room:lobby", "state": "typing"}}

Client-Side Presence Events

Clients receive two types of presence messages:

{
  "event": "presence_state",
  "payload": {
    "user_123": {
      "metas": [
        { "phx_ref": "1", "state": "online", "name": "Alice", "avatar": "/alice.png" },
        { "phx_ref": "2", "state": "typing", "name": "Alice", "avatar": "/alice.png" }
      ]
    },
    "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" }] }
    }
  }
}
let presences = {};

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);

    if (data.event === 'presence_state') {
        // Initial state - replace all
        presences = data.payload;
        renderUserList();
    }
    else if (data.event === 'presence_diff') {
        // Apply diff
        Object.entries(data.payload.joins).forEach(([userId, presence]) => {
            presences[userId] = presence;
        });
        Object.entries(data.payload.leaves).forEach(([userId]) => {
            delete presences[userId];
        });
        renderUserList();
    }
};

function renderUserList() {
    const users = Object.entries(presences).map(([userId, { metas }]) => ({
        userId,
        ...metas[0],  // Use first meta for display
        connectionCount: metas.length
    }));
    // Render users...
}

Server-Side Presence Functions

Query presence state from your handlers:

# Get all users in a room
let users = ws_list_presence("room:lobby");
# Returns: [{ user_id: "123", metas: [...] }, ...]

# Get user count (unique users, not connections)
let count = ws_presence_count("room:lobby");
# Returns: 5

# Get a specific user's presence
let user = ws_get_presence("room:lobby", "user_123");
# Returns: { user_id: "123", metas: [...] } or null

Client-Side Code

// Connect to WebSocket
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/ws/chat`);

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('Received:', data);
};

ws.send(JSON.stringify({ text: 'Hello!' }));

// Send typing indicator
ws.send(JSON.stringify({ event: 'typing' }));

// Stop typing
ws.send(JSON.stringify({ event: 'stop_typing' }));

Performance

10k+
Messages / Sec
<1ms
Latency
Async
Non-blocking I/O

Optimization Tips

  • Use send instead of broadcast whenever possible.
  • Use rooms to scope broadcasts - broadcast_room is cheaper than global broadcast.
  • Presence diffs are only sent when users join/leave, not for every connection.

Server-Side Helper Functions

These functions can be called from HTTP routes or other parts of your application to interact with WebSocket connections.

ws_send(connection_id, message)

Send a message to a specific WebSocket connection.

ws_send("uuid-1234", json_encode({ "type": "notification" }));

ws_broadcast(message)

Broadcast a message to all connected clients.

ws_broadcast(json_encode({ "type": "announcement" }));

ws_broadcast_room(channel, message)

Broadcast to all clients in a specific channel.

ws_broadcast_room("room:lobby", json_encode({ "type": "chat" }));

ws_count()

Get total number of active connections.

let total = ws_count();

ws_join(channel)

Join current connection to a channel.

ws_join("room:lobby");

ws_leave(channel)

Leave a channel.

ws_leave("room:lobby");

ws_clients()

Get all connected client IDs.

let clients = ws_clients();

ws_clients_in(channel)

Get all client IDs in a channel.

let room_clients = ws_clients_in("room:lobby");

ws_close(connection_id, reason)

Close a connection with a reason.

ws_close("uuid-1234", "Session expired");

ws_list_presence(channel)

Get all users in a channel with presence data.

let users = ws_list_presence("room:lobby");

ws_presence_count(channel)

Get unique user count in a channel.

let count = ws_presence_count("room:lobby");

ws_get_presence(channel, user_id)

Get presence data for a specific user.

let user = ws_get_presence("room:lobby", "user_123");