Real-Time Chat
The two windows below allow chatting with one another and reacting to individual messages. 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 and reactions are routed through the Hotsock installation before showing in the other window. Chat history and reaction counts are stored and persist 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 and reactions 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,
"react": ["❤️", "👍", "👎", "😂"]
},
"hotsock.messageReaction": { "echo": true },
"is-typing": { "publish": true }
}
}
}
}
The uid claim identifies each user and is included in the metadata of every message and reaction 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. Setting echo: true on hotsock.messageReaction means the sender also receives their own hotsock.messageReactionAdded and hotsock.messageReactionRemoved events, so their reaction counts and active state update through the same real-time path as every other subscriber. The store: 86400 claim retains chat messages for 24 hours, and historyStart: 0 grants access to the full message history. The react claim allows reactions on stored chat messages and restricts the allowed values to the four reactions shown by the UI: ❤️, 👍, 👎, and 😂. 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 and their reactions:
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. The demo passes expandReactions: true so each message includes reaction counts and the reacting users. 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, expandReactions: true }
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) => ({
id: msg.id,
sender: msg.meta.uid,
content: msg.data,
reactions: msg.reactions || {},
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. The message ID is also required when adding or removing a reaction, because a hotsock.messageReaction event targets one stored message by ID. 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,
{
id: message.id,
sender: message.meta.uid,
content: message.data,
reactions: {},
time: formatTime(new Date()),
},
])
})
channel.bind("hotsock.messageReactionAdded", (message) => {
setMessages((prev) => updateMessageReaction(prev, message, "add"))
})
channel.bind("hotsock.messageReactionRemoved", (message) => {
setMessages((prev) => updateMessageReaction(prev, message, "remove"))
})
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.
Adding and removing reactions
Reactions are sent as hotsock.messageReaction events. Reaction authorization comes from the react directive on the stored message type being reacted to, not from publish: true on hotsock.messageReaction. The connection token should set echo: true on hotsock.messageReaction; without echo, other subscribers will see the reaction in real time, but the sender will not receive the reaction event that updates their own local UI. The payload identifies the stored message to update, the reaction value, and whether the user is adding or removing it:
channel.sendMessage("hotsock.messageReaction", {
data: {
messageId: message.id,
reaction: "❤️",
action: "add",
},
})
To remove a reaction, send the same payload with action: "remove":
channel.sendMessage("hotsock.messageReaction", {
data: {
messageId: message.id,
reaction: "❤️",
action: "remove",
},
})
The example keeps the UI close to Apple Messages behavior by allowing each user to have one active reaction on a message at a time. If the user clicks a different reaction, the client removes the previous reaction first and then adds the new one:
const REACTIONS = ["❤️", "👍", "👎", "😂"]
const handleReaction = (message, reaction) => {
const currentReaction = userReaction(message, channel.uid)
if (currentReaction) {
channel.sendMessage("hotsock.messageReaction", {
data: {
messageId: message.id,
reaction: currentReaction,
action: "remove",
},
})
}
if (currentReaction !== reaction) {
channel.sendMessage("hotsock.messageReaction", {
data: {
messageId: message.id,
reaction,
action: "add",
},
})
}
}
Hotsock persists the reaction and fans out either hotsock.messageReactionAdded or hotsock.messageReactionRemoved to all channel subscribers. Those real-time events include data.messageId, data.reaction, and meta.uid, which is enough to update the local reaction counts and active state without reloading history:
function updateMessageReaction(messages, reactionMessage, action) {
const { messageId, reaction } = reactionMessage.data
const uid = reactionMessage.meta.uid
return messages.map((message) => {
if (message.id !== messageId) return message
const reactions = { ...message.reactions }
const existing = reactions[reaction] || { count: 0, items: [] }
const items = existing.items || []
const alreadyReacted = items.some((item) => item.uid === uid)
if (action === "add" && !alreadyReacted) {
const nextItems = [...items, { uid, umd: reactionMessage.meta.umd }]
reactions[reaction] = {
count: Math.max(existing.count + 1, nextItems.length),
items: nextItems,
}
}
if (action === "remove") {
const nextItems = items.filter((item) => item.uid !== uid)
const nextCount = Math.max(0, existing.count - (alreadyReacted ? 1 : 0))
if (nextCount === 0) {
delete reactions[reaction]
} else {
reactions[reaction] = {
count: nextItems.length || nextCount,
items: nextItems,
}
}
}
return { ...message, reactions }
})
}
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, reaction, and history access, loads existing chat messages by paginating through stored history via the Client HTTP API with expandReactions: true, then publishes and receives messages and reactions in real time. Chat messages are stored for 24 hours while typing indicators remain ephemeral. Reactions attach to stored chat messages, inherit the parent message lifetime, and are visible immediately to live subscribers and later to users who reload the same session. The session persists automatically — returning visitors see their previous conversation and past reactions, 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, the Message Reactions (react) claim, and the Client Messages documentation.