Meteor Publication/Subscription Model: How Reactive Data Sync Works and Where It Breaks
Meteor's publication/subscription model keeps client caches in sync by streaming MongoDB cursor diffs over DDP. This guide explains the mergebox, shows a worked active-tasks example, and covers parameterized pubs, field projections, reactive joins, and mergebox scaling limits.
06 Jan 2026, 09:10 UTC

The Problem: Real-Time Data Without Polling
Meteor's publication/subscription system solves a specific problem: keeping client-side data in sync with a MongoDB backend without writing WebSocket logic, diff algorithms, or cache invalidation code. The useful takeaway: publications define server-side MongoDB cursors; subscriptions create a reactive local cache (Minimongo) that the mergebox keeps consistent by streaming only document-level diffs over DDP. You write a cursor once on the server, subscribe on the client, and query that same cursor locally — reactivity comes from the mergebox, not from re-fetching.
Core Mechanism: Cursor → Mergebox → Minimongo
On the server, a publication returns a MongoDB cursor. The mergebox (Meteor's server-side observer) watches that cursor's result set. When a write occurs — insert, update, or remove — the mergebox computes the minimal diff and sends added, changed, or removed DDP messages to every subscribed client. Each client applies those messages to its Minimongo cache, which triggers reactive re-runs in any Tracker.autorun or component query.
Worked Example: Active Tasks Publication
Server (/imports/api/tasks/server/publications.js):
import { Meteor } from 'meteor/meteor';
import { Tasks } from '../tasks.js';
Meteor.publish('activeTasks', function () {
// this.userId is set by the login token; null if unauthenticated
if (!this.userId) return this.ready();
// Cursor with projection limits network payload and client document shape
return Tasks.find(
{ status: 'active', owner: this.userId },
{ fields: { title: 1, dueAt: 1, status: 1 } }
);
});Client (React component using createContainer or useTracker):
import { useTracker } from 'meteor/react-meteor-data';
import { Tasks } from '/imports/api/tasks/tasks.js';
function ActiveTasksList() {
const handle = useTracker(() => {
const sub = Meteor.subscribe('activeTasks');
return {
tasks: Tasks.find({ status: 'active' }).fetch(),
loading: !sub.ready(),
};
}, []);
if (handle.loading) return Loading…;
return (
{handle.tasks.map(t => - {t.title} — due {t.dueAt}
)}
);
}When the server inserts { _id: 'abc', title: 'Fix bug', dueAt: new Date(), status: 'active', owner: 'user1' }, the mergebox sends one added message. The client's Tasks.find({ status: 'active' }) cursor reacts instantly — no re-subscription, no polling.
Parameterized Publications: Arguments Are Not Reactive
Publications accept arguments for filtered queries:
Meteor.publish('userTasks', function (userId) {
check(userId, String);
if (this.userId !== userId) return this.ready(); // authorization
return Tasks.find({ owner: userId }, { fields: { title: 1, dueAt: 1 } });
});Critical limitation: changing userId does not re-run the publication. The client must call subscribe('userTasks', newUserId) again, which tears down the old subscription (mergebox cleans up its observer, removes cached docs) and starts a fresh one with a full initial sync. Two simultaneous subscriptions to userTasks with different arguments create two independent mergebox observers and two disjoint document sets in Minimongo.
Field Projections: Bandwidth and Mergebox Work
The fields option in the cursor serves two purposes:
- Network payload: only projected fields travel over DDP.
- Client document shape: Minimongo stores only those fields;
Tasks.findOne()on the client returns partial documents.
Omitting fields sends full documents, increasing bandwidth and mergebox CPU (more data to diff per write). Adding a field later requires a new publication version or a publish-null workaround — the mergebox does not retroactively add fields to already-synced documents.
Common Mistakes and Hard Limits
1. Reactive Joins Across Collections
The mergebox observes one cursor per publication. Publishing related documents from multiple collections (e.g., tasks + comments) requires manual this.added(), this.changed(), this.removed() calls inside the publish function, or a package like publish-composite. The mergebox does not auto-join.
2. Large Result Sets Overload the Mergebox
Each subscription spawns a server-side observer. >10k documents or high write throughput per subscription can exhaust memory and CPU. Mitigations:
- Add
limitand pagination (cursorskip/limitwith a separate count publication). - Use non-reactive methods (
Meteor.callreturningfetch()) for bulk/export scenarios. - Monitor
observerscount andmsg/sentrate viameteor-apmor Kadira.
3. this.unblock() Removes Sequential Guarantees
Calling this.unblock() in a publish function allows concurrent publications for the same client. Use only when publication logic is independent of prior writes; otherwise, race conditions can leave the client cache inconsistent.
4. Client Queries Without Matching Subscription Return Empty
Tasks.find({ status: 'active' }) on the client returns an empty cursor if no active subscription publishes those documents. The reactive cache only contains what the mergebox has sent.
Practical Verification Steps
Run these in a fresh Meteor 3.x project to confirm behavior:
- Create app:
meteor create test-pubsub && cd test-pubsub(run in terminal, no special permissions). - Add collection and publication: create
/imports/api/tasks/tasks.jsand/imports/api/tasks/server/publications.jswith the example code above. - Start server:
meteor(default port 3000). - Open browser console and insert from server shell:
meteor shell→Tasks.insert({ title: 'Test', dueAt: new Date(), status: 'active', owner: 'user1' }). Watch the UI update in real time. - Enable DDP logging:
DEBUG=ddp* meteor(Linux/macOS) orset DEBUG=ddp* && meteor(Windows). Observesub,added,changed,removedmessages. - Test argument reactivity: call
Meteor.subscribe('userTasks', 'userA'), thenMeteor.subscribe('userTasks', 'userB')in console. Verify two subscription handles (subscriptionIddiffers) and separate document sets inTasks.find().fetch().
When to Use Alternatives
Use Meteor methods (Meteor.call) instead of publications when:
- Data is read-once or user-initiated (search, reports).
- Result sets are large and pagination UX is acceptable.
- You need argument reactivity without full resync (methods re-run on every call).
Publications excel at small-to-medium, frequently changing datasets where multiple clients need the same live view — collaborative task lists, dashboards, presence indicators. Size your publications, project fields, and monitor mergebox metrics.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.