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 usingonChange'sdocChangescorrectly and placingcloser.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
watchprotocol 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
watchpush performance; in extreme cases, move accumulation into a Cloud Function for aggregation)
Prerequisites
| Dependency | Version |
|---|---|
@cloudbase/js-sdk | 2.27.3 (already installed in add-auth-wechat-miniprogram) |
@cloudbase/adapter-wx_mp | 1.3.1 |
| WeChat DevTools | ≥ 1.06.x |
Also required:
- add-auth-wechat-miniprogram and add-database-wechat-miniprogram completed, with
auth.hasLoginState()returning true anddb.collection('xxx')able to read and write normally - The target collection exists in Console under "Database", with permissions allowing reads for the current user
Step 1: Understand the watch callback structure
The watch callback contains two fields with different meanings that are easy to confuse:
| Field | Meaning | Typical use |
|---|---|---|
snapshot.docs | All documents currently matching the query (the complete result set after changes) | Directly setData to re-render the entire list |
snapshot.docChanges | The current changed records, with a dataType field | Partial updates / notifications / incremental animations |
docChanges[].dataType values:
init: First push — this is the initial snapshot, containing all documents currently matching thewhereconditionadd: A new document has entered the query results (may be newly inserted, or an existing document whose field change brought it into thewherescope)update: An existing document's content has been modifiedremove: A document was deleted, or a field change caused it to leave thewherescope
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 thanwhere({_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 oninit— 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()forlastActive, not client-sidenew 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
onLoadcreates 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.watcherreference 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
watchexceeds the maximum document count,onChangewill 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),
watchpushes may be merged/throttled. Do not use it as an event bus watchdoes not support aggregation queries (aggregate). To monitor aggregated results, run the aggregation in a Cloud Function, have the frontendwatcha "result cache collection", and have the Cloud Function write results into it
Verification
- Compile in WeChat DevTools and open the Console
- Test the order status push example: manually change the
statusfield of a record in theorderscollection in the Console; the Mini Program page should immediately show the new status and display a toast - Test the message push example: add a
messagesrecord in the Console; the frontend should immediately show the new message - Exit the page — no more watch push logs should appear in the Console (if close() was missed, pushes will continue)
- Enter and exit the same page multiple times — only one watcher should be active, with no duplicate pushes
Common Errors
| Error Symptom | Cause | Fix |
|---|---|---|
onChange never fires | The collection permission does not allow reads for the current user | Check "Database → Collection → Permissions" in Console; watch is subject to permission rules |
onChange always pushes full data, causing list flickering | Not distinguishing between init and add, treating all docChanges as new events | See Step 3 — use a state check like messages.length > 0 to filter |
| After entering/exiting the page N times, pushes accumulate N times | Old 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 limit | Reduce watch scope, or paginate the list watch (only monitor the currently visible items) |
| Brief disconnection after network switch | Wi-Fi → 4G switch; SDK reconnects | SDK recovers automatically; no business-side handling needed |
Occasionally receives dataType: 'remove' but nothing was actually deleted | Document field changed, causing it to leave the where condition | Treat 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.
Related Documentation
- Realtime Push / watch protocol —
watchcallback structure andclosesemantics - Web SDK Database API — Full signatures for
collection / where / watch - add-database-wechat-miniprogram — Prerequisite: basic database read/write
- add-auth-wechat-miniprogram — Prerequisite: authentication integration
Next Steps
- Push notifications to users who are not currently in the Mini Program: add-subscribe-message-cloud-function
- Route online status writes through a Cloud Function: secure-database-multi-tenant-rules
- Multi-tenant isolation for group chat scenarios: secure-database-multi-tenant-rules