HandsOn


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/centrifuge

1. 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 Centrifugo
security 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.key

2. Start Centrifugo#

Create local config:

mkdir -p /tmp/centrifugo-demo
cd /tmp/centrifugo-demo
cat > 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"
      }
    ]
  }
}
EOF

Run 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.json

Verify:

docker logs --tail 50 centrifugo-demo
curl -i http://localhost:8000/health

Follow logs when debugging:

docker logs -f centrifugo-demo

Stop:

docker stop centrifugo-demo

3. 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 jsonwebtoken
platform choice:
    client SDK platform: JavaScript browser
    package: centrifuge
    bundler: Vite

4. 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 jsonwebtoken
cat > 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);
EOF

Generate token:

node generate-token.cjs

If 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
EOF
real 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 connect

5. 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 dev

Open:

http://localhost:5173

Expected:

Status changes to connected
Messages shows subscribed to chat:index

6. 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 secret

8. 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