Skip to main content

Real-Time Chat

The two windows below allow chatting with one another. Although they are on the same screen for the purposes of the demo, they each have their own isolated Hotsock connection over WebSockets and all messages are routed through the Hotsock installation before showing in the other window. Chat history is stored and persists across page loads.

Get the code for this example and run it yourself from the GitHub repository.

How it works

This example uses the @hotsock/hotsock-js client library to connect two users to the same channel, exchange messages in real time, and persist chat history using stored messages and the Client HTTP API. Below is a walkthrough of each piece.

Session persistence

Each chat session has an ID that maps to a Hotsock channel. The session ID is stored in both localStorage and the URL hash so that conversations persist across page loads and can be shared via link.

const STORAGE_KEY = "hotsock-chat-session"

function getSessionId() {
const hash = window.location.hash.slice(1)
if (hash) return hash
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) return stored
return null
}

function saveSessionId(id) {
localStorage.setItem(STORAGE_KEY, id)
window.location.hash = id
}

On load, the client checks for an existing session — first from the URL hash (for shared links), then from localStorage (for returning visitors). If neither exists, the backend generates a new session ID and the client saves it for next time.

Authentication

Before connecting, each user needs a connect token — a signed JWT that grants permission to subscribe to a channel, publish specific message events, and access message history.

For this demo, a backend endpoint issues tokens for two users (Jim and Pam) that include permissions for the same channel. A typical token payload looks like:

{
"exp": 1693963905,
"scope": "connect",
"uid": "jim",
"channels": {
"chat-abc123": {
"subscribe": true,
"historyStart": 0,
"messages": {
"chat": { "publish": true, "echo": true, "store": 86400 },
"is-typing": { "publish": true }
}
}
}
}

The uid claim identifies each user and is included in the metadata of every message they send. The channels claim grants subscribe access and permission to publish chat and is-typing events. Setting echo: true on chat means the sender receives their own message back, which simplifies rendering. The store: 86400 claim retains chat messages for 24 hours, and historyStart: 0 grants access to the full message history. The is-typing event is intentionally ephemeral — no store — since typing indicators are only relevant to live subscribers.

The client fetches tokens from the backend with a connectTokenFn — a function the Hotsock client calls whenever it needs a new or refreshed token:

const connectTokenFn = async () => {
const resp = await fetch("https://your-backend.example.com/tokens", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
})
const data = await resp.json()
return data.token
}

Connecting

Each user creates their own HotsockClient instance with the WebSocket URL and their token function:

import { HotsockClient } from "@hotsock/hotsock-js"

const client = new HotsockClient(
"wss://your-hotsock-url.execute-api.us-east-1.amazonaws.com/v1",
{ connectTokenFn }
)

The client handles the WebSocket lifecycle automatically — connecting, reconnecting on failure, and calling connectTokenFn to get fresh tokens as needed.

Loading message history

When the WebSocket connects, the hotsock.connected message provides connectionId and connectionSecret credentials. After subscribing to the channel, the client uses these credentials to call the Client HTTP API's connection/listMessages endpoint to load stored chat messages:

hotsockClient.bind("hotsock.connected", (message) => {
connectionInfo.current = {
connectionId: message.data.connectionId,
connectionSecret: message.data.connectionSecret,
}
})

channel.bind("hotsock.subscribed", () => {
if (connectionInfo.current) {
loadHistory(
connectionInfo.current.connectionId,
connectionInfo.current.connectionSecret
)
}
})

The listMessages endpoint returns up to 100 messages per request. If a full page is returned, the client paginates forward using the after parameter with the last message's ID until all history is loaded:

const loadHistory = async (connId, connSecret) => {
const params = new URLSearchParams({
connectionId: connId,
connectionSecret: connSecret,
})
const allMessages = []
let after = undefined
while (true) {
const body = { channel: channelName }
if (after) body.after = after
const resp = await fetch(
`${httpApiUrl}/connection/listMessages?${params}`,
{ method: "POST", body: JSON.stringify(body) }
)
const { messages } = await resp.json()
if (messages && messages.length > 0) {
allMessages.push(...messages)
if (messages.length >= 100) {
after = messages[messages.length - 1].id
continue
}
}
break
}
// Filter to chat messages only (skip is-typing) and render
setMessages(
allMessages
.filter((msg) => msg.event === "chat")
.map((msg) => ({
sender: msg.meta.uid,
content: msg.data,
time: formatTime(new Date(decodeUlidTime(msg.id))),
}))
)
}

History messages get their timestamps from the message ID — Hotsock message IDs are ULIDs, which encode the creation time in their first 10 characters. Only chat events are loaded; ephemeral is-typing events are not stored.

Subscribing and receiving messages

Once connected, subscribe to a channel by name. The hotsock.subscribed event fires when the subscription is confirmed, and then handlers for each event type process incoming messages:

const channel = client.channels("chat-abc123")

channel.bind("hotsock.subscribed", () => {
console.log("Subscribed! My uid:", channel.uid)
})

channel.bind("chat", (message) => {
setMessages((prev) => [
...prev,
{
sender: message.meta.uid,
content: message.data,
time: formatTime(new Date()),
},
])
})

channel.bind("is-typing", (message) => {
setIsTyping(`${message.meta.uid} is typing...`)
clearTimeout(typingTimeout)
typingTimeout = setTimeout(() => setIsTyping(""), 2000)
})

The channel.uid property contains the user's identifier from their token, making it easy to distinguish your own messages from others and align them left or right in the UI.

Sending messages

Publish messages to the channel using sendMessage. The first argument is the event name (which must match a permitted event in your token), and the second is an optional data payload:

const handleSend = (e) => {
if (e.key === "Enter" && e.target.value.trim() !== "") {
channel.sendMessage("chat", { data: e.target.value.trim() })
e.target.value = ""
}
}

Typing indicators

The demo throttles typing indicator events to avoid flooding the channel. A timestamp tracks when the last is-typing event was sent, and new events are only published if enough time has passed:

const lastSentTime = useRef(0)

const handleTyping = (e) => {
if (e.key === "Enter") return

const now = Date.now()
if (now - lastSentTime.current >= 1500) {
channel.sendMessage("is-typing")
lastSentTime.current = now
}
}

On the receiving end, typing indicators are shown temporarily and cleared after a short timeout. Since is-typing has no store claim, these events are purely ephemeral — only delivered to users who are currently connected.

Cleanup

When the component unmounts, terminate both clients to close the WebSocket connections:

useEffect(() => {
return () => {
jimClient.terminate()
pamClient.terminate()
}
}, [])

Putting it all together

The full flow is: the client resolves a session ID (from URL hash, localStorage, or a fresh one from the backend), connects both users with tokens granting publish and history access, loads existing chat messages by paginating through stored history via the Client HTTP API, then publishes and receives messages in real time. Chat messages are stored for 24 hours while typing indicators remain ephemeral. The session persists automatically — returning visitors see their previous conversation, and sharing the URL lets others join the same chat.

For a deeper dive into each concept, see the Client HTTP API documentation, the Message Storage (store) claim, the History Access (historyStart) claim, and the Client Messages documentation.