Every migration-off-Meteor estimate we write has one line item that surprises people. Not the database, not authentication, not the build. It is the fact that the new UI feels slower than the old one, on the same hardware, against the same MongoDB, with faster page loads.
The reason is latency compensation. Meteor has done it for you since 2012, your team never wrote a line of code for it, and there is no file in the repository where it lives. That makes it the single most common unpriced item in a migration plan — and the thing most worth understanding before you decide whether to keep the app, upgrade it, or leave.
What is actually happening
Three pieces cooperate:
Minimongo is a MongoDB implementation in the browser. When a publication sends documents down over DDP, they land in an in-memory client collection that supports the same query API as the server. Tasks.find({ done: false }) in the browser is a real query against a real local store, not a cached HTTP response.
Method stubs run your method twice. When client and server code share a Meteor.methods definition, calling tasks.complete executes it immediately on the client against Minimongo, then sends the call to the server over DDP. The UI updates on the local write — typically inside a frame — while the real call is still in flight.
Reconciliation cleans up. When the server's result arrives, Meteor discards the simulated write and replaces it with the authoritative documents. If the server rejected the call, the simulated change disappears and the UI snaps back. This is the part nobody writes by hand, because writing it by hand is genuinely difficult.
The result is a UI where clicking a checkbox is instant and stays instant on a hotel wifi connection. Users do not describe this as a feature. They describe its absence as "the new version is laggy."
Reading how much of it your app relies on
Before any architectural decision, measure the dependence. Three greps and one experiment:
- Count shared methods. Methods defined in
imports/api/**or any directory loaded on both client and server get stubs. Methods defined underserver/do not. Split your method list into those two buckets; the first bucket is your optimistic surface. - Count client-side writes. Direct
Collection.insert/update/removefrom client code — allowed wheninsecureis still installed or whenallow/denyrules permit it — are fully optimistic by definition. These are also the ones your security pass should be looking at, so the two inventories pay for each other. - Count reactive reads. How many templates or
useTrackerhooks query Minimongo directly? Each is a subscription-backed live view that a REST endpoint does not replicate for free. - Then throttle. In Chrome DevTools, set the network to a 300 ms round trip and use the app for ten minutes. Everything that still feels instant is being carried by latency compensation. Write those flows down by name. That list, not a line count, is what you are committing to preserve.
We have done this on apps where the answer was "three screens" and on apps where the answer was "the entire product." Both are fine outcomes. Not knowing which one you have is what makes a migration estimate fiction.
What changes in Meteor 3
Staying on Meteor does not mean this topic leaves you alone. The async migration touches stubs in a specific, easy-to-miss way.
Minimongo's client-side API remains synchronous. Tasks.findOne(id) still works in the browser, because there is no I/O to wait for. The server-side collection API is what became async. So shared method bodies now have to work in both worlds — and the isomorphic code that used to be identical on both sides no longer is.
The pattern that holds up:
Meteor.methods({
async 'tasks.complete'(taskId) {
check(taskId, String);
if (Meteor.isServer) {
await Tasks.updateAsync(
{ _id: taskId, ownerId: this.userId },
{ $set: { done: true, completedAt: new Date() } }
);
return;
}
// Client simulation: Minimongo, synchronous, best-effort.
Tasks.update(taskId, { $set: { done: true, completedAt: new Date() } });
},
});
Two things worth knowing while you convert:
- A stub that throws kills the real call. If your simulation hits an API that no longer exists on the client, the client-side exception aborts the method before it reaches the server. The symptom is a write that silently stops happening, with an error only in the browser console. This is the most common latency-compensation bug we find after a partially finished async migration.
awaitin a stub does not buy you anything. Meteor does not wait for an async stub to settle before sending the call; any state set after the firstawaitmay land after reconciliation has already happened. Keep simulations synchronous and simple. If a simulation needs data it cannot get locally, do not simulate that method at all — a method with no stub is a perfectly good choice, and an honest one.
While you are in there, check whether each stub still matches server behavior. Simulations drift. A stub that sets a field the server stopped writing in 2021 produces a visible flicker on every call: the UI shows one value, then the server's reconciliation corrects it a moment later. Users read that flicker as a bug, because it is one.
Rebuilding it outside Meteor
On a strangler migration to Next.js and React, latency compensation is not a thing you port. It is a thing you re-implement, per interaction, where it earns its place.
The closest modern equivalent is an optimistic mutation in TanStack Query (React Query): cancel in-flight queries for the key, snapshot the cache, apply the optimistic change, roll back in onError, invalidate in onSettled. That is roughly fifteen lines per mutation, and it is the same four-step dance Meteor was doing invisibly:
const completeTask = useMutation({
mutationFn: (id) => api.completeTask(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] });
const previous = queryClient.getQueryData(['tasks']);
queryClient.setQueryData(['tasks'], (tasks) =>
tasks.map((t) => (t._id === id ? { ...t, done: true } : t))
);
return { previous };
},
onError: (_err, _id, ctx) => queryClient.setQueryData(['tasks'], ctx.previous),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
That covers one user's own actions. It does not cover the other half of what Meteor gave you: live updates when someone else changes the data. If a flow genuinely needs multi-user live state — a shared board, a queue several operators work at once, a presence indicator — you need a transport for it. Server-sent events over a MongoDB change stream is the smallest honest answer; a websocket layer or a hosted realtime service is the larger one. Either way it is real work, and it belongs in the estimate with a number next to it.
So the planning rule we use: sort the flows from your throttled walkthrough into three buckets.
- Optimistic, single-user. Rebuild with an optimistic mutation. Cheap and well-trodden.
- Live, multi-user, load-bearing. Budget a transport. This is the bucket that decides whether the migration is a quarter or three.
- Neither, honestly. Admin screens, reports, settings pages. A plain request with a spinner is fine and always was. On most apps this bucket is larger than the team expects, which is usually the good news in the room.
If bucket two is most of the product, that is a legitimate reason to stay on Meteor. Meteor's reactivity is not a legacy quirk to be escaped; it is a genuinely strong implementation of a hard problem, and rebuilding it elsewhere to end up where you started is the definition of a bad trade. We have told clients exactly that, and they stayed, and they were right to.
The point
Latency compensation is invisible until it is missing, which is why it shows up as a schedule problem instead of a design decision. Make it a design decision. Inventory it, price it per flow, and let the size of bucket two inform the keep-or-leave call rather than discovering it in a demo three months in.
If you are weighing that decision on a live app, an assessment can put real numbers on these buckets — method stubs, subscription counts, and which screens actually depend on being instant.