Meteor 3.5 is out!
DEV Community

Meteor 3.5 is out!

Change Streams by Default

Meteor 3.5 is a broad release, and it earns the word. Its center of gravity is the reactive data layer (the part of your app that runs in production around the clock), but the work reaches from the database driver out to your auth middleware and the Node runtime underneath.

Here is the map, and everything below is covered in detail further down. For the complete, line-by-line list of what shipped, see the full changelog.

The headline is change streams by default: reactivity now works on managed and serverless MongoDB (Atlas Shared, Atlas Serverless) where the oplog was never available, so each instance does less work per subscription, your fleet gets smaller, and so does the bill.

Around that sits the rest of the real-time modernization, with DDP session resumption that survives a dropped connection, a pluggable DDP transport with a uws option, a fully functional DISABLE_SOCKJS, and EJSON optimizations that trim allocations on the hot path.

If you build APIs and auth, there is accounts-express for first-class authenticated routes, async DDPRateLimiter matchers, and client logins that are fully promise-based end to end.

Underneath it all, the platform moves to Node.js 24, with a handful of dependencies (MongoDB collation, OTPAuth for 2FA, http-proxy-3) quietly modernized so you don't have to think about them.

None of these are vendor benchmarks; the numbers in this post come from developers testing their own apps in public. And 3.5 is the release where the performance thread that ran through the 3.x line finally reaches runtime.

The Problem We Kept Running Into

For years, scaling real-time in Meteor came with a quiet catch, one a lot of teams hit without talking about it much. Meteor's real-time updates rode on Mongo's oplog: to know when data changed, every app server tailed the database's replication log (a running record of every write to every collection) and filtered it, in your Node process, down to just the queries it cared about.

It worked, but it carried two costs.

The first is that the oplog is the whole database's write log, not just yours. Meteor tails all of it and throws away what it doesn't need. The moment something outside Meteor wrote to the same database (a nightly ETL job, a data migration, a third-party service injecting records), every app server still had to read and discard all of that traffic. Your Meteor CPU bill went up because of writes your app never even made.

The second is that it tied you to self-managed Mongo. On managed and serverless tiers like Atlas Shared, the oplog is off-limits. So the moment you moved to the hosting most teams actually want, real-time broke, and your only fallback was polling: asking the database the same questions over and over, burning CPU and money to fake what should be instant.

Meteor 3.5 changes that. Change streams are now the default, real-time works on managed and serverless Mongo, and there's no config to write. And real apps are already reporting the difference, in public, with numbers.

The Headline: MongoDB Change Streams

Do more with fewer servers

In Meteor 3.5, reactivity is powered by MongoDB change streams by default, and the practical result is simple: each app instance does far less work per subscription because change detection now lives on the database server instead of your Node process. That translates into more concurrent users, subscriptions, and methods per instance, smaller fleets, and a smaller bill.

There is a second payoff that matters just as much. Real-time now works on managed and serverless MongoDB tiers, like Atlas Shared and Atlas Serverless, where oplog access simply is not available. Until now, users on those tiers were pushed into polling, the slowest and most expensive fallback. With change streams, they get efficient real-time updates without paying for a dedicated cluster just to tail the oplog.

There is one requirement, and Meteor handles it for you: change streams need MongoDB 6+ running as a replica set or a sharded cluster. On older or standalone MongoDB, Meteor detects that it can't use them and automatically falls back (to oplog, and then to polling). The same fallback covers cursors that use skip or limit and selectors change streams cannot serve. So the default is safe: you get the fast path where it works and a working path everywhere else, with no code changes and nothing to configure.

If you ever need the previous behavior, you can pin the pre-3.5 path for the whole app by setting packages.mongo.reactivity to ["oplog", "polling"] in settings.json (more on that, and the rest of the driver's tuning, below). For the full picture, including how the observer driver selects and switches strategies, see the change streams observer driver docs. The feature landed in PR#13787.

The Proof: Real-World Numbers

We could tell you change streams are faster. Instead, here is what the community measured. These are not vendor benchmarks run on hand-picked hardware to make a launch look good. They come from Meteor developers testing their own apps and load harnesses, and posting the results in public. Read them that way.

More connection capacity under load

In the official Meteor 3.5 announcement, forum member @italojs shared a load test comparing the two reactivity backends on the same app. Change streams sustained roughly 40% higher connection capacity than oplog. Under the same ramp, oplog hit out-of-memory crashes around 1,200 virtual users, while change streams stayed stable up to about 1,680 virtual users, degrading only to timeouts rather than crashing. The post includes a montiAPM chart of both runs; see the charts in the thread for the full picture.

A real app, not a benchmark

One of the most striking reports came from a production user. In the same thread, @mvogt22 (Michael) reported that after switching his app to change streams, average Event Loop Delay dropped from 97ms on v3.4 to 18ms on v3.5-beta.4, roughly a 5x (about 81%) reduction (post 32, post 37). Event Loop Delay is a direct measure of how starved your Node process is; cutting it that far means far more headroom before an instance falls over. He also posted monitoring screenshots marking the v3.5-beta.4 deploy, with the improvement visible right at the deploy line.

Directional, but pointing the right way

@dupontbertrand built a community benchmark harness (a Compare Runs dashboard that pits release-3.4.1 against release-3.5 across several scenarios), documented in the Meteor performance: a to-do list thread. The resource picture is striking. Across the ddp-reactive-light, fanout-light, and reactive-crud scenarios, 3.5 cut average app-server CPU by roughly 32โ€“67%, average RAM by up to about 73%, and total garbage-collector pause time by 81โ€“94%, with GC event counts down a comparable amount. Treat these as directional rather than final (the author noted that server configuration changed between runs, and wall-clock time was mixed across scenarios), but the direction is unmistakable: with change streams, the app server simply does far less work.

Numbers like these depend on running change streams well, so it is worth understanding the driver you are now running by default.

Change Streams Under the Hood: Tuning and Trade-Offs

Change streams are the default, but the whole driver pipeline is configurable. Meteor picks a reactivity driver in order, and you control that order under packages.mongo.reactivity in settings.json. The default is effectively ["changeStreams", "oplog", "polling"]: try change streams first, fall back to oplog, then polling. To pin the pre-3.5 behavior, set it to ["oplog", "polling"].

A few optional knobs live under packages.mongo.changeStream:

  • delay.error (default 100 ms) is how long the driver waits before restarting a stream after an error
  • delay.close (default 100 ms) is the equivalent delay after a clean close
  • waitUntilCaughtUpTimeoutMs (default 1000 ms) is the upper bound Meteor waits for the change stream to catch up when coordinating with DDP write fences

That timeout is a genuine trade-off: if it elapses, the write fence proceeds anyway, so a client can briefly miss its own write (read-your-writes). Raise it for stricter consistency, lower it to keep methods snappy under load.

{
  "packages": {
    "mongo": {
      "reactivity": ["changeStreams", "oplog", "polling"],
      "changeStream": {
        "delay": {
          "error": 100,
          "close": 100
        },
        "waitUntilCaughtUpTimeoutMs": 1000
      }
    }
  }
}

Now the trade-off worth knowing: change streams are not free everywhere. They are extremely efficient for targeted queries, but a very broad selector or a highly-mutated collection pushes matching work onto the Mongo cluster, which can become the bottleneck. Oplog shifts that load back to the app server, better when Mongo is your constraint, but it scales poorly under very high global write volume and needs oplog permissions. Polling is the expensive last resort.

The guidance is boring and effective: narrow your selectors, add the matching indexes, and force oplog for specific broad-filter collections by reordering their drivers. Full details are in the change streams observer driver docs.

How a Feature That Was Ready Took Four and a Half Months to Ship

Change streams were essentially done before the first beta. When 3.5-beta.0 went out on 2026-02-11, the feature worked. The four and a half months that followed were not spent finishing it. They were spent earning the right to make it the default.

None of this was for lack of wanting it. Change streams had come up in the community for years as the obvious answer to the oplog's limits. Issue #11578, "Slow oplog tailing on ATLAS" is one of many threads where the pain of tailing the oplog on managed Mongo ran straight into "so why not change streams?". Wanting a feature and shipping it as a safe default, though, are very different problems.

Three forces stretched the calendar.

The first is the nature of the change itself. Change streams rewire the observe driver, which is the reactive core of Meteor. Every subscription, every live query, every observeChanges call ultimately flows through it. You do not flip the heart of a framework to a new default on a hunch. That confidence has to be manufactured in production-like conditions, over and over, which is why 3.5 ran through twelve betas and two release candidates instead of one or two.

The second is timing. A large backlog of performance work landed in the same window: the EJSON serialization improvements, the DDP transport rewiring, the session-resumption machinery. Each of those PRs needed careful review, and reviewing them alongside a driver rewrite meant the same small group of maintainers was under load on multiple fronts at once.

The third is context, not a complaint: this cycle also saw a surge in incoming PRs, driven in large part by the community's growing use of LLM-assisted contributions. That is a healthy signal, and it broadened the review surface the maintainers were carrying while everything above was in flight. (What that shift means for Meteor is worth its own note, at the end.)

Then there was the long tail of correctness, which is where the calendar really went. Making change streams the default meant grinding through every place the Meteor data model meets MongoDB's native types. PR#14238 fixed ObjectID fields being delivered to the client as binary when a query used a projection. PR#14389 closed a set of race conditions in ChangeStreamObserveDriver where events could be dropped during snapshot, restart, or watch setup, added a polling fallback for skip/limit cursors, and preserved uws listeners across restarts.

Beyond those two, the work was quieter and harder to headline. Meteor-to-native BSON and ObjectID translation had to be handled correctly at every selector and matcher boundary, not just once at the edge. And a standalone-Mongo mis-detection could drop the driver into a change-stream restart loop, which had to be tracked down and fixed.

The pattern is the theme. The code was done early. Getting it right, in public, at production scale, is what took the calendar.

The Rest of the Real-Time Modernization

Change streams get the headline, but they are not the only reason 3.5 is faster. The whole real-time layer was reworked this release (how sessions survive a dropped connection, how DDP moves bytes over the wire, and how EJSON serializes them), and each piece contributes its own share of the speedup.

Sessions survive the network, not just the request

With DDP Session Resumption (PR#14051), a client that drops and reconnects within a grace period (default 15s) picks up its existing session instead of tearing everything down. Pending method calls are not lost, and active subscriptions resume where they left off. The payoff is felt on mobile handoffs, sleeping tabs, and flaky Wi-Fi: smoother UX for users, and far less server CPU during reconnect storms because you are not re-running every subscription from scratch. The grace period and message-queue limit are tunable per app.

Pick your transport

DDP is now pluggable (PR#14231). Keep sockjs, the default, for maximum compatibility behind strict proxies, or switch to uws (uWebSockets) for lower latency and higher throughput on internal or controlled deployments. You flip it via an env var or a settings flag, with no app code changes. See the DDP transport docs.

Drop SockJS entirely when you don't need it

DISABLE_SOCKJS is now fully functional (PR#14206). Where you don't rely on the polling fallback, you get a smaller client bundle and fewer handshakes.

Fewer allocations on every message

A batch of EJSON optimizations trims the cost of serialization on the hot path:

  • Zero-clone stringifyDDP (PR#14213)
  • Copy-on-write toJSONValue/fromJSONValue (PR#14209)
  • Fast-path primitive comparisons and early bail-out in EJSON.equals (PR#14208, PR#14205)
  • No array allocation in lengthOf (PR#14204)

The net effect on a busy server: fewer allocations in DDP.

Comments

No comments yet. Start the discussion.