Links#
https://centrifugal.dev/docs/getting-started/quickstart
https://centrifugal.dev/docs/server/authentication
https://centrifugal.dev/docs/server/server_api
https://centrifugal.dev/docs/transports/client_api
https://github.com/centrifugal/centrifuge-js
https://www.npmjs.com/package/centrifuge1. Goal#
Build a local real-time demo:
Centrifugo:
runs on localhost:8000
accepts browser WebSocket connections
exposes server API for backend publish
Browser JavaScript client:
uses centrifuge JavaScript SDK
connects through ws://localhost:8000/connection/websocket
subscribes to chat:index
receives backend-published messages
Token:
generated by a local Node.js script
signed with the same dev token secret as Centrifugosecurity lesson:
this demo generates JWT locally for convenience
real project must generate JWT in backend
browser must never receive client.token.hmac_secret_key or http_api.key2. Start Centrifugo#
Create local config:
mkdir -p /tmp/centrifugo-demo
cd /tmp/centrifugo-democat > config.json <<'EOF'
{
"client": {
"token": {
"hmac_secret_key": "dev-token-secret-change-me"
},
"allowed_origins": ["http://localhost:5173"]
},
"http_api": {
"key": "dev-api-key-change-me"
},
"prometheus": {
"enabled": true
},
"channel": {
"namespaces": [
{
"name": "chat",
"allow_subscribe_for_client": true,
"presence": true,
"history_size": 20,
"history_ttl": "300s"
}
]
}
}
EOFRun container:
docker run --rm \
-d \
--name centrifugo-demo \
--ulimit nofile=262144:262144 \
-p 8000:8000 \
-v "$PWD/config.json:/centrifugo/config.json:ro" \
centrifugo/centrifugo:v6 \
centrifugo --config=/centrifugo/config.jsonVerify:
docker logs --tail 50 centrifugo-demo
curl -i http://localhost:8000/healthFollow logs when debugging:
docker logs -f centrifugo-demoStop:
docker stop centrifugo-demo3. Create Browser Project#
Use Vite with plain JavaScript:
cd /tmp/centrifugo-demo
npm create vite@latest browser-client -- --template vanilla
cd browser-client
npm install
npm install centrifuge jsonwebtokenplatform choice:
client SDK platform: JavaScript browser
package: centrifuge
bundler: Vite4. Generate Dev Token#
Create a small token generator. This simulates what your backend should do.
Make sure jsonwebtoken is installed in the same directory where you run node generate-token.cjs:
cd /tmp/centrifugo-demo/browser-client
npm install jsonwebtokencat > generate-token.cjs <<'EOF'
const jwt = require("jsonwebtoken");
const secret = "dev-token-secret-change-me";
const now = Math.floor(Date.now() / 1000);
const token = jwt.sign(
{
sub: "user-1001",
exp: now + 60 * 60
},
secret,
{ algorithm: "HS256" }
);
console.log(token);
EOFGenerate token:
node generate-token.cjsIf this fails with Cannot find module 'jsonwebtoken', run npm install jsonwebtoken in the current directory, then run the script again.
Put the output into an env file:
cat > .env.local <<'EOF'
VITE_CENTRIFUGO_URL=ws://localhost:8000/connection/websocket
VITE_CENTRIFUGO_TOKEN=paste-token-here
EOFreal project:
browser calls /api/realtime/token on your backend
backend checks current user/session
backend signs short-lived JWT
browser uses the returned token to connect5. Browser Code#
Replace src/main.js:
import "./style.css";
import { Centrifuge } from "centrifuge";
const app = document.querySelector("#app");
app.innerHTML = `
<main class="page">
<h1>Centrifugo Browser Demo</h1>
<section class="panel">
<div class="row">
<span>Status</span>
<strong id="status">idle</strong>
</div>
<div class="row">
<span>Channel</span>
<strong>chat:index</strong>
</div>
</section>
<section class="panel">
<h2>Messages</h2>
<ul id="messages"></ul>
</section>
</main>
`;
const statusEl = document.querySelector("#status");
const messagesEl = document.querySelector("#messages");
function appendMessage(text) {
const item = document.createElement("li");
item.textContent = text;
messagesEl.prepend(item);
}
const centrifuge = new Centrifuge(import.meta.env.VITE_CENTRIFUGO_URL, {
token: import.meta.env.VITE_CENTRIFUGO_TOKEN
});
centrifuge.on("connecting", (ctx) => {
statusEl.textContent = `connecting: ${ctx.reason}`;
});
centrifuge.on("connected", (ctx) => {
statusEl.textContent = `connected: ${ctx.client}`;
});
centrifuge.on("disconnected", (ctx) => {
statusEl.textContent = `disconnected: ${ctx.reason}`;
});
const sub = centrifuge.newSubscription("chat:index");
sub.on("publication", (ctx) => {
appendMessage(JSON.stringify(ctx.data));
});
sub.on("subscribed", () => {
appendMessage("subscribed to chat:index");
});
sub.on("error", (ctx) => {
appendMessage(`subscription error: ${ctx.message}`);
});
sub.subscribe();
centrifuge.connect();Replace src/style.css:
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f6f7f9;
color: #1f2933;
}
.page {
max-width: 760px;
margin: 48px auto;
padding: 0 20px;
}
.panel {
background: #ffffff;
border: 1px solid #d9dee7;
border-radius: 8px;
padding: 18px;
margin-top: 16px;
}
.row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 8px 0;
}
ul {
padding-left: 20px;
}
li {
margin: 8px 0;
overflow-wrap: anywhere;
}Run browser app:
npm run devOpen:
http://localhost:5173Expected:
Status changes to connected
Messages shows subscribed to chat:index6. Publish From Backend Side#
Publish through Centrifugo server API:
curl -s http://localhost:8000/api/publish \
-H "Content-Type: application/json" \
-H "X-API-Key: dev-api-key-change-me" \
-d '{
"channel": "chat:index",
"data": {
"text": "hello browser",
"created_at": "'$(date -u +%FT%TZ)'"
}
}'Expected browser message:
{"text":"hello browser","created_at":"2026-06-15T00:00:00Z"}7. Token Refresh Pattern#
When using short-lived JWT, browser should refresh through backend.
const centrifuge = new Centrifuge("wss://realtime.example.com/connection/websocket", {
token: initialToken,
getToken: async () => {
const response = await fetch("/api/realtime/token", {
method: "POST",
credentials: "include"
});
if (!response.ok) {
throw new Error("failed to refresh Centrifugo token");
}
const body = await response.json();
return body.token;
}
});Backend endpoint rule:
POST /api/realtime/token:
requires normal app session/cookie/access token
verifies user is still active
signs new short-lived Centrifugo JWT
returns only token, never signing secret8. Real Project Wrapper#
Create a small wrapper instead of spreading Centrifugo calls across UI code.
import { Centrifuge } from "centrifuge";
export function createRealtimeClient({ url, token, refreshToken }) {
const centrifuge = new Centrifuge(url, {
token,
getToken: refreshToken
});
return {
connect() {
centrifuge.connect();
},
disconnect() {
centrifuge.disconnect();
},
subscribe(channel, onMessage) {
const subscription = centrifuge.newSubscription(channel);
subscription.on("publication", (ctx) => onMessage(ctx.data));
subscription.subscribe();
return () => {
subscription.unsubscribe();
};
}
};
}Usage:
const realtime = createRealtimeClient({
url: import.meta.env.VITE_CENTRIFUGO_URL,
token: initialToken,
refreshToken: fetchRealtimeToken
});
realtime.connect();
const unsubscribe = realtime.subscribe("chat:index", (message) => {
console.log("received", message);
});9. Common Mistakes#
browser stores http_api.key:
wrong
API key is for trusted backend only
browser signs JWT:
wrong
signing secret must stay in backend/secret manager
wildcard client.allowed_origins in production:
risky
use exact frontend origins
channel authorization only in frontend:
wrong
frontend checks are UX only; backend must enforce access
publish to wrong channel name:
symptom:
API returns success but browser receives nothing
verify:
browser subscription channel equals publish channel exactly
running multiple Centrifugo nodes without engine:
symptom:
clients connected to another node do not receive all publications
fix:
configure supported engine for multi-node deployment