Every Meteor 3 migration plan we review has methods, publications, and packages on it. Roughly half of them are missing the nightly invoice run.
Background work is the quietest part of a Meteor codebase. It has no UI, it rarely appears in a test suite, and it usually predates everyone currently on the team. It is also one of the few places where a failure is silent: a method that throws produces an angry user, while a cron job that stops firing produces nothing at all, for weeks, until finance asks where the statements went.
The Fibers removal in Meteor 3 touches this code harder than it touches the rest of the app, for a reason worth understanding before you start editing.
Why scheduled work breaks differently
A method body runs inside a request. Meteor 3 gives you a clear place to put await, the client surfaces the error, and your logs tie the failure to a user action.
A scheduled job has none of that. Under Meteor 2, packages like percolate:synced-cron and vsivsi:job-collection wrapped your callback in a Fiber themselves, so the function body could call Collection.find().fetch() synchronously and everything worked. That wrapping is exactly what Meteor 3 removes. The failure modes you get instead:
- The package will not install at all. Anything that calls
Meteor.bindEnvironmentaround a Fiber, orMeteor.wrapAsyncinternally, fails on Meteor 3. This is the loud case, and it is the good one — you find it on day one. - The job runs and returns immediately. Convert the body to
asyncbut leave the scheduler expecting a synchronous return, and the scheduler marks the job finished the moment your function hits its firstawait. The work may still complete. The record of the work is now fiction, and overlap protection is gone. - Errors disappear. An unhandled rejection inside a job body that nobody awaits does not crash anything and, depending on your Node flags and logging setup, may not even print. The job silently does half its work each night.
The second and third are why we treat background jobs as their own workstream rather than a footnote to the async conversion.
Inventory first — and it is bigger than the cron package
Before choosing a replacement library, find everything that runs on a schedule or outside a request. In our assessments this list is always longer than the client expects:
- The cron package itself.
SyncedCron.addcall sites, plus anything that registers jobs dynamically at startup. - Raw timers.
Meteor.setIntervalandMeteor.setTimeoutin server startup code. These do not announce themselves and they run once per container — which means they fire N times on a horizontally scaled deployment, a bug that has usually been in production for years. - Queue-style collections. A
TasksorJobscollection with astatusfield, polled by an interval. Homegrown queues are common in Meteor apps and they never show up inpackage.json. Meteor.defercalls. Fire-and-forget work at the end of a method. Under Fibers this was deferred-but-wrapped; under async it needs an explicit decision about whether anyone awaits it.- External schedulers. System crontab entries, a Galaxy scheduled container, a CI job, or a Lambda that hits an HTTP endpoint on the app. These are outside the repository entirely and are found by asking the operations owner, not by grepping.
- Startup migrations.
percolate:migrationsand its relatives run at boot and hit the database hard. They need the same async treatment and the same care about running once across a cluster.
For each entry, record four things: what it does, how often, what breaks if it silently stops, and whether it is safe to run twice. That last column decides most of the design questions later.
Choosing where the work runs
There are three honest options, and the right one depends on the inventory, not on fashion.
Keep it in Meteor, on a maintained scheduler. The Meteor 3 era has active successors to synced-cron — the community fork lineage is alive, and node-cron or croner with a small MongoDB lock collection is a perfectly respectable twenty-line alternative. This is the lowest-disruption path and it is the right answer for the large majority of apps: a handful of jobs, modest runtime, no throughput problem. Whatever you pick, confirm two things in the source: that it awaits your handler's returned promise, and that it holds a distributed lock so multiple containers do not duplicate work.
Move it to a real job queue. If you have retries, backoff, visibility needs, or jobs measured in minutes, BullMQ on Redis or Agenda on your existing MongoDB gives you a dashboard, a dead-letter path, and durable retry semantics. Cost: a new dependency, and, for Redis, new infrastructure to run. Worth it when you actually have queue problems. Not worth it to run three nightly reports.
Move it out of the app entirely. Long-running jobs in the same process as your DDP server compete with user traffic for CPU, and on Meteor that competition is visible as method latency. Splitting a worker process off the same codebase — same container image, different entrypoint, no HTTP listener — is usually a one-afternoon change and it removes a whole class of "the site got slow at 2am" tickets. It is also the piece that is easiest to carry across if you later strangle the app onto something else, because a worker that talks to MongoDB and nothing else has no Meteor coupling left in it.
Converting the bodies safely
The mechanics are the same as the rest of the async migration, with three additions that are specific to jobs:
- Make every handler
asyncand make the scheduler await it. If the library does not await, wrap it yourself or replace the library. Do not leave a promise floating in a job body; the whole point of a scheduler is knowing whether the work finished. - Turn on
no-floating-promisesand read the job files first. This is where missed awaits hide longest, because nothing user-facing fails. - Add a heartbeat before you change anything. One document per job with
lastStartedAt,lastFinishedAt,lastError, and a duration, plus an alert when a job has not completed inside its expected window. Do this first, on the current Meteor 2 code, so you have a baseline to compare against after the conversion. Half the value of this work is discovering what the jobs were actually doing.
On idempotency: the inventory column about running twice matters most here, because any lock can fail during a deployment or a container restart. Jobs that append rather than overwrite, send email, or charge money need a guard of their own — a processed-marker on the record, a unique index, an idempotency key at the payment provider. That is not a Meteor concern, it is a correctness concern that the Fibers wrapper was hiding.
What we do on an engagement
We pull the background-job inventory during the assessment, before anyone estimates the async work, because it changes the shape of the plan. It is also the stage where an app most often surprises its owners: a job that stopped firing in 2023 and was never missed, an interval running six times because the app scaled to six containers, a migration script that still runs at every boot.
Add the heartbeat, convert the handlers with the rest of the server code, and make the scheduler choice on the evidence in the inventory. Most apps keep their cron package lineage and move on. Some need a queue. A few need the worker split out yesterday, for reasons that have nothing to do with Meteor 3 at all.
If you are scoping a Meteor 3 migration and the scheduled work is not yet on the list, that is the next thing to write down.