When the UI Lies on Purpose: Meteor's Latency Compensation in Practice
Meteor methods run twice — once as a client-side simulation, once on the server. Here's how latency compensation actually reconciles, where it breaks, and how to verify it.
29 May 2026, 21:04 UTC

You click "Save" in a chat app and the message appears instantly, even though the server round-trip takes 300ms. Then, occasionally, the message vanishes and reappears a moment later. That flicker isn't a bug — it's Meteor's latency compensation doing exactly what it was designed to do, and understanding it changes how you write methods.
The thesis: Meteor methods run twice — once on the client as a simulation, once on the server for real — and the client trusts the simulation until the server says otherwise. If you write methods with this in mind, your app feels instant. If you don't, you get phantom data and confusing rollbacks.
How the two-phase method call works
When you call Meteor.call('messages.send', text), Meteor looks for a method of that name defined on the client (a stub). The stub runs immediately against Minimongo, the client-side in-memory copy of your MongoDB data. The UI, being reactive, re-renders off that speculative write right away.
Meanwhile, the real method executes on the server over DDP (Distributed Data Protocol, Meteor's persistent WebSocket-based wire protocol). When the server's result comes back, Meteor reconciles: if the server's write matches the stub's, nothing visibly changes. If it differs — validation failed, an ID was generated differently, a permission check rejected the write — the client rolls back the simulated change and applies the authoritative one. That's the flicker you saw.
A worked example: a message send with a server-generated field
Here's a method defined in a shared file (imported by both client and server, which is what enables the simulation):
// imports/api/messages/methods.js
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { Messages } from './messages.js';
Meteor.methods({
'messages.send'(text) {
check(text, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Sign in to post.');
}
return Messages.insert({
text,
owner: this.userId,
createdAt: new Date(),
});
},
});
Because this file is imported client-side too, the insert runs in Minimongo instantly and the message list re-renders without waiting. The server then performs the same insert for real.
The subtle part: new Date() executes at slightly different times on client and server, and the auto-generated _id would differ too unless Meteor handles it. Meteor actually does handle the _id case — the stub's generated ID is sent to the server so both writes land on the same document. But createdAt will differ by the round-trip time. If your UI sorts by createdAt, a message can jump position when the authoritative version arrives.
Writing stubs that reconcile cleanly
The rule of thumb: the simulation must be deterministic with respect to the server. In practice that means:
- Avoid wall-clock and random values in shared method code. Generate timestamps and slugs on the server only, using
this.isSimulationto branch. - Don't call external APIs from a shared method unconditionally. An email send inside the method would fire on the client simulation too — or worse, twice.
- Keep validation identical on both sides. If the client stub accepts a write the server rejects, the user sees their action appear and then disappear — the worst kind of feedback.
Using isSimulation for the timestamp:
Meteor.methods({
'messages.send'(text) {
check(text, String);
if (!this.userId) throw new Meteor.Error('not-authorized');
const doc = { text, owner: this.userId };
if (!this.isSimulation) {
doc.createdAt = new Date(); // server is authoritative
}
return Messages.insert(doc);
},
});
Note the trade-off: the simulated document now has no createdAt, so your UI must tolerate that field being briefly undefined (sort defensively, or fall back to insertion order).
Where latency compensation breaks down
Three honest limitations. First, rollbacks are visible. If server-side permission checks reject a write the stub allowed, users see their action undone. Mitigate by duplicating the cheap checks (logged-in status, string length) in shared code so the stub fails the same way the server would.
Second, not every method should simulate. Anything with side effects beyond the database — payments, emails, third-party API calls — should be defined server-only (place it in a server/ directory). The client then shows a loading state instead of a speculative result. Optimism is for reads-and-writes, not for charging credit cards.
Third, reactive over-propagation. Latency compensation rides on the same Tracker reactivity as pub/sub; if a compensated write touches a collection that many computations depend on, you can get a burst of re-renders on every keystroke-like action. Benchmark with realistic data volumes and narrow your publications before blaming the method layer.
How to verify it's actually working
Don't trust the feeling of speed — measure the reconciliation. Two quick checks:
- Add an artificial delay. In the server-only portion of the method, wrap the insert with
Meteor._sleepForMs(2000)(available in development). The UI should still update instantly, then reconcile two seconds later. If the UI waits, your method isn't being simulated — check that it's imported client-side and not gated behind aserver/folder. - Watch Minimongo in devtools. In the browser console, run
Messages.find().fetch()(assuming the collection is exposed) immediately after triggering the action, before the server responds. The speculative document should be there. After the round-trip, confirm the_idis unchanged and only the server-authoritative fields (likecreatedAt) shifted.
The actionable takeaway: audit one method in your current project. Move server-only side effects behind isSimulation or into a server-only file, make timestamps server-authoritative, and duplicate cheap validation into shared code. That single pass eliminates the most common causes of visible rollback — and gets you the instant-feeling UI Meteor's data model promises.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.