Skip to main content

Realtime Notifications with CloudBase Database watch

In one sentence: In a Mini Program that already uses add-database-wechat-miniprogram, subscribe to a query result set with db.collection().watch() to implement "server changes instantly reflected on the client" scenarios — order status push, message alerts, and online presence sync. The key is using onChange's docChanges correctly and placing closer.close() in the right location.

Estimated time: 35 minutes | Difficulty: Advanced

Applicable Scenarios

The add-database-wechat-miniprogram recipe covers the basic watch setup. This recipe builds on that to show how to apply it in concrete scenarios.

  • Applicable: Real-time order status refresh, single/group chat message push, collaborative editing sync, online presence display
  • Applicable: WeChat Mini Program frontend + standalone CloudBase environment (@cloudbase/js-sdk + @cloudbase/adapter-wx_mp)
  • Not applicable: Strong real-time requirements with tens of millions of concurrent cross-device connections (at IM scale, use a dedicated IM service; this approach is fine for small to medium scale)
  • Not applicable: Scenarios requiring latency strictly under 100ms. The watch protocol is based on long connections; typical latency is in the hundreds of milliseconds to seconds range
  • Not applicable: High-frequency database writes with instant accumulation (heavy writes degrade watch push performance; in extreme cases, move accumulation into a Cloud Function for aggregation)

Prerequisites

DependencyVersion
@cloudbase/js-sdk2.27.3 (already installed in add-auth-wechat-miniprogram)
@cloudbase/adapter-wx_mp1.3.1
WeChat DevTools1.06.x

Also required:

Step 1: Understand the watch callback structure

The watch callback contains two fields with different meanings that are easy to confuse:

FieldMeaningTypical use
snapshot.docsAll documents currently matching the query (the complete result set after changes)Directly setData to re-render the entire list
snapshot.docChangesThe current changed records, with a dataType fieldPartial updates / notifications / incremental animations

docChanges[].dataType values:

  • init: First push — this is the initial snapshot, containing all documents currently matching the where condition
  • add: A new document has entered the query results (may be newly inserted, or an existing document whose field change brought it into the where scope)
  • update: An existing document's content has been modified
  • remove: A document was deleted, or a field change caused it to leave the where scope

One principle to remember when writing application code: use snapshot.docs for full list re-renders, and snapshot.docChanges for notifications / partial updates / incremental processing. Mix and match as needed.

Step 2: Real-time order status push

Scenario: After a user places an order, the order progresses from "Pending Payment → Processing → Pending Shipment → Shipped". The waiting page needs to display status changes instantly.

pages/order-detail/order-detail.js:

import { db } from '../../libs/cloudbase';
import { ensureLogin } from '../../libs/login';

Page({
data: {
order: null,
},

async onLoad({ orderId }) {
await ensureLogin();
this.orderId = orderId;
this.startWatchOrder();
},

startWatchOrder() {
this.watcher = db
.collection('orders')
.doc(this.orderId)
.watch({
onChange: (snapshot) => {
// Single-document watch: docs length will only be 0 or 1
const order = snapshot.docs[0] || null;
this.setData({ order });

// Check incremental changes on each update, show a toast
for (const change of snapshot.docChanges) {
if (change.dataType === 'update') {
this.notifyStatusChange(change.doc.status);
}
}
},
onError: (err) => {
console.error('[watch order] error', err);
},
});
},

notifyStatusChange(status) {
const map = {
paid: 'Payment successful',
shipped: 'Order shipped',
delivered: 'Order delivered',
};
if (map[status]) {
wx.showToast({ title: map[status], icon: 'success' });
}
},

onUnload() {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
},
});

Key points:

  • Use db.collection().doc(id).watch() for single-document watching — more semantically clear than where({_id: id})
  • After a server-side update, the frontend receives the push via the long connection, typically in hundreds of milliseconds to seconds. Compared to frontend polling, this saves API calls and avoids the awkwardness of "just refreshed but nothing changed"
  • Only show business event notifications on update, not on init — otherwise the user will get a "Payment successful" toast the moment they enter the page

Step 3: Simple message push

Scenario: A chat page subscribes to the message collection for the current conversationId; new add events trigger UI rendering.

pages/chat/chat.js:

Page({
data: {
conversationId: '',
messages: [],
},

async onLoad({ conversationId }) {
await ensureLogin();
this.setData({ conversationId });

this.watcher = db
.collection('messages')
.where({ conversationId })
.orderBy('createdAt', 'asc')
.watch({
onChange: (snapshot) => {
// Full message list is pushed; refresh directly
this.setData({ messages: snapshot.docs });

// Use docChanges to get newly added messages for sound/scroll
const newOnes = snapshot.docChanges.filter(
(c) => c.dataType === 'add'
);
if (newOnes.length && this.data.messages.length > 0) {
// Don't count init-phase docs as "new messages"
wx.vibrateShort({ type: 'light' });
this.scrollToBottom();
}
},
onError: (err) => {
console.error('[watch messages] error', err);
},
});
},

scrollToBottom() {
// ScrollView scroll-to-bottom implementation, omitted
},

onUnload() {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
},
});

The send side is just a normal add, not covered here:

await db.collection('messages').add({
conversationId,
content: 'Hello',
createdAt: db.serverDate(),
});

Step 4: Online presence (simple version)

Scenario: Show "this user was recently online". The approach is: the client periodically heartbeats by updating its own lastActive, and other clients watch this field and compare the time difference.

Heartbeat side (start a timer in app.js after launch):

// miniprogram/app.js
import { db } from './libs/cloudbase';
import { ensureLogin } from './libs/login';

App({
async onLaunch() {
await ensureLogin();
this.startHeartbeat();
},

startHeartbeat() {
const beat = async () => {
try {
await db.collection('user_status').doc(this.globalData.user.uid).set({
lastActive: db.serverDate(),
});
} catch (e) {
console.warn('[heartbeat] failed', e);
}
};

beat();
this.heartbeatTimer = setInterval(beat, 30 * 1000); // every 30s
},

onHide() {
// Stop heartbeat when backgrounded to save resources
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
},

onShow() {
if (!this.heartbeatTimer && this.globalData.user) {
this.startHeartbeat();
}
},

globalData: { user: null },
});

Observer side — watch lastActive for a set of users and compare timestamps on the frontend:

const ONLINE_THRESHOLD = 90 * 1000; // online if active within 90s

this.watcher = db
.collection('user_status')
.where({ uid: db.command.in(this.peerUids) })
.watch({
onChange: (snapshot) => {
const now = Date.now();
const onlineMap = {};
for (const doc of snapshot.docs) {
const last = doc.lastActive?.getTime?.() || 0;
onlineMap[doc.uid] = now - last < ONLINE_THRESHOLD;
}
this.setData({ onlineMap });
},
});

Notes:

  • The heartbeat interval (30s) must be less than the online threshold (90s) to allow for some tolerance. If the two values are equal, a single missed heartbeat will result in the user being marked offline
  • Use db.serverDate() for lastActive, not client-side new Date(). Client clocks can be inaccurate; server time is consistent
  • This is the simplest approach. When the number of online users reaches hundreds or more, switch to a dedicated presence system

Step 5: Always call close() when the page is destroyed

The watch connection is a long connection; the server maintains subscription state and consumes quota. If the frontend forgets to call close(), there are three common pitfalls:

  • Repeatedly entering and leaving the same page via onLoad creates a new watcher each time while the old one remains, causing duplicate pushes
  • The user navigates around and eventually exits the app, leaving many watchers unreleased, consuming server connection slots and triggering rate limiting
  • The background process is reclaimed by WeChat; the watcher is effectively disconnected, but the frontend this.watcher reference remains, giving a false sense of it still working

Standard pattern:

Page({
onLoad() {
this.startWatch();
},

startWatch() {
// Close any existing watcher first
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}

this.watcher = db.collection('xxx').where({...}).watch({
onChange: (snapshot) => {
// ...
},
});
},

onUnload() {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
},
});

For components, use attached / detached in the same way.

Step 6: Known limitations

Understand these limitations before writing code to save debugging time later.

  • There is a maximum number of documents per connection subscription. Check the specific number in Console under "Environment Configuration → Quota". If a single watch exceeds the maximum document count, onChange will not fire
  • Switching networks (Wi-Fi to 4G) triggers a brief disconnection. The SDK internally reconnects automatically, so this is mostly transparent to business logic, but may occasionally cause 1-2 seconds of push delay
  • For high-frequency write collections (tens of writes per second or more), watch pushes may be merged/throttled. Do not use it as an event bus
  • watch does not support aggregation queries (aggregate). To monitor aggregated results, run the aggregation in a Cloud Function, have the frontend watch a "result cache collection", and have the Cloud Function write results into it

Verification

  1. Compile in WeChat DevTools and open the Console
  2. Test the order status push example: manually change the status field of a record in the orders collection in the Console; the Mini Program page should immediately show the new status and display a toast
  3. Test the message push example: add a messages record in the Console; the frontend should immediately show the new message
  4. Exit the page — no more watch push logs should appear in the Console (if close() was missed, pushes will continue)
  5. Enter and exit the same page multiple times — only one watcher should be active, with no duplicate pushes

Common Errors

Error SymptomCauseFix
onChange never firesThe collection permission does not allow reads for the current userCheck "Database → Collection → Permissions" in Console; watch is subject to permission rules
onChange always pushes full data, causing list flickeringNot distinguishing between init and add, treating all docChanges as new eventsSee Step 3 — use a state check like messages.length > 0 to filter
After entering/exiting the page N times, pushes accumulate N timesOld watcher not closed with close()See Step 5 standard pattern — close at the start of startWatch
onError fires frequently with "subscription count exceeded"Single-connection document subscription exceeds the limitReduce watch scope, or paginate the list watch (only monitor the currently visible items)
Brief disconnection after network switchWi-Fi → 4G switch; SDK reconnectsSDK recovers automatically; no business-side handling needed
Occasionally receives dataType: 'remove' but nothing was actually deletedDocument field changed, causing it to leave the where conditionTreat it as "a document we no longer care about" at the business level; don't assume it was truly deleted

For error code definitions, see error-code.

Next Steps