Most of the upgrade conversations we have start with the application: Fibers, packages, Blaze. But the clock that actually forces the schedule usually runs underneath the app. Node.js releases go end-of-life on a published calendar. MongoDB server versions leave support, and managed providers eventually stop hosting them. Your Meteor version pins which of those you are allowed to run.
That coupling is what makes runtime upgrades feel scary on an older Meteor app: you cannot move Node without moving Meteor, and you may not be able to move Meteor until packages and async work are done. The good news is that the database half can almost always move first, independently, and that removes most of the risk from the rest.
Start with the version matrix, written down
Before touching anything, put four numbers and their support dates in one table:
- Meteor release (
meteor --versionand the.meteor/releasefile) - Node version Meteor bundles for that release (
meteor node --version— not whatevernode -vsays on your laptop) - MongoDB server version in production (
db.version()) - MongoDB Node driver version the app actually loads (
npm ls mongodb)
The rule that governs everything after: on a Meteor app, you do not choose the Node version — the Meteor release does. Meteor bundles its own Node. Broadly, Meteor 2.x shipped Node 14 through the 2.16 line; the Meteor 3.x line moved to Node 20 and later. So "we need to get off Node 14" is not a Node task. It is a Meteor upgrade task, and it comes with everything the Fibers-to-async conversion implies.
MongoDB is the opposite, and that is the opening. The server version is a deployment concern. The driver version is dragged along by the Meteor release, but the server you point it at can move ahead on its own schedule, within the driver's compatibility window.
Move the database first
Upgrading MongoDB under a running Meteor app is the least entangled work available to you, and it retires real risk — an unsupported database is the exposure most likely to turn into an incident nobody planned for.
The sequence that works:
- One minor version at a time, in order. MongoDB supports upgrades between consecutive major versions only. Going 4.2 to 7.0 means 4.4, then 5.0, then 6.0, then 7.0 — each with its own soak period. Skipping steps is not supported and the tooling will tell you so, usually at the worst moment.
- Watch
featureCompatibilityVersion. After each major upgrade the FCV stays at the old level until you raise it. Leave it low until the app has run clean for a while: while FCV is low you can still downgrade. Once you raise it, that door closes. - Check for removed behavior, not just removed commands. The ones that bite Meteor apps: stricter index name and field validation, changes to
$whereand map-reduce, and default write concern moving tomajorityin 5.0. That last one is quietly the most interesting — writes that used to return immediately now wait for replication acknowledgement. On a healthy replica set that costs single-digit milliseconds. On a stretched or under-provisioned one, it is a visible latency change in your method timings. - Rehearse the restore, not just the backup. Restore a production snapshot into a staging cluster on the target version and run the app against it. A backup you have never restored on the new version is a hope, not a plan.
- Verify the app against the new server before cutting over. Point staging at the upgraded cluster for a week of normal use. Most incompatibilities show up as a failing query, not a failing startup.
A replica set is a hard prerequisite worth naming: Meteor needs one for oplog tailing, and Meteor 3 needs one for change streams and transactions. If you are still on a standalone mongod somewhere, converting to a single-node replica set is the first step of every other step here.
What changes when oplog tailing becomes change streams
This is the part teams do not see coming, because it is not in any changelog they read.
Meteor 2's reactivity was powered by oplog tailing: the server read the replica set's operation log directly and matched entries against live observers to decide which publications to invalidate. It is fast and it is also a private-API trick — it needed oplog read access, it needed MONGO_OPLOG_URL configured, and on some managed providers it needed a support ticket.
Meteor 3 moves to change streams, the supported MongoDB API for the same idea. Practically:
- Configuration gets simpler. A separate oplog URL is no longer the price of reactivity, and managed clusters that never allowed oplog access work normally.
- The change-detection path is supported and documented rather than dependent on internal log formats.
- Resource characteristics shift. Change streams are per-watch cursors on the server rather than one process-wide oplog reader. On an app with a very large number of distinct observers, the load profile moves from your app server toward your database, and the fix is the same as it always was: publish less, and publish more narrowly.
If you tuned around oplog behavior — a tight disableOplog list, a custom oplog package, or the redis-oplog pattern adopted to dodge oplog cost — revisit those assumptions after the move rather than porting them forward untested. Some of them were solving a problem that no longer exists; others are still earning their keep. Measure, do not assume.
Sequencing the whole thing
For an app on Meteor 2.x, Node 14, and an old MongoDB, the order we use:
- Confirm a replica set and tested backup/restore.
- Walk MongoDB up to a supported major version, one step at a time, raising FCV behind each. The app stays on Meteor 2 throughout.
- Move to Meteor 2.16, which gives you the
*Asynccollection API while still running Fibers underneath. - Do the async conversion on 2.16, shipping normally the whole time.
- Upgrade to Meteor 3 — this is where Node moves, in one deliberate step, with the application code already async.
- Re-measure: method latency, publication counts, database CPU. Reactivity is running on a different mechanism now, and your old performance baseline is no longer the right comparison.
Steps 1 and 2 are worth doing even if you never take step 5. They are cheap relative to the risk they retire, they do not depend on the async work, and they do not commit you to anything. If the assessment later says stay on Meteor for three more years, you are on a supported database. If it says migrate off, the data layer you carry forward is already current — and on a strangler migration, that data layer is the thing both systems share.
The honest caveat
None of this is free, and some of it is genuinely fiddly: FCV rollbacks, a driver that suddenly enforces a validation rule your data has quietly violated since 2017, an index build that takes longer on the new version than the maintenance window allows. Budget the soak time. The failure mode we see is not a bad upgrade plan; it is a good plan compressed into one weekend because someone wanted it finished. Version numbers are the easy part. The waiting in between is the work.