Socket.IO Integration
This guide describes how to use the real-time functionality in BAASIX using Socket.IO integration.
Table of Contents
- Overview
- Configuration
- WAL-Based Realtime (PostgreSQL)
- Available Events
- Client Integration
- Authentication
- Custom Rooms
- Custom Event Handlers
- Client Examples
Overview
BAASIX provides real-time functionality through Socket.IO integration powered by PostgreSQL's Write-Ahead Log (WAL) logical replication. This approach captures database changes at the database level, ensuring reliable and efficient real-time updates even when changes are made directly to the database.
Key Features
- WAL-Based: Uses PostgreSQL logical replication for reliable change capture
- Per-Collection Control: Enable/disable realtime per collection via schema settings
- Action Filtering: Choose which actions (insert/update/delete) to broadcast
- Custom Rooms: Create custom rooms for game lobbies, chat rooms, or any on-demand grouping
- Custom Handlers: Register server-side handlers for custom socket events (perfect for extensions)
Configuration
Environment Variables
# Enable Socket.IO functionality
SOCKET_ENABLED=true
# Optional: Configure Socket.IO path (default: /socket)
SOCKET_PATH=/realtime
# Optional: CORS origins for Socket.IO (comma-separated)
SOCKET_CORS_ENABLED_ORIGINS=https://yourdomain.com,https://app.yourdomain.com
# Redis configuration for Socket.IO clustering (optional)
SOCKET_REDIS_ENABLED=true
SOCKET_REDIS_URL=redis://localhost:6379
SOCKET_REDIS_KEY=socket.ioRedis Adapter for Scaling
BAASIX automatically configures Redis adapter for Socket.IO when enabled, allowing horizontal scaling across multiple server instances.
Multi-Instance WAL Consumption
PostgreSQL WAL replication slots only allow one consumer at a time. When running multiple instances:
- With Redis (
SOCKET_REDIS_ENABLED=true): BAASIX uses Redis-based leader election. Only one instance consumes WAL changes and broadcasts to all instances via Redis pub/sub. - Without Redis: Single instance mode - the instance directly consumes WAL changes.
Custom Rooms & History in Multi-Instance Mode
When Redis is enabled, custom room state and message history are stored in Redis instead of process memory — making them shared across all instances:
| Data | Single instance | With Redis |
|---|---|---|
| Room members + metadata | In-memory Map | Redis Hash (room:{name}:members) |
| Current creator | In-memory Map | Redis String (room:{name}:creator) |
| Original creator | In-memory Map | Redis String (room:{name}:original_creator) |
| Message history | In-memory array | Redis List (room:{name}:history, capped at 200) |
| Socket→rooms index | In-memory | Redis Set (socket:{id}:rooms) |
All room data is automatically deleted from Redis when the last member leaves.
# Multi-instance configuration
SOCKET_ENABLED=true
SOCKET_REDIS_ENABLED=true
SOCKET_REDIS_URL=redis://localhost:6379
SOCKET_REDIS_KEY=socket.ioThe leader election ensures:
- Only one instance connects to the WAL replication slot
- Automatic failover if the leader instance goes down
- Lock renewal every 10 seconds to maintain leadership
- Clean handoff when shutting down gracefully
WAL-Based Realtime (PostgreSQL)
BAASIX uses PostgreSQL's logical replication to capture database changes in real-time. This is more reliable than hook-based approaches because:
- Captures all changes - Including direct SQL updates, triggers, and other database-level modifications
- Transactional - Only broadcasts committed changes
- Efficient - Minimal overhead on your application
Enabling WAL Logical Replication
To use WAL-based realtime, your PostgreSQL server must have logical replication enabled:
1. Check Current Configuration
SHOW wal_level;
-- Should return 'logical'2. Enable Logical Replication
Edit your postgresql.conf:
# Required for WAL-based realtime
wal_level = logical
# Recommended settings
max_replication_slots = 10
max_wal_senders = 10Restart PostgreSQL after making changes.
3. For Cloud Providers
- AWS RDS/Aurora: Set
rds.logical_replicationto 1 in parameter group - Google Cloud SQL: Enable
cloudsql.logical_decodingflag - Azure Database: Set
azure.replication_supporttological - Supabase/Neon: Already enabled by default
Enabling Realtime on Collections
You can enable realtime via the Admin UI or API:
Via Admin UI
- Go to Schema Management
- Click on a collection's settings (gear icon)
- Enable Realtime toggle
- Select which actions to broadcast (Insert, Update, Delete)
- Save changes
Via API
# Enable realtime for a collection
POST /realtime/collections/:collection/enable
{
"actions": ["insert", "update", "delete"],
"replicaIdentityFull": true # Optional: Include old values on update/delete
}
# Disable realtime for a collection
POST /realtime/collections/:collection/disable
# Check realtime status
GET /realtime/status
# Get enabled collections
GET /realtime/collectionsVia Schema Definition
The realtime config is stored in the schema definition:
{
"name": "products",
"realtime": {
"enabled": true,
"actions": ["insert", "update", "delete"]
},
"fields": {
// ... fields
}
}Checking Replication Status
GET /realtime/configReturns:
{
"data": {
"walLevel": "logical",
"maxReplicationSlots": 10,
"maxWalSenders": 10,
"replicationSlotExists": true,
"publicationExists": true,
"tablesInPublication": ["baasix_Product", "baasix_Order"]
}
}Available Events
Collection Events (WAL-Based)
When realtime is enabled on a collection, events are emitted in the format {collection}:{action}:
products:insert- New record insertedproducts:update- Record updatedproducts:delete- Record deleted
Event payload structure:
{
action: "insert", // or "update", "delete"
collection: "products",
data: {
id: "uuid",
name: "Product Name",
price: 99.99,
// ... all fields
},
timestamp: "2026-01-09T10:30:00.000Z"
}System Events
connected- Emitted when authenticated and connectedworkflow:execution:update- Workflow execution progressworkflow:execution:complete- Workflow execution finished
Custom Room Events
| Event | Direction | Description |
|---|---|---|
room:user:joined | Server → Client | A user joined the room |
room:user:left | Server → Client | A user left or was kicked from the room |
room:kicked | Server → Client | You were kicked by the room creator |
room:creator:changed | Server → Client | Room ownership transferred to a new user |
room:{event} | Server → Client | Custom room message for the given event type |
Client Integration
Connecting to Socket
import { io } from 'socket.io-client';
const socket = io('http://your-api-domain', {
path: '/realtime',
auth: { token: 'your-jwt-token' },
transports: ['websocket', 'polling'],
});
socket.on('connect', () => {
console.log('Connected to BAASIX realtime');
});
socket.on('connected', (data) => {
console.log('Authenticated as:', data.userId);
});Subscribing to Collections
// Subscribe to a collection
socket.emit('subscribe', { collection: 'products' }, (response) => {
if (response.status === 'success') {
console.log('Subscribed to products');
}
});
// Listen for changes
socket.on('products:insert', (data) => {
console.log('New product:', data.data);
});
socket.on('products:update', (data) => {
console.log('Product updated:', data.data);
});
socket.on('products:delete', (data) => {
console.log('Product deleted:', data.data);
});
// Unsubscribe when done
socket.emit('unsubscribe', { collection: 'products' });Authentication
Socket connections require a valid JWT token:
const token = localStorage.getItem('auth_token');
const socket = io('http://your-api-domain', {
path: '/realtime',
auth: { token },
});Custom Rooms
BAASIX supports custom rooms for use cases like game lobbies, chat rooms, or collaborative editing. These are separate from collection subscriptions and can be created on-demand.
Room Ownership / Creator
Every room has a creator (owner):
- The first user to join a room becomes its creator.
- The creator's user ID is persisted. If the creator disconnects and a temporary owner takes over, the original creator automatically reclaims ownership when they rejoin.
- When the current creator leaves or disconnects and other members remain, ownership transfers to the next member and a
room:creator:changedevent is broadcast. - If the room becomes empty (last member leaves), the room is destroyed. The next user to join starts fresh as the new creator.
Joining a Room
Pass an optional metadata object to attach custom data (e.g. display name, avatar, team) to your membership. This data is stored server-side and returned whenever any member calls room:members.
The join acknowledgement includes a history array — the room's buffered messages — so late joiners can replay past messages immediately.
socket.emit(
'room:join',
{
room: 'game:lobby',
metadata: { username: 'Alice', avatar: 'https://example.com/alice.png', team: 'blue' },
},
(response) => {
if (response.status === 'success') {
console.log('Joined room:', response.room);
// Replay past messages for late joiners
response.history.forEach((msg) => {
// { event, payload, sender: { userId, socketId }, timestamp }
renderMessage(msg);
});
}
},
);
// Join without metadata
socket.emit('room:join', { room: 'game:lobby' }, (response) => {
if (response.status === 'success') console.log('Joined');
});Leaving a Room
socket.emit('room:leave', { room: 'game:lobby' }, (response) => {
console.log('Left room');
});Message History
Every room keeps an in-memory buffer of the last 200 messages (shared via Redis in multi-instance deployments). The buffer is returned when you join, so late joiners can catch up without any extra requests.
The buffer is cleared automatically when the room is destroyed (last member leaves).
socket.emit('room:join', { room: 'chat:general' }, (response) => {
// Render history first
response.history.forEach((msg) => {
appendMessage(msg.sender.userId, msg.payload, msg.timestamp);
});
});
// New messages arrive live as usual
socket.on('room:chat', (data) => {
appendMessage(data.sender.userId, data.payload, data.timestamp);
});Opting out of history storage
Pass history: false when sending ephemeral messages (cursor positions, typing indicators, presence pings) that should be broadcast but never stored:
// Stored in history (default) — replayed to future joiners
socket.emit('room:message', { room: 'chat:general', event: 'chat', payload: { text: 'Hello!' } });
// Ephemeral — broadcast only, never buffered
socket.emit('room:message', { room: 'game:lobby', event: 'cursor', payload: { x, y }, history: false });
socket.emit('room:message', { room: 'chat:general', event: 'typing', payload: { userId }, history: false });Any room member can request the current member list. Each entry includes the user ID, socket ID, whether the member is the room creator, and their metadata object.
socket.emit('room:members', { room: 'game:lobby' }, (response) => {
if (response.status === 'success') {
console.log('Members:', response.members);
// [
// { socketId: 'abc123', userId: 'user-1', isCreator: true, metadata: { username: 'Alice', team: 'blue' } },
// { socketId: 'def456', userId: 'user-2', isCreator: false, metadata: { username: 'Bob', team: 'red' } },
// ]
}
});Listing Rooms
Any authenticated socket can query the list of active rooms — no need to be a member. Pass an optional prefix string to filter by room name.
// All active rooms
socket.emit('room:list', {}, (response) => {
if (response.status === 'success') {
console.log(response.rooms);
// [{ name: 'game:lobby', memberCount: 4 }, { name: 'chat:general', memberCount: 12 }]
}
});
// Only rooms whose name starts with 'game:'
socket.emit('room:list', { prefix: 'game:' }, (response) => {
if (response.status === 'success') {
renderRoomBrowser(response.rooms);
}
});Only the room creator can kick other users. All sockets belonging to the target user are removed from the room and they receive a room:kicked event.
// Kick a user by their userId (creator only)
socket.emit('room:kick', { room: 'game:lobby', userId: 'target-user-id' }, (response) => {
if (response.status === 'success') {
console.log('User kicked');
} else {
console.error(response.message); // e.g. "Only the room creator can kick users"
}
});Listening for Room Events
// User joined the room
socket.on('room:user:joined', (data) => {
// { room, userId, socketId, metadata, timestamp }
console.log(`User ${data.userId} joined ${data.room}`, data.metadata);
});
// User left the room (voluntary or kicked)
socket.on('room:user:left', (data) => {
// { room, userId, socketId, timestamp }
console.log(`User ${data.userId} left ${data.room}`);
});
// You were kicked from the room
socket.on('room:kicked', (data) => {
// { room, kickedBy, timestamp }
console.log(`You were kicked from ${data.room} by user ${data.kickedBy}`);
});
// Room ownership changed
socket.on('room:creator:changed', (data) => {
// { room, newCreatorSocketId, newCreatorUserId, timestamp }
console.log(`New room owner: ${data.newCreatorUserId}`);
});
// Custom room messages (prefixed with room:)
socket.on('room:chat', (data) => {
// { room, event, payload, sender: { userId, socketId }, timestamp }
console.log(`${data.sender.userId}: ${data.payload.message}`);
});Sending Messages to a Room
Add history: false for ephemeral events that should not be replayed to future joiners.
// Stored in history (default)
socket.emit(
'room:message',
{ room: 'game:lobby', event: 'chat', payload: { message: 'Hello everyone!' } },
(response) => {
console.log('Message sent');
},
);
// Ephemeral — broadcast only, not stored
socket.emit('room:message', { room: 'game:lobby', event: 'typing', payload: { userId }, history: false });Room Example: Chat Room
// Join chat room — replay history for late joiners
socket.emit('room:join', { room: 'chat:general', metadata: { username: 'Alice' } }, (response) => {
// Show past messages immediately
response.history.forEach((msg) => addMessageToUI(msg.sender.userId, msg.payload.text));
});
// Fetch current members
socket.emit('room:members', { room: 'chat:general' }, ({ members }) => {
renderMemberList(members); // each member has .metadata.username etc.
});
// Track ownership changes
socket.on('room:creator:changed', ({ room, newCreatorUserId }) => {
updateOwnerBadge(room, newCreatorUserId);
});
// Send a persisted chat message
function sendMessage(text) {
socket.emit('room:message', { room: 'chat:general', event: 'chat', payload: { text } });
}
// Send an ephemeral typing indicator (not stored in history)
function sendTyping() {
socket.emit('room:message', { room: 'chat:general', event: 'typing', payload: { userId }, history: false });
}
// Receive messages
socket.on('room:chat', (data) => {
addMessageToUI(data.sender.userId, data.payload.text);
});Room Example: Game Lobby with Kick
// Join game lobby with player metadata
socket.emit('room:join', { room: `game:${gameId}`, metadata: { username: 'Alice', team: 'blue' } }, (response) => {
response.history.forEach((msg) => applyGameEvent(msg)); // replay game events
});
// Creator kicks a misbehaving player
function kickPlayer(userId) {
socket.emit('room:kick', { room: `game:${gameId}`, userId }, (response) => {
if (response.status !== 'success') alert(response.message);
});
}
// Handle being kicked
socket.on('room:kicked', ({ room, kickedBy }) => {
alert(`You were removed from the game by the host.`);
leaveGameUI();
});
// Send a persistent game-state event (stored in history)
socket.emit('room:message', { room: `game:${gameId}`, event: 'state', payload: { phase: 'battle' } });
// Send ephemeral player move (not stored in history)
socket.emit('room:message', { room: `game:${gameId}`, event: 'move', payload: { x: 10, y: 20 }, history: false });
socket.on('room:move', (data) => {
handlePlayerMove(data.sender.userId, data.payload);
});Custom Event Handlers
For extensions or advanced use cases, you can register custom event handlers on the server that clients can invoke:
Client Side
// Trigger a custom handler
socket.emit(
'custom',
{
event: 'game:roll-dice',
payload: { sides: 6 },
},
(response) => {
if (response.status === 'success') {
console.log('Dice result:', response.result);
}
},
);Server Side (Extensions)
// In your extension
import { SocketService } from '@baasix/baasix';
// Register a custom handler
SocketService.registerMessageHandler('game:roll-dice', async (socket, data, callback) => {
const sides = data.sides || 6;
const result = Math.floor(Math.random() * sides) + 1;
callback?.({ status: 'success', result });
// Optionally broadcast to a room
// SocketService.broadcastToRoom('game:123', 'dice:rolled', { player: socket.userId, result });
});
// Register a room validator (optional access control)
SocketService.registerRoomValidator('vip:', async (socket, roomName) => {
// Only VIP users can join VIP rooms
return socket.userRole.name === 'vip' || socket.userRole.name === 'administrator';
});Server-Side Broadcasting
import { SocketService } from '@baasix/baasix';
// Broadcast to a specific room
SocketService.broadcastToRoom('game:123', 'game:state', { phase: 'battle' });
// Broadcast to all connected users
SocketService.broadcastToAll('system:announcement', { message: 'Maintenance in 5 minutes' });
// Send to a specific user
SocketService.sendToUser(userId, 'private:notification', { text: 'You have a new message' });
// Room utilities (all async in multi-instance / Redis mode)
await SocketService.getRoomMembers('game:123'); // Array<{ socketId, userId, isCreator, metadata }>
await SocketService.getRoomMemberCount('game:123'); // number
await SocketService.roomExists('game:123'); // boolean
await SocketService.getCustomRooms(); // Map<roomName, memberCount>Client Examples
React Hook
import { useEffect, useState, useCallback } from 'react';
import { io } from 'socket.io-client';
function useRealtime(collection) {
const [socket, setSocket] = useState(null);
const [data, setData] = useState([]);
const [connected, setConnected] = useState(false);
useEffect(() => {
const token = localStorage.getItem('auth_token');
const newSocket = io('http://your-api-domain', {
path: '/realtime',
auth: { token },
});
newSocket.on('connect', () => setConnected(true));
newSocket.on('disconnect', () => setConnected(false));
// Subscribe to collection
newSocket.emit('subscribe', { collection });
// Listen for changes
newSocket.on(`${collection}:insert`, (payload) => {
setData((prev) => [...prev, payload.data]);
});
newSocket.on(`${collection}:update`, (payload) => {
setData((prev) => prev.map((item) => (item.id === payload.data.id ? payload.data : item)));
});
newSocket.on(`${collection}:delete`, (payload) => {
setData((prev) => prev.filter((item) => item.id !== payload.data.id));
});
setSocket(newSocket);
return () => {
newSocket.emit('unsubscribe', { collection });
newSocket.disconnect();
};
}, [collection]);
return { data, setData, connected, socket };
}
// Usage
function ProductList() {
const { data: products, connected } = useRealtime('products');
return (
<div>
<span>{connected ? '🟢 Live' : '🔴 Offline'}</span>
{products.map((p) => (
<div key={p.id}>{p.name}</div>
))}
</div>
);
}Vue Composable
import { ref, onMounted, onUnmounted } from 'vue';
import { io } from 'socket.io-client';
export function useRealtime(collection) {
const socket = ref(null);
const data = ref([]);
const connected = ref(false);
onMounted(() => {
const token = localStorage.getItem('auth_token');
socket.value = io('http://your-api-domain', {
path: '/realtime',
auth: { token },
});
socket.value.on('connect', () => (connected.value = true));
socket.value.on('disconnect', () => (connected.value = false));
socket.value.emit('subscribe', { collection });
socket.value.on(`${collection}:insert`, (payload) => {
data.value.push(payload.data);
});
socket.value.on(`${collection}:update`, (payload) => {
const idx = data.value.findIndex((i) => i.id === payload.data.id);
if (idx >= 0) data.value[idx] = payload.data;
});
socket.value.on(`${collection}:delete`, (payload) => {
data.value = data.value.filter((i) => i.id !== payload.data.id);
});
});
onUnmounted(() => {
if (socket.value) {
socket.value.emit('unsubscribe', { collection });
socket.value.disconnect();
}
});
return { data, connected, socket };
}Related Documentation
- Schema Reference - Collection schema configuration
- SDK Guide - Using the BAASIX JavaScript/TypeScript SDK
- Hooks and Endpoints - Server-side customization
- API Routes Reference - Complete API documentation