Developers · GraphQL
WebSockets Overview
Last updated 2 November 2026
Insites instances support WebSocket channels for live updates. A browser opens a WebSocket connection and subscribes to a channel and a room. Your instance can then send a JSON message to everyone in that room, from a page or from a background job.
The most common use is showing the result of slow work without making the visitor wait on a page request. A page queues a background job, the job calls a slow service, such as an AI model, and sends the answer to the visitor's room. See the full example below.
Connecting
Open the connection to
const socket = new WebSocket( 'wss://' + location.host + '/websocket?authenticity_token=' + encodeURIComponent(token), ['actioncable-v1-json'] );
Render the token into the page with
Subscribing to a channel
When the connection opens, the instance sends a
socket.send(JSON.stringify({
command: 'subscribe',
identifier: JSON.stringify({ channel: 'replies', room_id: room })
}));The instance answers with
Deciding who may subscribe
- Each channel needs a partial at
. It runs on every subscription. Output true to admit the subscriber. Any other output refuses them, and so does a missing partial. - The partial can read
, and . - It cannot read the visitor's session:
is empty, and so is for a visitor who is not signed in. So the room ID itself has to prove it was issued by your instance. The example signs it with and a secret constant. - Anyone who holds a valid room ID can subscribe to it. Treat it like a password: keep it out of addresses and logs.
- On new instances, every message the client sends over the subscription must carry the CSRF token. The token the connection was opened with counts.
- Clients on other domains can connect. The instance is found from the host name.
Sending a message
Send a message to everyone in a room on a channel with the
channel_send_message(
channel_name: String!
payload: JSONPayload!
room_id: String!
): StringThe mutation answers dispatched. A hash you pass as the payload arrives in the browser as a JSON object.
When a subscription is refused
A refused subscription receives a
Example: Show a Slow API Call's Result
This example asks a slow service a question and shows the answer when it is ready. The page returns at once; a background job makes the call, which may take up to 180 seconds, and sends the answer to the visitor's room.
We ran it on an instance with the service address pointed at a test service that answers after 8 seconds. The answer appeared 9.6 s after the visitor pressed Ask. A made-up room ID and an unsigned room ID were both refused.
It needs two constants: REPLY_ROOM_SECRET, any long random string, and ANSWER_SERVICE_KEY, the API key for your service. Set each with
app/views/pages/ask.liquid issues a signed room, keeps it in the session, renders the form and subscribes. The Ask button turns on once the subscription is confirmed.
---
slug: ask
---
{% liquid
assign room = context.session.reply_room
if room == blank
assign id = '' | uuid
assign signature = id | compute_hmac: context.constants.REPLY_ROOM_SECRET
assign room = id | append: '.' | append: signature
session reply_room = room
endif
%}
<form id="ask-form">
<label for="question">Your question</label>
<input id="question" name="question" required>
<button type="submit" disabled>Ask</button>
</form>
<p id="reply" aria-live="polite"></p>
<script>
const token = {{ context.authenticity_token | json }};
const identifier = JSON.stringify({ channel: 'replies', room_id: {{ room | json }} });
const form = document.getElementById('ask-form');
const reply = document.getElementById('reply');
const socket = new WebSocket(
'wss://' + location.host + '/websocket?authenticity_token=' + encodeURIComponent(token),
['actioncable-v1-json']
);
socket.onmessage = function (event) {
const msg = JSON.parse(event.data);
if (msg.type === 'welcome') {
socket.send(JSON.stringify({ command: 'subscribe', identifier: identifier }));
} else if (msg.type === 'confirm_subscription') {
form.querySelector('button').disabled = false;
} else if (msg.identifier === identifier && msg.message) {
reply.textContent = msg.message.text || msg.message.error;
}
};
form.addEventListener('submit', function (event) {
event.preventDefault();
reply.textContent = 'Working on it...';
fetch('/ask-start', {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: new URLSearchParams({ question: form.question.value, authenticity_token: token })
});
});
</script>app/views/pages/ask-start.liquid queues the job. The room comes from the session, not from the request, so a visitor can only queue work for their own room.
---
slug: ask-start
method: post
format: json
layout: ''
---
{% liquid
assign room = context.session.reply_room
if room == blank or context.params.question == blank
response_status 422
echo '{"queued": false}'
else
background job_id = 'ask/answer', room: room, question: context.params.question
echo '{"queued": true}'
endif
%}app/views/partials/channels/replies/subscribed.liquid admits a room only when its signature matches.
{% liquid
assign parts = context.params.room_id | split: '.'
assign expected = parts[0] | compute_hmac: context.constants.REPLY_ROOM_SECRET
if parts.size == 2 and parts[1] == expected
echo 'true'
else
echo 'false'
endif
%}app/views/partials/ask/answer.liquid is the background job. It calls the service and always sends something back, so the visitor is never left waiting.
{% liquid
assign payload = { "question": question }
assign body = payload | json
assign headers = { "Content-Type": "application/json" }
assign headers['Authorization'] = 'Bearer ' | append: context.constants.ANSWER_SERVICE_KEY
graphql r = 'ask/call_service', body: body, headers: headers
assign reply = {}
if r.api_call_send.response.status == 200
assign reply['text'] = r.api_call_send.response.body
else
assign reply['error'] = 'No answer this time. Please try again.'
endif
graphql sent = 'ask/send_reply', room: room, reply: reply
%}app/graphql/ask/call_service.graphql
mutation call_service($body: String!, $headers: HashObject) {
api_call_send(
api_call: { url: "https://api.example.com/answer", method: "POST", headers: $headers, body: $body }
options: { timeout: 120 }
) {
response { status body }
}
}app/graphql/ask/send_reply.graphql
mutation send_reply($room: String!, $reply: JSONPayload!) {
channel_send_message(channel_name: "replies", room_id: $room, payload: $reply)
}Server-Sent Events
See it working first: the Live Features Demo runs Server-Sent Events, background job progress, a shared live feed, a file upload and the log query on this site.
A page that only listens can subscribe with an
const identifier = JSON.stringify({ channel: 'replies', room_id: room });
const source = new EventSource('/anycable-events?identifier=' + encodeURIComponent(identifier)
+ '&authenticity_token=' + encodeURIComponent(token));
source.onmessage = function (event) { show(JSON.parse(event.data)); };- The identifier goes in the address as URL-encoded JSON, and the CSRF token goes in the same address as
. - The connection is read-only. There is no way to send, and a
partial is never called. is exactly the payload you sent. Over the WebSocket it arrives wrapped as . - A refused subscription answers with an error status (400 for a room your
partial refuses, 401 with no session or token) and closes the stream. The browser reports it as . - Don't rely on the browser's automatic reconnect. It reuses the same address and CSRF token, which stops working when the session changes. Open a new
instead.
Server-Sent Events are available on Insites instances from 15 October 2026. To check an instance, run
Example: Server-Sent Events with a WebSocket fallback
The example above only listens, so it can use Server-Sent Events. This version of its script opens an
<script>
const token = {{ context.authenticity_token | json }};
const identifier = JSON.stringify({ channel: 'replies', room_id: {{ room | json }} });
const form = document.getElementById('ask-form');
const reply = document.getElementById('reply');
const transport = document.getElementById('transport');
function ready(name) {
document.body.dataset.transport = name;
transport.textContent = 'Connected over ' + name;
form.querySelector('button').disabled = false;
}
function show(message) {
reply.textContent = message.text || message.error;
document.body.dataset.replied = 'true';
}
// Server-Sent Events first: read-only, which is all this page needs.
function connectEventSource() {
let confirmed = false;
const source = new EventSource('/anycable-events?identifier=' + encodeURIComponent(identifier)
+ '&authenticity_token=' + encodeURIComponent(token));
source.addEventListener('confirm_subscription', function () { confirmed = true; ready('Server-Sent Events'); });
source.onmessage = function (event) { show(JSON.parse(event.data)); };
source.onerror = function () {
source.close();
if (!confirmed) {
// Never subscribed: this instance does not serve SSE (or refused the room). Use the WebSocket.
document.body.dataset.sse = 'unavailable';
connectWebSocket();
} else {
// Dropped after it worked: reconnect with a fresh EventSource, never the browser's own retry.
setTimeout(connectEventSource, 2000);
}
};
}
function connectWebSocket() {
const socket = new WebSocket(
'wss://' + location.host + '/websocket?authenticity_token=' + encodeURIComponent(token),
['actioncable-v1-json']
);
socket.onmessage = function (event) {
const msg = JSON.parse(event.data);
if (msg.type === 'welcome') {
socket.send(JSON.stringify({ command: 'subscribe', identifier: identifier }));
} else if (msg.type === 'confirm_subscription') {
ready('WebSocket');
} else if (msg.identifier === identifier && msg.message) {
show(msg.message);
}
};
socket.onclose = function () { setTimeout(connectWebSocket, 2000); };
}
if (window.EventSource) { connectEventSource(); } else { connectWebSocket(); }
form.addEventListener('submit', function (event) {
event.preventDefault();
reply.textContent = 'Working on it...';
fetch('/ask-start', {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: new URLSearchParams({ question: form.question.value, authenticity_token: token })
});
});
</script>- Only an error before
means Server-Sent Events are not available. An error after it is a dropped connection, so the script opens a new .
What to Expect
- Most jobs started within a second of being queued. In our test, two jobs queued at the same moment as a third waited 20.5 and 22.2 seconds before they started.
- The timeout on an API call is the longest wait for the next part of the response. A service that streams its answer keeps the call going for as long as data keeps arriving. See System Limitations.
- The job receives the whole answer when the call ends, so it sends the answer in one message, not word by word.