> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xano.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Connecting a Realtime Client

> Connect a native WebSocket client to Realtime V2, wait for a channel join, and invoke a message handler.

<Info>
  This page covers **Realtime V2**. Legacy Realtime uses a different connection route and protocol; see [Realtime (Legacy)](/realtime/realtime-in-xano).
</Info>

## Prerequisites

* A realtime server, such as `chat` from [Servers & Channels](/realtime/realtime-servers-and-channels). This guide creates a separate demo channel on it.
* The server's **canonical**, copied from its settings or pulled XanoScript. Script references use the server name; the client URL uses the canonical.
* An instance with the V2 WebSocket service and `/ws/` route enabled.
* An application user JWT only if you configure the demo channel or handler to require authentication.

## Connection address

<CodeGroup>
  ```text Shape theme={null}
  wss://{instance-host}/ws/{server-canonical}
  ```

  ```text Example theme={null}
  wss://x8ki-letl-twmt.n7.xano.io/ws/67Dx5RNL
  ```
</CodeGroup>

The example address is illustrative; use your own instance and preserved server canonical. `/ws/` targets V2. `/rt/` targets legacy Realtime.

## Create a demo channel

Create this channel on the `chat` server. It allows anonymous test clients and uses `at_most_once` delivery:

```java XanoScript lines icon="code" theme={null}
channel "demo/{room_id}" {
  realtime_server = "chat"
  access = {anonymous: true, presence: false}
  publish = {who: "anyone", direct: false}
  delivery = {guarantee: "at_most_once", per_recipient: false}
  input {
    int room_id
  }
}
```

The minimal client below does not implement acknowledgement or replay handling for `at_least_once`. Use this demo channel instead of pointing it at a channel configured for durable delivery. For application use, choose [access rules](/realtime/access-control) appropriate to your users.

## Create an echo handler

Create this message on the `chat` server's `demo/{room_id}` channel:

```java XanoScript lines icon="code" theme={null}
message echo {
  realtime_server = "chat"
  channel = "demo/{room_id}"
  deliver_to = "sender"
  input {
    text body
  }
  stack {
    realtime.get_session as $session
  }
  response = {body: $input.body, session: $session}
}
```

The response goes only to the invoking client. Changing `deliver_to` to `channel` broadcasts the response to the subscribed clients; `others` excludes the sender.

## Connect and join

Save this module as `socket.js`. It sends a join request after opening, retries only the initial `Connection is not ready` error, and resolves `ready` only after the join acknowledgement. Other errors are surfaced rather than retried indefinitely.

```javascript socket.js theme={null}
// Native Realtime V2 protocol. Shared by the demo and the live verification.
export function connectClient({url, channel, token, onFrame = () => {}, onState = () => {}}) {
  const socket = token ? new WebSocket(url, [token]) : new WebSocket(url);
  let joined = false, attempts = 0, retry, heartbeat, deadline;
  let resolveReady, rejectReady;
  const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
  const send = frame => {
    if (socket.readyState !== WebSocket.OPEN) throw new Error('Socket is not open');
    socket.send(JSON.stringify(frame));
  };
  const clear = () => { clearTimeout(retry); clearTimeout(deadline); clearInterval(heartbeat); };
  const join = () => {
    if (socket.readyState !== WebSocket.OPEN || joined) return;
    attempts++;
    send({action: 'join', channel});
  };
  deadline = setTimeout(() => {
    rejectReady(new Error('Timed out joining the V2 channel'));
    clear(); socket.close();
  }, 10000);
  socket.addEventListener('open', () => { onState('Joining'); join(); });
  socket.addEventListener('message', event => {
    let frame;
    try { frame = JSON.parse(event.data); } catch { return; }
    onFrame(frame);
    if (!joined && frame.action === 'error') {
      if (frame.payload?.message === 'Connection is not ready' && attempts < 10) {
        clearTimeout(retry); retry = setTimeout(join, 250);
      } else {
        rejectReady(new Error(frame.payload?.message || 'Join refused'));
        clear(); socket.close();
      }
    }
    if (frame.action === 'join' && frame.channel === channel && frame.payload?.joined) {
      joined = true; clearTimeout(retry); clearTimeout(deadline);
      heartbeat = setInterval(() => { if (socket.readyState === WebSocket.OPEN) send({action:'ping'}); }, 20000);
      onState('Joined'); resolveReady(frame);
    }
  });
  socket.addEventListener('error', () => {
    rejectReady(new Error('WebSocket connection failed. Check the V2 /ws/ route and server canonical.'));
  });
  socket.addEventListener('close', event => {
    clear(); joined = false; onState('Offline');
    rejectReady(new Error(`Connection closed (${event.code})`));
  });
  return {
    ready, socket,
    get joined() { return joined; },
    broadcast(type, payload) {
      if (!joined) throw new Error('Join the channel before publishing');
      send({action: 'broadcast', channel, type, payload});
    },
    send,
    close() { clear(); socket.close(); }
  };
}
```

Import it from your frontend module and substitute your own connection address:

```javascript theme={null}
import {connectClient} from "./socket.js";

const client = connectClient({
  url: "wss://x8ki-letl-twmt.n7.xano.io/ws/67Dx5RNL",
  channel: "demo/42",
  onFrame(frame) {
    if (frame.action === "message") {
      console.log(frame.type, frame.payload);
    } else if (frame.action === "error") {
      console.error(frame.payload?.message);
    }
  }
});

try {
  await client.ready;
  client.broadcast("echo", {body: "Hello from the client"});
} catch (error) {
  client.close();
  console.error(error);
}

window.addEventListener("pagehide", () => client.close());
```

The demo channel allows anonymous access, so this call omits `token`. For an authenticated channel, add `token: userJwt` to the options using a token obtained from your application login flow. For a component-based application, also call `client.close()` during component teardown.

The echo arrives as `action: "message"`, `type: "echo"`, with the body and session in `payload`. A socket's `open` event alone is not a join acknowledgement, and a `broadcast` receipt is not a delivered message.

## Wire frames

<CodeGroup>
  ```json Join request theme={null}
  {"action":"join","channel":"demo/42"}
  ```

  ```json Join acknowledgement theme={null}
  {"action":"join","channel":"demo/42","payload":{"joined":true,"params":{"room_id":"42"}}}
  ```

  ```json Invoke echo theme={null}
  {"action":"broadcast","channel":"demo/42","type":"echo","payload":{"body":"Hello from the client"}}
  ```
</CodeGroup>

The invocation's `type` selects the named message handler; `payload` supplies its inputs. Use the resolved channel path (`demo/42`), not the declaration template (`demo/{room_id}`). Keep `type` at the top level of the frame.

## Authentication

The module passes an application user JWT as the WebSocket subprotocol, equivalent to:

```javascript theme={null}
const socket = new WebSocket(url, [userJwt]);
```

This is a user token from an auth-enabled table, not an administrative Metadata API token. Never put the CLI's administrative token in frontend code. Message-level authentication and channel join authorization are described in [Access Control](/realtime/access-control).

## Verify delivery

1. Open two independent clients and wait for both join acknowledgements.
2. Invoke a message with `deliver_to = "channel"`; verify that both clients receive its response.
3. Invoke the `echo` handler above; verify that only its sender receives it.
4. Publish from an API using [`realtime.publish`](/realtime/publishing-from-xano) and verify the incoming `message` frames on the subscribed clients.

Do not treat a successful import, an HTTP publish response, or a broadcast receipt as proof that a recipient received a message.

## Troubleshooting

| Symptom                                            | Check                                                                                                                                      |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| WebSocket upgrade returns HTML instead of HTTP 101 | Confirm the instance has the V2 service and `/ws/` route enabled. Importing V2 XanoScript alone does not provision a working socket route. |
| `Unknown connection hash`                          | Check the server canonical. A later push that omits it can regenerate the address; pull and preserve it.                                   |
| `Connection is not ready` immediately after open   | Retry the join with a bounded deadline, as the module does. Wait for its acknowledgement before sending messages.                          |
| Join is refused                                    | Check the resolved path, channel access settings, token, and join trigger decision. Surface `payload.message` from the error frame.        |
| A path parameter is text instead of a number       | Join acknowledgement and session `params` can contain strings despite the declared input type. Validate or convert before numeric use.     |

## Next steps

<CardGroup cols={2}>
  <Card title="Messages" icon="message" href="/realtime/messages">
    Define payloads and choose who receives the result.
  </Card>

  <Card title="Realtime Triggers" icon="bolt" href="/realtime/realtime-triggers">
    Use the complete join authorization example.
  </Card>
</CardGroup>


## Related topics

- [Connecting Clients](/ai-tools/mcp-builder/connecting-clients.md)
- [Realtime](/realtime/overview.md)
- [Realtime Servers & Channels](/realtime/realtime-servers-and-channels.md)
