Real-time Subscriptions
Looking for get/set/update/remove/query? Those work the same on client and server — see CRUD Operations and Query Operations. The subscriptions below build on those primitives to deliver live updates.
Important: All subscription functions (getSub, getSubChanged,
querySub, and querySubChanged) immediately send the current data when the
subscription is created. This ensures your UI can display the current state right away, before any changes
occur.
Basic Subscriptions
Get real-time updates when data changes. All subscription functions immediately send the current data when the subscription is created, then continue to send updates whenever the data changes:
// Subscribe to changes on a path
const unsubscribe = getSub({
event: "value@",
path: ["users", "john"]
}, event => {
// This fires immediately with current data, then on every change
console.log("User data:", event.data);
});
// When finished listening
unsubscribe();
Query Subscriptions
Subscribe to data matching specific conditions:
// Subscribe to active users
const unsubscribe = querySub({
event: "value@",
path: ["users"],
query: "child.status == 'online'"
}, event => {
// Receives all currently online users immediately, then updates
const onlineUsers = event.data;
updateOnlineUsersList(onlineUsers);
});
Windowed Subscriptions
querySub and querySubChanged take the same window parameters as query() — orderBy, desc, limit, startAt and cursor. The server re-runs the window on every write beneath path and pushes the current contents, so a live leaderboard or a "latest 50" feed is one subscription rather than a poll loop.
const unsubscribe = querySub({
path: ["players"],
query: "child.active == true",
orderBy: ["score"],
desc: true,
limit: 10
}, event => {
// Fires immediately with the current top 10, then on every write
// beneath ["players"] that changes it.
// Read them IN ORDER through event.order — object key order is not
// something to depend on.
renderLeaderboard(event.order.map(k => event.data[k]));
});
// A live feed of the 50 newest items, as a delta stream
const unsub2 = querySubChanged({
path: ["events"],
query: "true",
orderBy: ["ts"],
desc: true,
limit: 50
}, event => {
// The FIRST payload is the whole window — a starting state to merge into.
// Every payload after that contains only the records this write touched
// AND that fall inside the window.
Object.assign(feed, event.data);
});
Subscribers asking for the same window share one query run. The server buckets subscriptions by their exact window, and a bucket costs one query and one serialization per write no matter how many sessions are in it. A thousand clients watching the same leaderboard cost the same as one. That sharing is the whole performance model — which is why every reply echoes its window back (childPath, query, orderBy, desc, limit, startAt, cursor), so the SDK routes a notification to exactly the handler that asked for it.
A windowed subscription over a large collection needs an index. With an index on the orderBy field the server binary-searches to the window and walks limit entries — cheap and bounded, per write. Without one it is a full scan plus a sort of the entire collection, on every write, and the client has no way to see that it asked for that.
So above 1,000 records, the engine refuses a windowed subscription that would not get an index-order plan, and the failure message names the field to declare:
A windowed subscription over 48,210 records needs an index on ["score"].
Without one every write beneath this path would re-scan and re-sort the whole
collection for this subscription. Add "index": [["score"]] to this path in
rules.js, or drop orderBy/limit and use querySubChanged.
Smaller collections are waved through — scanning a few hundred records costs less than an unwindowed querySub, which has always been allowed. See Indexes.
count is query-only. It carries no data, and it would fire on every write beneath the path even when the number hadn't moved. Subscriptions reject it.
Subscription Limits
A querySub matching more than 1,000 records is refused — and dropped if it grows past that later. Every write beneath the path would re-send the entire result set, so a broad querySub over a big collection is a fan-out amplifier. The server answers with status: "Failed" and an explanation:
querySub matches 12,480 records, over the 1000 limit. Every write beneath this
path would re-send all of them. Narrow the query, add a limit, or use
querySubChanged, which sends only the records a write touched.
If an already-running subscription crosses the threshold, subscribers get the same notice and the subscription is removed rather than left re-sending. Handle a status: "Failed" payload in your handler.
Other limits:
- 256 subscriptions per WebSocket session. Exceeding it throws
Subscription limit exceeded. - 64 distinct query-subscription windows per (path, event, action). Joining an existing bucket is free; opening a new distinct window adds a query run to every future write on that path, so the count of distinct windows is bounded directly.
- 16 MB per WebSocket message, and 500,000 messages per second per connection before the socket is closed with a
rateLimitnotice.
Query Subscriptions with childPath
Just like regular queries, subscriptions can use childPath to subscribe only to specific nested portions of your data:
// Subscribe to public profiles only (excludes private data)
const unsubscribe = querySub({
event: "value@",
path: ["users"],
childPath: ["public"],
query: "child.verified == true"
}, event => {
// Receives the public subtree wrapped under its childPath key,
// mirroring the DB shape. "private" is never traversed or sent.
// Response: { matt123: { public: { verified: true, name: "Matt", ... } } }
displayVerifiedUsers(event.data);
});
// Subscribe to inventory changes for low stock items
const unsubscribe2 = querySub({
event: "value@",
path: ["products"],
childPath: ["inventory"],
query: "child.stock < 10"
}, event => {
// Only the inventory subtree is fetched and pushed; product details
// outside "inventory" never enter the payload.
// Response: { productA: { inventory: { stock: 5, ... } } }
showLowStockAlert(event.data);
});
// Use with querySubChanged for efficient updates
const unsubscribe3 = querySubChanged({
event: "value@",
path: ["users"],
childPath: ["profile"],
query: "child.country == 'USA'"
}, event => {
// Only fires when USA profiles change
// Only returns the profile portion that changed
console.log("Updated USA profiles:", event.data);
});
Benefits of childPath with subscriptions:
- Reduced bandwidth: Only transmit the data portions you need
- Narrower payload: Fields outside the childPath are never walked or sent — but this is about shape and bandwidth, not access control. The subscription is authorized once at the collection level; see Query authorization.
- Performance: Smaller payloads mean faster real-time updates
- Clean data: Clients receive exactly the structure they expect
Changed-Only Subscriptions
Despite the name, these subscriptions ALSO receive the initial data immediately when created, then only fire again when data actually changes:
Important for getSubChanged and querySubChanged: What you receive depends on what path you're watching:
- If watching "users" and John updates his name, you get John's COMPLETE object (all fields)
- If watching "users.john" and a field changes, you get ONLY the changed field (e.g., just {name: "New Name"})
- If watching "users.john.name" and it changes, you get just the new name value
- The deeper your watch path, the more specific the change data
// getSubChanged - watching a collection
const unsubscribe = getSubChanged({
event: "value@",
path: ["users"]
}, event => {
// Initial: all users
// If John updates his email:
// event.data = { john: { name: "John", email: "new@email.com", age: 25 } }
// You get John's COMPLETE object
updateChangedUsers(event.data);
});
// getSubChanged - watching a specific user
const unsubscribe2 = getSubChanged({
event: "value@",
path: ["users", "john"]
}, event => {
// Initial: John's complete data
// If John's email changes:
// event.data = { email: "new@email.com" }
// You get ONLY the changed field
Object.assign(currentUser, event.data); // Merge changes
});
// getSubChanged - watching a specific field
const unsubscribe3 = getSubChanged({
event: "value@",
path: ["users", "john", "status"]
}, event => {
// Initial: "online"
// If status changes:
// event.data = "offline"
// You get just the new value
updateStatusIndicator(event.data);
});
// With query filtering - returns only the changed items
const unsubscribe4 = querySubChanged({
event: "value@",
path: ["users"],
query: "child.age > 21"
}, event => {
// If user John (age 25) updates only his name:
// event.data = { john: { name: "John Doe", age: 25, email: "john@example.com" } }
// You get John's COMPLETE object, not just the changed name field
console.log("Users that changed:", event.data);
});
// Example: monitoring low stock products
const unsubscribe5 = querySubChanged({
event: "value@",
path: ["products"],
query: "child.stock < 5"
}, event => {
// If product ABC updates its price, you get:
// { ABC: { name: "Widget", stock: 3, price: 29.99 } }
// The complete product object for ONLY the product that changed
Object.keys(event.data).forEach(productId => {
updateSingleProduct(productId, event.data[productId]);
});
});
Operation-Specific Subscriptions
Listen for specific types of operations by prefixing your path with an operation type:
Available operation types:
value@- Fires on any change (set, update, or remove)set@- Fires only when data is created or completely replacedupdate@- Fires only when existing data is partially updatedremove@- Fires only when data is deleted
Compatibility: Operation prefixes work with all subscription functions:
getSub, getSubChanged, querySub, and querySubChanged.
// Listen only for updates to user data
const unsubscribe = getSub({
event: "update@",
path: ["users", "john"]
}, event => {
console.log("User was updated:", event.data);
});
// Listen for new data being set
const unsubscribe2 = getSub({
event: "set@",
path: ["orders"]
}, event => {
console.log("New order created:", event.data);
});
// Listen for data removal
const unsubscribe3 = getSub({
event: "remove@",
path: ["users"]
}, event => {
console.log("A user was deleted:", event.path);
});
// Operation-specific with getSubChanged
const unsubscribe4 = getSubChanged({
event: "set@",
path: ["products"]
}, event => {
// Only fires when NEW products are created (not updates)
console.log("New products added:", event.data);
});
// Operation-specific with queries
const unsubscribe5 = querySub({
event: "update@",
path: ["users"],
query: "child.status == 'premium'"
}, event => {
// Only fires when premium users are UPDATED (not created or deleted)
console.log("Premium users updated:", event.data);
});
// Combining with querySubChanged
const unsubscribe6 = querySubChanged({
event: "remove@",
path: ["tasks"],
query: "child.completed == true"
}, event => {
// Only fires when completed tasks are DELETED
console.log("Completed tasks removed:", event.data);
});
// Default behavior without prefix (same as value@)
const unsubscribe7 = getSub({
path: ["users", "john"]
}, event => {
// Fires on ANY change: set, update, or remove
// event parameter defaults to "value@" if not specified
console.log("Something changed:", event.data);
});
Subscription Bubble-Up Behavior
Understanding how subscription changes propagate is crucial for designing efficient real-time applications. NukeBase subscriptions follow a "bubble-up" pattern:
Key Concept: Changes Bubble UP, Not DOWN
- Bubble UP ✅: Changes at child paths trigger parent subscriptions
- No Trickle DOWN ❌: Changes at parent paths do NOT trigger child subscriptions
// Set up subscriptions at different levels
getSub({
event: "value@",
path: ["calls"]
}, (event) => {
console.log("1. Calls level:", event.data);
});
getSub({
event: "value@",
path: ["calls", "123"]
}, (event) => {
console.log("2. Specific call:", event.data);
});
getSub({
event: "value@",
path: ["calls", "123", "answer"]
}, (event) => {
console.log("3. Answer level:", event.data);
});
// Scenario 1: Change at deep level (bubbles UP)
await set(["calls", "123", "answer"], { type: "answer", sdp: "..." });
// ✅ Fires: 1. Calls level (bubbled up)
// ✅ Fires: 2. Specific call (bubbled up)
// ✅ Fires: 3. Answer level (direct match)
// Scenario 2: Change at middle level (bubbles UP, not DOWN)
await update(["calls", "123"], { status: "active" });
// ✅ Fires: 1. Calls level (bubbled up)
// ✅ Fires: 2. Specific call (direct match)
// ❌ NOT fired: 3. Answer level (no trickle down)
// Scenario 3: Change at top level (no trickle DOWN)
await set(["calls"], { "456": { offer: {...} } });
// ✅ Fires: 1. Calls level (direct match)
// ❌ NOT fired: 2. Specific call (no trickle down)
// ❌ NOT fired: 3. Answer level (no trickle down)
Practical Implications:
- Parent subscriptions are "catch-all": Watching
userswill fire for ANY change in ANY user or their properties - Child subscriptions are specific: Watching
users.john.emailonly fires when that exact path or its children change - Performance consideration: Higher-level subscriptions fire more frequently due to bubble-up
- Data replacement warning: If you
set()at a parent level, child subscriptions may stop working as their paths no longer exist