Subscriptions
Listen to real-time database changes with onSnapshot. Use client.db(namespace).table(name) to access the correct DB block.
Table Subscription
- JavaScript
- Dart/Flutter
- Swift
- Kotlin
- Java
- C#
- C++
const unsubscribe = client.db('shared').table('posts').onSnapshot((event) => {
if (event.type === 'added') {
console.log('New post:', event.data);
} else if (event.type === 'modified') {
console.log('Updated:', event.data);
} else if (event.type === 'removed') {
console.log('Deleted:', event.data);
}
});
// Stop listening
unsubscribe();
final subscription = client.db('shared').table('posts').onSnapshot((event) {
if (event.type == ChangeType.added) {
print('New post: ${event.data}');
}
});
// Stop listening
subscription.cancel();
let subscription = client.db("shared").table("posts").onSnapshot { event in
switch event.type {
case .added: print("New post: \(event.data)")
case .modified: print("Updated: \(event.data)")
case .removed: print("Deleted: \(event.data)")
}
}
subscription.cancel()
val subscription = client.db("shared").table("posts").onSnapshot { event ->
when (event.type) {
"added" -> println("New: ${event.data}")
"modified" -> println("Updated: ${event.data}")
"removed" -> println("Deleted: ${event.data}")
}
}
subscription.cancel()
Subscription sub = client.db("shared").table("posts").onSnapshot(event -> {
System.out.println(event.getType() + ": " + event.getData());
});
// Later: sub.cancel();
var sub = client.Db("shared").Table("posts").OnSnapshot(change => {
if (change.Type == "added")
Console.WriteLine($"New post: {change.Data}");
});
// Stop listening
sub.Cancel();
int subId = client.realtime().onSnapshot("posts", [](const eb::DbChange& change) {
if (change.changeType == "added")
std::cout << "New post: " << change.dataJson << std::endl;
});
// Stop listening
client.realtime().unsubscribe(subId);
Document Subscription
Subscribe to changes on a single document:
- JavaScript
- Dart/Flutter
- Swift
- Kotlin
- Java
- C#
- C++
const unsubscribe = client.db('shared').table('posts').doc('post-id').onSnapshot((event) => {
console.log('Document changed:', event.data);
});
final subscription = client.db('shared').table('posts').doc('post-id').onSnapshot((event) {
print('Document changed: ${event.data}');
});
let subscription = client.db("shared").table("posts").doc("post-id").onSnapshot { event in
print("Document changed: \(event.data)")
}
val subscription = client.db("shared").table("posts").doc("post-id").onSnapshot { event ->
println("Document changed: ${event.data}")
}
Subscription sub = client.db("shared").table("posts").doc("post-id").onSnapshot(event -> {
System.out.println("Document changed: " + event.getData());
});
var sub = client.Db("shared").Table("posts").Doc("post-id").OnSnapshot(change => {
Console.WriteLine($"Document changed: {change.Data}");
});
int subId = client.realtime().onDocSnapshot("posts", "post-id", [](const eb::DbChange& change) {
std::cout << "Document changed: " << change.dataJson << std::endl;
});
When a change occurs, both the table-level subscription and the document-level subscription receive the event simultaneously (dual propagation).
Filtered Subscriptions
Filter events using where(). EdgeBase supports two filtering modes:
Client-Side Filtering (Default)
The SDK receives all events and filters locally. No additional configuration needed:
const unsubscribe = client.db('shared').table('posts')
.where('status', '==', 'published')
.onSnapshot((event) => {
// Only receives events for published posts
});
Server-Side Filtering
For high-traffic tables, enable server-side filtering so the server only sends matching events. This reduces bandwidth and client-side processing:
const unsubscribe = client.db('shared').table('posts')
.where('status', '==', 'published')
.onSnapshot((event) => {
// Server only sends events where status == 'published'
}, { serverFilter: true });
Server-side filters support AND conditions, OR conditions, 8 comparison operators, and runtime updates. See Server-Side Filters for the full guide.
Realtime subscriptions are only available in client SDKs (JavaScript, Dart, Swift, Kotlin, C#, C++). Server-only Admin SDKs do not support onSnapshot. Use server-side broadcast instead.
Event Types
Every onSnapshot callback receives an event with one of three types:
| Type | Description | event.data |
|---|---|---|
added | New document created | Full document |
modified | Existing document updated | Full document (after update) |
removed | Document deleted | null |
Authentication
WebSocket connections require JWT authentication. The SDK handles this automatically — after connecting, it sends an auth message before any subscriptions. See Architecture for protocol details.
Key behaviors:
- Timeout: 5000ms default (configurable via
realtime.authTimeoutMs) - Token refresh: The SDK automatically sends refreshed tokens. The server re-evaluates all subscriptions and revokes any that are no longer authorized.
subscription_revokedevent: Listen globally to handle permission changes:
client.realtime.on('subscription_revoked', ({ channel }) => {
console.warn('Subscription revoked for channel:', channel);
});
See Access Rules for the full re-auth flow.
Token Refresh and Revoked Channels
When a client's auth token is refreshed on a long-lived WebSocket connection, the server re-evaluates channel access. The response includes any channels the client lost access to:
{
"type": "auth_refreshed",
"userId": "user-123",
"revokedChannels": ["db:private-table"]
}
| Field | Description |
|---|---|
revokedChannels | List of channels the client lost access to after token refresh |
- The client should handle this by removing subscriptions for revoked channels
- This occurs when user roles or permissions change while the client is connected
- If no channels are revoked,
revokedChannelsis an empty array - If the refresh fails, existing auth is preserved and a non-fatal error is returned
Batch Changes
When many changes occur simultaneously (e.g., bulk operations), the server batches them into a single batch_changes message instead of individual events. The batch threshold is 10 changes by default (configurable via realtime.batchThreshold).
{
"type": "batch_changes",
"channel": "realtime:shared:posts",
"changes": [
{ "event": "modified", "docId": "post_1", "data": { "title": "Updated" } },
{ "event": "modified", "docId": "post_2", "data": { "title": "Also updated" } }
],
"total": 150
}
Your onSnapshot callback receives each change individually — the SDK unpacks batch events automatically.
SDK Version Negotiation
The SDK sends its version during authentication (sdkVersion field). The server uses this to determine batch support:
- Modern SDKs: Receive
batch_changesmessages (unpacked automatically by the SDK) - Legacy SDKs: Continue to receive individual events (backward compatible)
High-Frequency Update Pattern
For scenarios with very frequent updates (e.g., real-time analytics), consider debouncing your UI updates:
let pending: SnapshotEvent[] = [];
client.db('shared').table('metrics').onSnapshot((event) => {
pending.push(event);
requestAnimationFrame(() => {
if (pending.length > 0) {
renderUpdates(pending);
pending = [];
}
});
});
Type-Safe Subscriptions
Use the generic parameter on onSnapshot<T>() with types generated by npx edgebase typegen:
import type { Post } from './edgebase.d.ts';
// Typed subscription — change.data is Post | null
const unsub = client.realtime.onSnapshot<Post>('posts', (change) => {
console.log(change.data?.title); // ✅ TypeScript autocomplete
});
The edgebase typegen command generates interfaces from your edgebase.config.ts schema:
npx edgebase typegen -o edgebase.d.ts
This creates types like:
export interface Post {
id: string;
createdAt: string;
updatedAt: string;
title: string;
content?: string;
}
Keepalive (Ping/Pong)
The SDK sends periodic ping messages to keep the WebSocket connection alive and update the server's lastSeen timestamp for presence tracking:
// Client sends:
{ "type": "ping" }
// Server responds:
{ "type": "pong" }
| Setting | Value |
|---|---|
| Recommended ping interval | 30 seconds |
| Presence TTL | 60 seconds (configurable via presenceTTL) |
- Each
pingupdates thelastSeentimestamp for the connection's presence entry - If no
pingis received within the TTL window (default 60s), the connection's presence is cleaned up andpresence_leaveis broadcast withreason: "timeout" - The SDK handles ping/pong automatically -- no application code is needed
Connection Management
- Auto-reconnect — Reconnects automatically on disconnection with exponential backoff
- Namespace-aware — Use
client.db(namespace, id?)to route subscriptions to the correct DB block - Tab token sync — Auth/token state is synchronized across browser tabs
- Hibernation recovery — Idle WebSocket connections cost $0 via Cloudflare's Hibernation API. On wake-up, the server sends
FILTER_RESYNCandPRESENCE_RESYNCmessages; the SDK automatically re-registers filters and presence state. See Architecture for details.