When we assess a Meteor app that has been in production since the Blaze years, the file-upload path is usually the piece with the widest gap between how much attention it gets and how much risk it carries. Nobody has touched it in six years. It still works. And it is holding customer contracts, signed PDFs, avatars, and invoice attachments inside a package whose last commit predates the current maintainer's job.
This is not a Meteor problem so much as a 2015 problem that Meteor apps inherited. It is worth its own workstream, because it is one of the few upgrade items where getting it wrong loses data rather than uptime.
What is usually in there
Four patterns cover almost everything we find:
- CollectionFS (
cfs:standard-packages). The dominant answer in the 2014-2016 era. It was deprecated years ago and it is not coming back. Depending on the storage adapter it kept files in GridFS, on the local filesystem, or on S3, with a Mongo collection of metadata in front. ostrio:files(Meteor-Files). The successor most teams moved to. Still maintained, which puts you in a much better position — the work here is usually a version bump plus the async changes, not a replacement.- GridFS directly. Binary chunks inside MongoDB. Works, is supported, and is the option most likely to be quietly making your database backups enormous.
- A homegrown route. A
WebApp.connectHandlers.use('/files', ...)handler, a Node stream, and a directory on the app server's local disk. This one is easy to miss in a grep and is the one that breaks the first time somebody adds a second container.
The local-disk case deserves a flag of its own. If uploads land on the container filesystem, then on Galaxy or any container platform they disappear at the next deploy, and on a scaled deployment a file uploaded through container A is a 404 through container B. Teams usually discover this as an intermittent "the attachment is missing sometimes" ticket that has been open for years.
Why Meteor 3 forces the conversation
Upload code is Fibers-heavy in a way most application code is not. It tends to use Meteor.wrapAsync around stream callbacks, Meteor.bindEnvironment in event handlers, and synchronous collection reads inside a request handler. That is precisely the surface Meteor 3 removes.
- CollectionFS will not run on Meteor 3. It is not a conversion job; it is a replacement job. Plan it as such rather than discovering it halfway through the async work.
- Custom route handlers need explicit async. A
connectHandlerscallback that used to callCollection.findOne()synchronously now needsfindOneAsyncand an awaited handler, and any missedawaithere shows up as an empty response body rather than an exception. - Signed-URL and permission checks move too. A
Meteor.userId()lookup inside an upload hook was often relying on Fiber-local context. Under async you pass the user explicitly, and this is a good moment to re-read the authorization check rather than port it blind.
If you are on ostrio:files, most of this is handled for you by a current version of the package. That alone is often the cheapest path: move CollectionFS to Meteor-Files first, on Meteor 2, and do the Meteor 3 upgrade afterwards with a maintained package underneath you.
Decide where the bytes should live
Separate two questions that legacy packages tangled together: where the bytes live, and how the app serves them.
For the bytes, object storage — S3 or an S3-compatible service — is the default answer for almost every app we see, for unglamorous reasons: it survives deploys, it is independent of container count, it does not inflate your MongoDB backups, and it has lifecycle rules and versioning built in. GridFS is a reasonable answer when you have a hard requirement to keep everything inside one database for backup or compliance reasons, and when total volume is modest. "Modest" in our experience means tens of gigabytes, not hundreds.
The number that decides it is usually already available. Run db.stats() and compare the size of your file chunks collection against everything else. On apps where uploads are more than half the database, moving them out shrinks backup windows and restore times more than any query tuning you will ever do.
For the serving path, the goal is that file bytes stop travelling through your Node process. Pre-signed URLs for downloads and direct-to-S3 uploads from the browser take a whole class of memory and latency problems off the app server — and on a Meteor app, where the same process is also holding every DDP connection open, that matters more than it does on a plain HTTP backend. A long upload pinning an event-loop slot is felt by every subscribed client.
Migrating without breaking the links customers have
This is the part that needs care, because file URLs leak. They are in sent emails, in PDFs, in other systems' databases, in browser bookmarks. You cannot assume you control every reference.
The sequence we use:
- Inventory and count. Number of files, total bytes, largest file, oldest file, and how many are referenced by a document that still exists. On old apps, 20 to 60 percent of stored files are commonly orphans — attachments to deleted records. Knowing that before you start changes the size of the job.
- Add the new storage path behind the existing interface. New uploads go to object storage; reads check the new location first and fall back to the old one. Nothing is deleted. This step alone stops the problem growing.
- Backfill in batches, verified. Copy old files across, comparing checksums and byte counts, writing the new key onto the metadata document as you go. Batch it, make it resumable, and log every failure — a backfill that skips files silently is worse than no backfill.
- Keep the old URLs alive. Serve the legacy route from a lookup that resolves to the new location, or redirect to a pre-signed URL. Retiring a URL shape is a separate decision, taken later, with logs showing how often it is still hit.
- Delete the old copies last, and only after a full backup cycle has rolled over. There is no reason to rush this step. Storage is cheap; a lost contract is not.
Orphan cleanup is tempting to bundle in here. Do it as a separate pass, after the migration is stable, with a soft-delete window. Deleting files during a storage migration means any mistake looks like a migration bug, and you will spend a week proving it was not.
The small things worth fixing while you are in there
Since you are already in this code, and it has not been read in years:
- Check the authorization on downloads. Old file routes frequently serve anything to anyone with the ID, and the IDs are guessable if they are sequential or derived from a filename. Pre-signed, expiring URLs fix this as a side effect.
- Check what happens to the content type. Serving user-uploaded HTML or SVG from your own domain is a stored-XSS path. A
Content-Disposition: attachmentheader and a separate domain or bucket for user content are cheap. - Check image processing. Old apps often resize on the app server with a native dependency that will not compile on a current Node. That is usually a reason to move thumbnailing to an on-demand service or a worker, not a reason to stay on old Node.
- Check upload size limits. A missing limit in a Meteor method that base64-encodes the file into a DDP message is a memory incident waiting for a large customer.
How this fits an engagement
We pull file storage into the assessment as its own line item, alongside packages and async surface, because it changes both the estimate and the order of work. The common recommendation is unexciting and it holds up: move off the unmaintained upload package while still on Meteor 2, get the bytes into object storage behind a compatibility layer, and only then take the app through Meteor 3. Three smaller changes, each independently verifiable, instead of one migration where a failure could mean missing customer files.
If you are planning a Meteor 3 upgrade and uploads are still handled by a package you have not checked the maintenance status of, that check is worth ten minutes today.