Blog/Deep dive

Meteor 3 Performance: What Actually Changed After Fibers

SkySignal TeamApril 21, 202615 min read

Meteor 3 shipped the single biggest internal change in the framework's history: Fibers is gone. Every async boundary in your app is now a real Promise. Every method handler returns one. Every cursor method that used to look synchronous — fetch(), findOne(), observe() — either has an async twin or is the async twin. If you're running on 3.x and the perf looks a little different than 2.x, there's a reason.

This post is about what actually changed under the hood, what got better, what got worse, and what it means for profiling and monitoring a modern Meteor app.

The pre-3.0 world: Fibers was a beautiful lie

Meteor 2.x and earlier used node-fibers, a native addon that implemented coroutines on top of V8. From your code's perspective, this meant you could write:

const user = Meteor.users.findOne({ _id: userId });
const posts = Posts.find({ userId }).fetch();
return { user, posts };

…and it just worked. No await, no callback, no Promise. Under the hood, each of those calls was actually yielding the fiber, making an async MongoDB request, parking the fiber on the event loop, and resuming when the result came back. It was cooperative multitasking dressed up as synchronous code.

This was wonderful for developer experience and terrible for observability. Two reasons:

  • The call stack lied. When you hit a slow query and looked at a stack trace, you'd see a bunch of Fiber.yield frames and a caller that appeared synchronous. Mapping that back to the actual async boundary required tooling that knew about Fibers specifically.
  • Hidden await points were everywhere. Any method call, any cursor fetch, any HTTP request could yield. You couldn't look at code and reason about where it paused without knowing which APIs were fiber-blocking.

MontiAPM and every other Fibers-era APM compensated by wrapping specific hot paths (method handlers, cursor methods, HTTP.call) with timing probes and reconstructing the call graph from those. It worked. It also meant every new API surface needed a new wrapper.

The 3.x world: real async all the way down

Meteor 3 removed Fibers entirely. The code paths that used to yield now await. If you look at the Meteor source today, you'll find things like this in packages/mongo/cursor.ts:

class Cursor {
  async observe(callbacks, options) {
    return await this._mongo._observeChanges(
      this._cursorDescription,
      /* ordered */ true,
      wrapCallbacks(callbacks),
      options?.nonMutatingCallbacks
    );
  }

  async observeChanges(callbacks, options) {
    return await this._mongo._observeChanges(
      this._cursorDescription,
      /* ordered */ false,
      callbacks,
      options?.nonMutatingCallbacks
    );
  }
}

Note: observe() and observeChanges() now return Promises on the server. This is load-bearing. If you wrote a helper that did handle = cursor.observeChanges(...) and immediately used handle.stop, it no longer works — you now get a Promise back, not a handle. You have to await it.

The pattern is consistent across the MongoDB package. In packages/mongo/collection/collection.js the public Collection.find() delegates to the connection's cursor, and in packages/mongo/collection/methods_sync.js you'll find the deprecation shims that mark findOne, fetch, count, and friends as sync wrappers that will eventually be removed. Their async counterparts — findOneAsync, fetchAsync, countAsync — are now the preferred surface.

Method handlers got the same treatment:

Meteor.methods({
  async 'posts.create'(data) {
    check(data, { title: String, body: String });
    const user = await Meteor.users.findOneAsync(this.userId);
    if (!user) throw new Meteor.Error('not-authorized');
    return await Posts.insertAsync({ ...data, userId: user._id });
  }
});

Clients call these with Meteor.callAsync('posts.create', ...). The legacy Meteor.call still works for backward compat, but on Meteor 3 it resolves the underlying Promise internally and introduces scheduling quirks if you rely on its apparent synchronousness.

What broke

Sync Mongo helpers

Every code path that used collection.find().fetch(), collection.findOne(), collection.insert(), collection.update(), collection.remove(), collection.upsert(), or collection.count() still "works" on 3.x for a migration window, but emits deprecation warnings and will be removed. The async twins are:

find().fetchAsync()
findOneAsync()
insertAsync()
updateAsync()
removeAsync()
upsertAsync()
countAsync()

In a real app this shows up as thousands of call sites to migrate. Manual grep-and-sed is slow and error-prone because the sync form is easy to hide behind helpers and default exports.

Fibers-based instrumentation

Any APM agent or tracing library that hooked into Fiber.yield to track async boundaries is now instrumenting nothing. The hot paths it used to intercept are plain async functions now. Instrumentation on 3.x has to wrap at the Promise level — AsyncLocalStorage, patched prototypes, or diagnostic channels.

Third-party packages

Any Atmosphere package that called sync Mongo helpers internally needs a 2.x to 3.x compatible release. Most of the widely-used packages have caught up, but long-tail packages (especially abandoned ones) can become upgrade blockers.

What got better

Real stack traces

When a method throws now, the stack trace looks like a normal Node.js async stack. You can follow it from the thrown error back through the await chain to the method handler to the DDP session. No fiber frames, no stitched traces. This alone is worth the migration cost for debugging.

Promise-based instrumentation works

Because everything is a real Promise, Node.js's built-in mechanisms work as expected. async_hooks and AsyncLocalStorage propagate context throughawaits naturally. diagnostics_channel can observe outbound undici and http traffic without monkey-patching. V8 inspector CPU profiles actually reflect your call graph.

SkySignal uses AsyncLocalStorage (we keep ours in a publicationContextStore) to attach publication context to observer events. When a publication starts an observer, the context — publication name, session id, user id — flows with every downstream await until the observer stops. This is impossible to do cleanly on Fibers because the continuation wasn't a first-class Node async resource.

The new observer landscape

Observers are how Meteor publications stay reactive. Three drivers can coexist on a single server in Meteor 3.5+:

  • changeStream — new in 3.5, uses MongoDB change streams for per-collection reactive updates. Scales well, relatively low overhead.
  • oplog — the classic approach, one tailer per server consuming the replica set oplog. Still works; some apps prefer it for compatibility.
  • polling — the fallback. If an observer can't be driven by changeStream or oplog, Meteor polls the collection. Poll observers are expensive and their count is a direct performance signal.

We use a single metric — "reactive efficiency" — to capture this:

reactiveEfficiency = (changeStream + oplog) / totalObservers

Apps with efficiency near 1.0 are healthy. Efficiency below ~0.7 usually means some queries can't be backed by changeStream or oplog (commonly because of $where, certain aggregation operators, or indexless query shapes), and the poll count is dragging the server down. The agent detects the driver per observer by inspecting handle._multiplexer._observeDriver.constructor.name and, for pre-3.5 apps, falls back to a MONGO_OPLOG_URL heuristic.

Deprecated API migration is actually profilable

On Meteor 2, you couldn't easily answer "how much of our code still uses sync helpers?" without grep-and-hope. On Meteor 3, you can wrap the Mongo.Collection prototype and Meteor.call, record every invocation, and aggregate by call-site fingerprint. SkySignal's DeprecatedApiCollector does exactly this. You get a ranked list of the top offenders by call volume, which is the list your engineering team should actually work down during the 3.x migration.

Practical advice for a Meteor 3 upgrade

Profile before and after

Before you upgrade, capture a baseline of response time and throughput per method on 2.x. After the upgrade, capture the same on 3.x. You want to see that the 95th-percentile response times are roughly similar or better. A lot of apps see a modest regression right after upgrade (2-5%) because naive ports of sync helpers end up awaiting more often than strictly needed — for example, fetching three documents serially with three awaits when they could be fetched in parallel with Promise.all.

Watch for new event-loop pressure

Without Fibers to mediate scheduling, your Meteor app behaves like a plain Node server — which means a single hot sync loop can block the event loop and every concurrent method will stall. Watch event-loop lag like a hawk during and after the upgrade. If you see spikes, find the synchronous CPU-bound section and either defer it to a worker thread or chunk it with setImmediate.

Parallelize independent awaits

A common mistake in the Fibers-to-async port:

// slow — serialized awaits
const user = await Meteor.users.findOneAsync(userId);
const posts = await Posts.find({ userId }).fetchAsync();
const comments = await Comments.find({ userId }).fetchAsync();

// fast — parallel
const [user, posts, comments] = await Promise.all([
  Meteor.users.findOneAsync(userId),
  Posts.find({ userId }).fetchAsync(),
  Comments.find({ userId }).fetchAsync()
]);

On Fibers both forms were effectively parallel because the scheduler could interleave yields. In 3.x, serialized awaits really are serial. A one-line change can double or triple method throughput.

Set performance budgets

Use an APM that supports alerts on p95 method response time, not just averages. Set a budget (say, 200ms p95 for your ten busiest methods) and alert on regressions. The upgrade is the perfect time to introduce these because you have a clean baseline and a team that's paying attention.

Don't skip the observer audit

Apps with thousands of observers and low reactive efficiency are the biggest perf surprises after upgrade — the event loop is busier, every poll observer costs more, and the cumulative effect is a server that quietly runs 30% slower. Scan your publications for queries that can't be backed by oplog or changeStream. Rewrite them with projections and indexes that make reactive mode viable, or explicitly opt them into polling with a sensible interval.

The upshot

Meteor 3 is faster, more debuggable, and more observable than Meteor 2 — but only if you finish the migration. A half-migrated app (mix of sync helpers, mix of call and callAsync, Fibers-era instrumentation still running) is worse on 3.x than it was on 2.x because you've lost the magic that used to cover for you.

The good news: once you're fully on 3.x with async-first code, you get a pile of observability superpowers the Fibers era couldn't deliver. Real async stacks. Context propagation through AsyncLocalStorage. Per-observer driver visibility. Actionable deprecation reports. And a monitoring surface that finally matches what modern Node tools can do.