Meteor on Bun: ESM bundle, native host, and initial benchmarks

Hello team !

After many experiments and several prototypes, an initial PR was opened to lay the groundwork: meteor/meteor#14311. The current work is proposed in meteor/meteor#14751 :+1:

The goal is not simply to run the existing Node bundle with Bun. This work adds a native ESM server path and a production host that uses Bun’s network and file primitives directly, while preserving compatibility with existing Meteor packages :+1:

TLDR

This PR adds an opt-in ESM server bundle, Bun development support, a native Bun DDP WebSocket transport, and an optional Bun.serve() production host. On the T450 benchmark machine, Bun started about twice as fast, used roughly half the peak RSS, and delivered substantially higher DDP and static-file throughput on the measured workloads. The existing Node legacy path remains unchanged.

Disclaimer

As stated in the PR, this was an AI-assisted project. Codex Astra and Luna helped me brainstorm the idea, break down the work, and create the implementation plan. Gemini 3.8 then implemented and iterated on the core code. In parallel, Luna created the plan for a separate application used to test regressions and performance, and Claude Fable implemented that Kitchen Sink test application together with its regression and benchmark harness :bar_chart:

I deliberately used different models for the core codebase and for the test application, so that the implementation was checked against an independent application rather than only against its own assumptions. I made the technical decisions with Codex, including whether and where to use Bun-specific APIs :+1:

The Bun port is deliberately a port

I considered using this work as an opportunity to refactor Meteor’s foundations, but I kept this PR focused on the Bun port. For transparency, my internal telemetry estimates roughly 273 million Gemini tokens, 42 million Claude tokens excluding repeated cached context (about 2.5 billion when that replay is counted), and 22 million Codex tokens, over six calendar days from September 8 to September 13. These figures describe model-context processing; they are not a benchmark of engineering effort or a general measure of cost

I also ran a separate, unvalidated experiment asking an AI system to recreate Meteor with Bun from scratch. It produced a modern prototype in about an afternoon, with features such as offline support, Capacitor, PWA, MCP, and multiple databases. That is a separate direction to explore, not a claim that the prototype is production-ready or equivalent to Meteor. The difficult part is no longer generating code; it is validating the result with human review and real applications :smile:

What is added

  • meteor build --format=esm produces a standard ESM server bundle that can run on Node or Bun.
  • meteor run --runtime=bun enables development under Bun, including reloads and IPC between the runner and the server process.
  • DDP uses a WebSocket transport instead of SockJS under Bun. Under Node, SockJS remains the default transport, while uWebSockets.js remains available where its native Node addon is supported and can be selected explicitly.
  • bun-host.mjs provides an optional production host based on Bun.serve().

What the native Bun host does

Bun.serve() listens directly on the public port and routes requests according to their type:

  • Static files are served with Bun.file() from the Meteor manifests, with ETags and immutable caching. Assets therefore do not need to pass through the Express stack.
  • DDP WebSocket connections are accepted directly by Bun. server.upgrade(req) is Bun’s API for turning an HTTP request into a WebSocket connection. The connection is then handed to Meteor’s StreamServer, the internal component that handles the DDP protocol: handshakes, method calls, publications, and messages sent to the client. In this mode, the DDP path does not go through SockJS.
  • Dynamic routes, SSR, and Express/Connect middleware remain compatible with the Meteor ecosystem. They are forwarded to the WebApp server through a local Unix socket.
  • This forwarding uses Bun’s native fetch (Bun.fetch) with the { unix: socketPath } option, preserving request bodies and streaming responses.

This separation makes it possible to use Bun’s native primitives where they provide a real benefit, without rewriting the existing Meteor middleware stack :+1:

Initial numbers

An in-depth benchmark was run on a ThinkPad T450 (i7-5600U, 4 cores, 15 GB RAM), my second machine, with very few other programs running. The same application, MongoDB instance, and workloads were used for Node legacy, Node ESM, Bun ESM, and bun-host

For startup time and memory, lower is better. For throughput, higher is better :wink:

Metric Node legacy Node ESM Bun ESM Bun bun-host
Cold startup (ms, lower is better) 2,0852,0961,0751,084
Peak RSS (MB, lower is better) 494581246222
DDP method throughput at concurrency 100 (req/s, higher is better) 14,89611,76123,10021,526
2FA throughput (ops/s, higher is better) 39,51846,14173,067107,153
Static HTTP throughput at concurrency 1 (req/s, higher is better) ~595~6017604,381

These figures are indicative: they apply to this machine, these versions, and these workloads. They are not a general performance guarantee. The complete benchmark and its methodology are included in the PR so that others can run it themselves; an in-depth pass takes several hours, approximately six hours on this T450.

Compatibility and validation

Kitchen Sink is an independent Meteor test application designed to verify the observable behavior of a real application. It contains 238 probes and eight profiles covering Node legacy, Node ESM, Bun ESM, development under Bun, and the native Bun host.

The executable profiles pass with newFailures = 0, meaning that no probe which passed on the Node reference becomes red on the profile under test. The two known Blaze failures (#512 and #514) were already present before this port: they reproduce on the Node reference and are unrelated to Bun.

Legacy Node/CommonJS behavior remains unchanged. The ESM/Bun path is opt-in, allowing gradual adoption and direct comparisons with the current behavior.

Current limitations

  • The ESM bundle and bun-host are new and still need to be exercised against more Meteor applications and Atmosphere packages.
  • Packages using Node-specific native addons may require a separate audit.
  • Performance figures should be reproduced on other machines and workloads before drawing general conclusions.

The PR is here: meteor/meteor#14751.

I would be interested in feedback on:

  1. which applications and packages should be tested first under Bun;
  2. which Node or Meteor APIs still cause problems;
  3. which workloads should be added to the benchmark;
10 Likes

@dupontbertrand

You could test WeKan. Could Bun make WeKan AppImage smaller?

Bun does not support s390x

Bun does not support RISC-V officially yet, but there is some progress

Bun requires AVX , where WeKan uses Qemu-user for some binaries if AVX missing, so maybe Qemu-user would be required for Bun too

2 Likes

@xet7 First of all thank you very much for testing it :folded_hands: , and yes: I cloned WeKan `main` (c5c64ca, Meteor 3.6-beta.0 + Rspack) and built it with the PR checkout. Results below, plus answers on AppImage size, architectures and AVX.

WeKan boots and works on the three new paths (Node ESM, Bun ESM, bun-host) after one fix in the PR; the legacy Node bundle is the unchanged reference. Same clone, same MongoDB, one production build per format (`meteor build --server-only`, plus `–format=esm` for the ESM rows), on my laptop:

RuntimeEntry pointCold boot to HTTP 200RSS after bootSignup + board creation (Playwright)
Node 24.15, legacy bundle (reference)main.js4.7 to 6.7 s835 MBOK, 0 errors
Node 24.15, ESM bundleindex.mjs6.2 s1086 MBOK, 0 errors
Bun 1.4.0, ESM bundleindex.mjs5.2 s325 MBOK, 0 errors
Bun 1.4.0, native hostbun-host.mjs3.7 s319 MBOK, 0 errors

Single runs, indicative only. DDP connection, five publications (`boards`, `boardTemplates`, `accountSettings`, `announcements`, `my-avatars`) and the static assets (7.9 MB main script, ETag, immutable caching on bun-host) behave the same everywhere. `sharp` loads fine under Bun.

What WeKan caught that Kitchen Sink had not.
The ESM loader resolved npm dependencies of *scoped* Atmosphere packages (`aldeed:simple-schema` requiring `message-box`) through the virtual path with the colon, while the directory on disk uses an underscore. Boot crashed identically under Node ESM, Bun ESM and bun-host; the legacy bundle was fine. Two-line fix in `esm-loader.mjs`, mirroring what the legacy `npm-require.js` has always done, plus a scoped package with an npm dependency in the CI smoke app so it stays covered. Kitchen Sink only had unscoped packages with npm dependencies, so this is exactly the kind of gap a second real application finds. Thanks for pushing for it :+1:

Two things you would need on the WeKan side: WeKan pins the 3.6-beta package versions (`ecmascript@0.19.2-beta360.0`, `rspack@1.4.0-beta360.0`) while the PR is on `devel` (0.19.1 / 1.3.0), so I dropped the pins in my clone; nothing else in the app changed :smile:

Smaller AppImage: no, not from the runtime :grimacing: The ESM and legacy bundles of WeKan are the same 964 MB, byte for byte apart from the boot files (40 KB for ESM, 80 KB for legacy). The runtime binary is the only piece that changes: Bun 1.4.0 is 81 MB on disk against 123 MB for Node 24, roughly 40 MB less before compression, a few percent of a 214 MB AppImage. FerretDB and the mongo-tools stay the same. If size matters, the levers are inside the bundle and have nothing to do with Bun: 145 MB of source maps (5,000 files), and 120 MB of prebuilt uWebSockets.js binaries shipped by `ddp-server` (20 `.node` files for darwin, linux and win32 times four Node ABIs), of which a Linux x64 build needs 28 MB at most, and none at all with SockJS, which is what WeKan uses. The measured gains of Bun are memory (WeKan at rest: 319 to 325 MB instead of 835 MB), startup and throughput, not disk :blush:

Architectures: this is the real blocker for WeKan’s release matrix Bun 1.4.2 ships Linux x64 and aarch64 (glibc and musl), macOS, Windows x64 and arm64, FreeBSD and Android. Nothing for i686, armhf/armv6/armv7, ppc64le or s390x, and no official riscv64 build yet (the issue you link is still open), while WeKan releases for all of those. So Bun could only ever be an optional runtime for the x64 and arm64 builds :unamused_face:

AVX: this changed (a little bit, but to be honest I’m not an expert at all on this subject so thank you Claude for that:) The crash you link is Bun 1.2.2. The current installation docs state that Bun ships a single x64 binary targeting Nehalem (SSE4.2) and selects the AVX2/AVX-512 code paths at runtime; the `-baseline` release assets are kept only as aliases. So the floor is now SSE4.2 (first-generation Intel Core, AMD Bulldozer or newer), not AVX: Installation | Bun Docs . Running Bun under qemu-user on anything older would cancel the startup and memory gains, so I would not go there :joy:

1 Like

Removing those beta pins confirms that the core ESM transformations work on real-world projects seamlessly You can now run benchmarks on WeKan to measure Bun memory gains on large DDP payloads

@dupontbertrand

Could you send PR to WeKan, so that there would be GitHub workflow Bun.yml that would build Bun versions of WeKan to all OS/CPU platforms Bun support, same way like existing AppImage? And maybe also add that Bun.yml to be run at release-all.yml and release-all-missing.yml , that I run when building WeKan releases, so that those would be also added to each new release?

2 Likes