Realtime Channels

Realtime Channels give your application bidirectional pub/sub messaging and presence tracking. Your server-side code publishes messages and manages presence; browsers connect via WebSocket and receive updates instantly.

// Server-side: publish a message to a channel
await platform.realtime.publish('chat:lobby', {
  text: 'Hello everyone!',
}, { userId: 'alice' });

// Server-side: check who's online
const members = await platform.realtime.presence.members('chat:lobby');

How It Works

  1. Your server-side code (SvelteKit, React Router, etc.) uses platform.realtime to publish messages and track presence
  2. Browsers connect to /_rt/ws via WebSocket and subscribe to channels
  3. When a message is published, all subscribed clients receive it instantly
  4. Presence tracks which users are in which channels, with automatic heartbeat expiry

Each project gets its own isolated set of channels. Messages are delivered reliably regardless of how many users are connected.

Quick Start

Server Side

Publish messages from your server routes or API handlers:

import { getPlatform } from '@maravilla-labs/platform';

const platform = getPlatform();

// In your API route handler
export async function POST({ request }) {
  const { text, roomId, userId } = await request.json();

  // Publish to the channel — all connected browsers will receive this
  await platform.realtime.publish(`room:${roomId}`, {
    type: 'chat',
    text,
    userId,
    timestamp: Date.now(),
  });

  return new Response(JSON.stringify({ ok: true }));
}

Client Side

Connect from the browser and subscribe to channels:

import { RealtimeClient } from '@maravilla-labs/platform';

const client = new RealtimeClient();
client.connect();

// Subscribe to a channel
const unsubscribe = client.subscribe('room:general', (event) => {
  console.log(event.data.text); // "Hello everyone!"
  console.log(event.userId);    // "alice"
});

// Publish from the client (relayed through the server)
client.publish('room:general', { text: 'Hi from browser!' });

// Later: clean up
unsubscribe();
client.disconnect();

Server-Side API

Access realtime features through platform.realtime in your server-side code.

publish(channel, data, options?)

Publishes a message to a channel. All connected clients subscribed to this channel will receive it.

Parameters:

  • channel (string) — the channel name (e.g. chat:lobby, game:room-42)
  • data (any) — the message payload (serialized as JSON)
  • options (object, optional):
    • userId (string) — identifies who sent the message
// Simple message
await platform.realtime.publish('notifications', {
  title: 'New comment',
  body: 'Alice replied to your post',
});

// Message with sender identity
await platform.realtime.publish('chat:general', {
  text: 'Hello!',
}, { userId: 'alice' });

channels()

Returns a list of currently active channel names for the current tenant.

const activeChannels = await platform.realtime.channels();
// ['chat:general', 'chat:support', 'game:room-1']

presence.join(channel, userId, metadata?)

Marks a user as present in a channel. Broadcasts a presence.join event to all subscribers.

Parameters:

  • channel (string) — the channel name
  • userId (string) — unique user identifier
  • metadata (any, optional) — arbitrary data attached to the user’s presence (e.g. display name, avatar)

Returns: true if this is a new join, false if the user was already present.

const isNew = await platform.realtime.presence.join('chat:lobby', 'alice', {
  name: 'Alice',
  avatar: '/avatars/alice.png',
});

presence.update(channel, userId, metadata)

Updates the metadata for a user already present in a channel. Broadcasts a presence.update event without triggering a leave/join cycle.

Returns: true if the user was present and updated, false if they weren’t in the channel.

// Update a user's status or state
await platform.realtime.presence.update('chat:lobby', 'alice', {
  name: 'Alice',
  status: 'away',
});

presence.leave(channel, userId)

Removes a user from a channel’s presence. Broadcasts a presence.leave event.

Returns: true if the user was present, false if they weren’t.

await platform.realtime.presence.leave('chat:lobby', 'alice');

presence.members(channel)

Returns all users currently present in a channel. Stale members (no heartbeat within 30 seconds) are automatically expired.

Returns: Array of presence members.

const members = await platform.realtime.presence.members('chat:lobby');
// [
//   { userId: 'alice', metadata: { name: 'Alice' }, lastSeen: 1710000000 },
//   { userId: 'bob', metadata: null, lastSeen: 1710000005 }
// ]

Client-Side API

Import RealtimeClient from the platform package to connect via WebSocket.

Creating a Client

import { RealtimeClient } from '@maravilla-labs/platform';

const client = new RealtimeClient({
  debug: false,            // enable console logging
  autoReconnect: true,     // reconnect on disconnect (default: true)
  maxBackoffMs: 15000,     // max reconnect delay (default: 15s)
});

client.connect();

The client auto-detects the correct WebSocket endpoint based on the runtime environment.

subscribe(channel, callback)

Subscribes to messages on a channel. Returns an unsubscribe function.

const unsubscribe = client.subscribe('chat:general', (event) => {
  console.log(event.event);   // "realtime.message"
  console.log(event.channel); // "chat:general"
  console.log(event.data);    // { text: "Hello!" }
  console.log(event.userId);  // "alice"
  console.log(event.ts);      // 1710000000000
});

// Stop listening
unsubscribe();

publish(channel, data, options?)

Publishes a message through the WebSocket connection.

client.publish('chat:general', {
  text: 'Hello from browser!',
}, { userId: 'bob' });

presence(channel)

Returns a presence handle for managing and observing channel presence.

const presence = client.presence('chat:lobby');

// Join with metadata
presence.join('alice', { name: 'Alice', status: 'online' });

// Update metadata in-place (no leave/join cycle)
presence.update({ name: 'Alice', status: 'away' });

// Listen for others joining
const offJoin = presence.onJoin((member) => {
  console.log(`${member.userId} joined`, member.metadata);
});

// Listen for metadata updates
const offUpdate = presence.onUpdate((member) => {
  console.log(`${member.userId} updated`, member.metadata);
});

// Listen for others leaving
const offLeave = presence.onLeave((member) => {
  console.log(`${member.userId} left`);
});

// Leave when done
presence.leave();
offJoin();
offUpdate();
offLeave();

onAny(callback)

Listens to all events across all subscribed channels:

client.onAny((event) => {
  console.log(`[${event.channel}] ${event.event}:`, event.data);
});

isConnected()

Returns true if the WebSocket connection is currently open.

disconnect()

Closes the WebSocket connection and stops reconnecting.

Presence

Presence tracks which users are currently active in a channel. It works across both server and client:

  • Server-side: Call presence.join() when a user enters a page or starts an action. Call presence.update() to change their metadata (e.g. status, activity). Call presence.leave() when they leave. Query presence.members() to render an online users list.
  • Client-side: Use the presence() handle to join, update metadata, leave, and listen for join/update/leave events from others.

Heartbeat Expiry

Presence members are automatically expired if they haven’t been seen within 30 seconds. This handles cases where a client disconnects without sending a leave message (e.g. browser crash, network loss).

The WebSocket connection sends periodic pings that keep the presence alive. If the connection drops, the member is expired after the TTL.

WebSocket Protocol Reference

GET /_rt/ws

Upgrades to a WebSocket connection.

Query Parameters:

  • cid — client ID for correlation (auto-generated if omitted)
  • tenant — tenant identifier (auto-resolved in production)

Client Messages

All messages are JSON objects with an action field:

// Subscribe to a channel
{ "action": "subscribe", "channel": "chat:lobby" }

// Unsubscribe from a channel
{ "action": "unsubscribe", "channel": "chat:lobby" }

// Publish a message
{ "action": "publish", "channel": "chat:lobby", "data": { "text": "hello" }, "userId": "alice" }

// Join presence
{ "action": "presence:join", "channel": "chat:lobby", "userId": "alice", "metadata": { "name": "Alice" } }

// Update presence metadata
{ "action": "presence:update", "channel": "chat:lobby", "userId": "alice", "metadata": { "name": "Alice", "status": "away" } }

// Leave presence
{ "action": "presence:leave", "channel": "chat:lobby" }

// Keepalive
{ "action": "ping" }

Server Messages

// Connection established
{ "event": "connected", "data": { "clientId": "abc123" }, "ts": 1710000000000 }

// Subscription confirmed
{ "event": "subscribed", "channel": "chat:lobby", "ts": 1710000000000 }

// Channel message
{ "event": "realtime.message", "channel": "chat:lobby", "data": { "text": "hello" }, "userId": "alice", "ts": 1710000000000 }

// Presence events
{ "event": "presence.join", "channel": "chat:lobby", "userId": "alice", "metadata": { "name": "Alice" }, "ts": 1710000000000 }
{ "event": "presence.update", "channel": "chat:lobby", "userId": "alice", "metadata": { "name": "Alice", "status": "away" }, "ts": 1710000000000 }
{ "event": "presence.leave", "channel": "chat:lobby", "userId": "alice", "ts": 1710000000000 }

// Keepalive response
{ "event": "pong", "ts": 1710000000000 }

Example: Live Chat

A complete chat feature using SvelteKit with server-side message handling and client-side real-time updates.

Server Route

// src/routes/api/chat/[room]/+server.ts
import { getPlatform } from '@maravilla-labs/platform';

const platform = getPlatform();

export async function POST({ params, request }) {
  const { text, userId, userName } = await request.json();

  // Store in database
  await platform.DB.messages.insertOne({
    room: params.room,
    text,
    userId,
    userName,
    createdAt: Date.now(),
  });

  // Broadcast to channel — all connected clients see it instantly
  await platform.realtime.publish(`chat:${params.room}`, {
    type: 'message',
    text,
    userId,
    userName,
    createdAt: Date.now(),
  });

  return new Response(JSON.stringify({ ok: true }));
}

export async function GET({ params }) {
  const messages = await platform.DB.messages.find(
    { room: params.room },
    { limit: 50, sort: { createdAt: -1 } }
  );

  const members = await platform.realtime.presence.members(`chat:${params.room}`);

  return new Response(JSON.stringify({ messages, members }));
}

Server Presence Hook

// src/routes/api/chat/[room]/join/+server.ts
import { getPlatform } from '@maravilla-labs/platform';

const platform = getPlatform();

export async function POST({ params, request }) {
  const { userId, userName } = await request.json();

  await platform.realtime.presence.join(`chat:${params.room}`, userId, {
    name: userName,
  });

  return new Response(JSON.stringify({ ok: true }));
}

Client Component

// src/lib/Chat.svelte (script section)
import { RealtimeClient } from '@maravilla-labs/platform';
import { onMount, onDestroy } from 'svelte';

let messages = $state([]);
let onlineMembers = $state([]);
let input = $state('');
const roomId = 'general';

const client = new RealtimeClient();

onMount(async () => {
  // Load initial messages
  const res = await fetch(`/api/chat/${roomId}`);
  const data = await res.json();
  messages = data.messages;
  onlineMembers = data.members;

  // Connect and subscribe
  client.connect();

  client.subscribe(`chat:${roomId}`, (event) => {
    if (event.data?.type === 'message') {
      messages = [...messages, event.data];
    }
  });

  // Track presence
  const presence = client.presence(`chat:${roomId}`);
  presence.onJoin((member) => {
    onlineMembers = [...onlineMembers, member];
  });
  presence.onLeave((member) => {
    onlineMembers = onlineMembers.filter(m => m.userId !== member.userId);
  });

  // Join presence via server
  await fetch(`/api/chat/${roomId}/join`, {
    method: 'POST',
    body: JSON.stringify({ userId: 'current-user', userName: 'You' }),
  });
});

onDestroy(() => {
  client.disconnect();
});

async function sendMessage() {
  if (!input.trim()) return;
  await fetch(`/api/chat/${roomId}`, {
    method: 'POST',
    body: JSON.stringify({ text: input, userId: 'current-user', userName: 'You' }),
  });
  input = '';
}

Rich Messages

The data field in every message is arbitrary JSON — you define your own message types. For images, files, or other binary content, upload via Storage first, then publish a message with the URL reference.

// Server-side: handle image upload and broadcast
export async function POST({ params, request }) {
  const platform = getPlatform();
  const formData = await request.formData();
  const file = formData.get('image');
  const userId = formData.get('userId');

  // Upload to storage
  const key = `chat/${params.room}/${Date.now()}.jpg`;
  await platform.storage.put(key, await file.arrayBuffer(), {
    contentType: file.type,
  });
  const url = await platform.storage.generateDownloadUrl(key);

  // Publish rich message — any JSON structure you want
  await platform.realtime.publish(`chat:${params.room}`, {
    type: 'image',
    url,
    caption: formData.get('caption') || '',
    sender: userId,
  });

  return new Response(JSON.stringify({ ok: true }));
}

Common message type patterns:

// Text
{ type: 'text', text: 'Hello!', sender: 'alice' }

// Image
{ type: 'image', url: 'https://...', caption: 'Check this out', sender: 'alice' }

// File attachment
{ type: 'file', url: 'https://...', name: 'report.pdf', size: 12345, sender: 'bob' }

// Reaction
{ type: 'reaction', emoji: '👍', targetId: 'msg-123', sender: 'alice' }

// Typing indicator
{ type: 'typing', sender: 'bob' }

Private Channels

Channels with a private- prefix require a token to subscribe or publish. Public channels (any other name) are open to all.

Server-side: generate a token

const platform = getPlatform();

// Generate a token that lets alice subscribe and publish to this channel
const token = await platform.realtime.createChannelToken('private-room:vip', {
  userId: 'alice',
  permissions: ['subscribe', 'publish'],
  expiresIn: 3600, // 1 hour
});

// Return token to the client
return new Response(JSON.stringify({ token }));

Client-side: pass the token

const client = new RealtimeClient();
client.connect();

// Fetch token from your server
const { token } = await fetch('/api/chat/vip/token').then(r => r.json());

// Subscribe with the token
client.subscribe('private-room:vip', (event) => {
  console.log(event.data);
}, { token });

Without a valid token, subscribing to a private- channel returns an error event:

{ "event": "error", "channel": "private-room:vip", "data": { "code": "unauthorized" } }

Message History

When history is enabled, channel messages are persisted and can be replayed. Useful for chat catch-up, audit trails, or reconnecting clients.

Server-side: query history

const platform = getPlatform();

// Get the last 50 messages from a channel
const messages = await platform.realtime.history('chat:general', {
  limit: 50,
});

// Get messages after a specific sequence number (for pagination)
const older = await platform.realtime.history('chat:general', {
  limit: 20,
  after: lastSeenSeq,
});

Client-side: catch-up on reconnect

When subscribing with a lastEventId, the server replays missed messages before delivering live events:

// Store the last sequence number you received
let lastSeq = 0;

client.subscribe('chat:general', (event) => {
  if (event.data?.seq) lastSeq = event.data.seq;
  renderMessage(event.data);
}, { lastEventId: lastSeq });

History is stored in the platform database (same as platform.DB) and automatically trimmed to the most recent 1,000 messages per channel.

Development vs. Production

DevelopmentProduction
WebSocket endpointws://localhost:3001/_rt/wswss://yourapp.maravilla.page/_rt/ws
DetectionAutomatic (Vite port 5173 connects to dev server port 3001)Automatic (relative URL)
SetupWorks out of the box with maravilla devWorks out of the box on Maravilla Cloud

The RealtimeClient auto-detects the correct endpoint. Your code works the same in both environments.

Limits

These limits are enforced automatically. Exceeding them returns an error event to the client.

FreeBuilderProEnterprise
Max channels1050200Unlimited
Max message size64 KB128 KB512 KB1 MB
Publishes per minute5005,00050,000Unlimited
Message history100 messages1,0001,0001,000

Next Steps

  • Real-Time Events (REN) — SSE-based resource change notifications (KV, DB, storage)
  • KV Store — store chat messages, user profiles, or session data
  • Database — persist messages with full query support