DEV Community

italojs for Meteor

Posted on

Meteor pluggable DDP transport: meet uWebSockets.js

Type caption for image (optional)

For thirteen years, every DDP message your Meteor app sent rode the same rails: SockJS. Server-side, that's a copy-fork of SockJS v0.3.4 (vintage 2012), carried forward release after release because it worked, and because the transport was tightly integrated with the rest of the DDP stack. It was load-bearing duct tape: fast enough, compatible everywhere, and completely unswappable.

Meteor 3.5 cuts that weld. The DDP WebSocket transport is now pluggable, SockJS is just one implementation behind a small registry, and you can opt into a C/C++ WebSocket server (uWebSockets.js) with a single env var. SockJS stays the default; nothing changes unless you ask.

This post covers four things:

  • What's new in the transport layer
  • How to turn on uws in about five minutes
  • The real benchmark numbers (the good and the meh)
  • The caveats you need before you ship it.

What's new in 3.5

There's now a real boundary (a transport registry in packages/ddp-server/transports/index.js) and SockJS is just one implementation behind it. It's still the default, and your existing apps behave exactly as before. But now you can opt into a different transport with a single env var:

DDP_TRANSPORT=uws meteor run
Enter fullscreen mode Exit fullscreen mode

That uws is uWebSockets.js, the Node.js binding for uWebSockets, a WebSocket server written in C/C++. And the win isn't only server-side: when you pick a non-SockJS transport, the browser stops using the SockJS shim at runtime and connects with native WebSocket instead, new WebSocket(...) straight to /websocket, no 2012-era fallback negotiation in the path.

Turn on uws in 5 minutes

You don't need a special branch or a fork to try the new transport, just Meteor 3.5 and one of three switches. Here's the whole loop, from an empty folder to a verified native WebSocket.

1. Get a Meteor 3.5 app

The pluggable transport ships in 3.5. Pin it explicitly when you scaffold (a plain meteor create uses whatever release your installed CLI defaults to, which may still be 3.4.x):

# new app, pinned directly to 3.5
meteor create uws-demo --release 3.5
cd uws-demo

# ...or, for an existing app:
meteor update --release 3.5
Enter fullscreen mode Exit fullscreen mode

You don't need to add ddp-server to .meteor/packages, it ships transitively via meteor-base (you'll see it in .meteor/versions). SockJS is still the default, so at this point nothing has changed yet.

2. Give yourself something to watch

Add an echo method on the server and call it from the client so you can see the round-trip succeed regardless of transport.

Watch out for the entry point. The default 3.5 skeleton is the React skeleton, whose client entry is client/main.jsx (pinned in package.json under meteor.mainModule.client). If you create a new client/main.js, Meteor silently ignores it, your code never runs and there's no error. Put the client snippet in the skeleton's actual entry, or check package.json -> meteor.mainModule.client first.

// server/main.js, NOTE: this replaces the skeleton's sample Links
// collection + publish/startup. Fine for this demo; add to the existing
// file instead if you want to keep them.
import { Meteor } from 'meteor/meteor';

Meteor.methods({
  echo(payload) {
    return payload;
  },
});
Enter fullscreen mode Exit fullscreen mode
// client/main.jsx  (the skeleton's real client entry, not client/main.js)
import { Meteor } from 'meteor/meteor';

Meteor.startup(() => {
  console.log('[demo] client transport =>',
    __meteor_runtime_config__.DDP_TRANSPORT || 'sockjs');
  Meteor.call('echo', { hello: 'uws', at: Date.now() }, (err, res) => {
    if (err) console.error('[demo] echo failed', err);
    else console.log('[demo] echo replied =>', res);
  });
});
Enter fullscreen mode Exit fullscreen mode

Run meteor run and confirm the browser console logs the echo reply. That's your baseline on SockJS.

3. Flip the switch (three ways, in precedence order)

There are three ways to select uws. They resolve in this order, highest first:

# 3a. settings.json (HIGHEST precedence)
meteor run --settings settings.json
Enter fullscreen mode Exit fullscreen mode
{
  "packages": {
    "ddp-server": {
      "transport": "uws",
      "uws": { "port": 5001, "host": "127.0.0.1", "payloadLength": 48, "timeout": 45 }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
# 3b. environment variable (the simplest one)
DDP_TRANSPORT=uws meteor run

# 3c. legacy alias, kept for backward compat (equivalent to selecting uws)
DISABLE_SOCKJS=1 meteor run
Enter fullscreen mode Exit fullscreen mode

If Meteor.settings.packages['ddp-server'].transport is set, it wins over DDP_TRANSPORT, which wins over DISABLE_SOCKJS. With nothing set you get sockjs, exactly as before.

One thing to know early: the uws internal port is only settable via Meteor.settings / METEOR_SETTINGS (see Step 5). So even on the env-var path (3b), you still need METEOR_SETTINGS if you want a non-default port.

4. Verify it's really uws

Meteor does not print a transport line at startup, so don't go looking for one in the terminal, the boot log gives zero hint of uws vs sockjs. Use checks that actually distinguish the two:

  • The internal port is bound. uws listens on its own port (default 5001). lsof -nP -iTCP:5001 -sTCP:LISTEN shows the bound socket under uws and shows nothing under sockjs.
  • SockJS endpoints go quiet. On SockJS, curl http://localhost:<PORT>/sockjs/info returns SockJS server JSON ({"websocket":true,...}). Under uws that route no longer answers as SockJS, you get app HTML instead.
  • Browser runtime config. In the browser console, evaluate __meteor_runtime_config__.DDP_TRANSPORT, it reads uws when uws is selected (and is undefined/sockjs otherwise). The client opens a native WebSocket, not SockJS.
  • DevTools → Network. Filter by WS. You'll see a single connection straight to /websocket, and the SockJS handshake traffic (/sockjs/info, random /sockjs/<n>/<id>/... requests) is gone.

If you fat-finger the transport name, you'll know immediately: an unknown name throws at boot (Unknown DDP transport: "x". Valid transports: sockjs, uws).

5. The internal port (and the multi-instance gotcha)

UWS does not share the app's PORT. It runs uWebSockets.js on its own internal port (default 5001), and Meteor's main HTTP server proxies /websocket upgrades to it. That port is only configurable through Meteor.settings, there is no dedicated env var. In env-driven deploys, pass it via METEOR_SETTINGS.

The catch: when several Meteor instances share one kernel network namespace (Docker network_mode: "host", or two dev apps at once), each must bind a distinct uws.port. Changing only PORT is not enough, every instance still defaults to 5001, and the second one fails loudly with an "address already in use" error. That's intentional: a loud failure beats silently splitting WebSocket traffic across unrelated processes.

# instance 1
PORT=3039 METEOR_SETTINGS='{"packages":{"ddp-server":{"transport":"uws","uws":{"port":5001}}}}' meteor run

# instance 2 (distinct PORT *and* distinct uws.port)
PORT=3040 METEOR_SETTINGS='{"packages":{"ddp-server":{"transport":"uws","uws":{"port":5002}}}}' meteor run
Enter fullscreen mode Exit fullscreen mode

That's it, five minutes, one switch, and a native WebSocket on the wire.

Multitenancy: many tenants, one host

The multi-instance gotcha from Step 5 stops being a footnote the moment you go multi-tenant: several independent apps on one box, each customer on an isolated Meteor process. When those processes share a single kernel network namespace (Docker network_mode: "host", one VM, one bare-metal host), every uws server reaches for the same default internal port 5001. The first tenant to boot wins; the next one dies with "address already in use." The Step 5 rule is the whole fix, applied once per tenant: a distinct uws.port for each process, next to the distinct public PORT and database you already give it.

WeKan runs exactly this in production. WeKan PR #6365 restores DDP_TRANSPORT=uws as WeKan's default and adds a docker-compose-multitenancy.yml that boots two fully isolated tenants against one shared Mongo replica set:

Tenant Public PORT Mongo database Internal uws.port
tenant 1 8081 wekan_tenant1 5001
tenant 2 8082 wekan_tenant2 5002

Each service runs with network_mode: "host", selects the transport with DDP_TRANSPORT=uws, and pins its own internal port through METEOR_SETTINGS, the same pattern from Step 5 scaled to N tenants:

services:
  wekan-tenant1:
    image: ghcr.io/wekan/wekan:latest
    network_mode: "host"
    environment:
      - PORT=8081
      - MONGO_URL=mongodb://127.0.0.1:27017/wekan_tenant1?replicaSet=rs0
      - DDP_TRANSPORT=uws
      - METEOR_SETTINGS={"packages":{"ddp-server":{"uws":{"port":5001,"host":"127.0.0.1"}}}}
  wekan-tenant2:                       # distinct PORT, database, and uws.port
    image: ghcr.io/wekan/wekan:latest
    network_mode: "host"
    environment:
      - PORT=8082
      - MONGO_URL=mongodb://127.0.0.1:27017/wekan_tenant2?replicaSet=rs0
      - DDP_TRANSPORT=uws
      - METEOR_SETTINGS={"packages":{"ddp-server":{"uws":{"port":5002,"host":"127.0.0.1"}}}}
Enter fullscreen mode Exit fullscreen mode

Adding a third tenant is just one more block with the next free PORT, a fresh database, and uws.port 5003.

Worth knowing the history: this only became a safe default after meteor/meteor#14425 fixed a multi-process uws port-collision bug that WeKan had temporarily worked around by falling back to SockJS. Meteor 3.5 ships that fix, which is why WeKan could turn uws back on for everyone. Single-process, single-domain deployments sidestep all of this: one process, one port, nothing to coordinate.

Benchmarks: sockjs vs uws

There are three sets of numbers here, and they answer different questions. The real-app harness numbers (first, and the ones to trust most) run the whole Meteor stack (a React tasks app on 3.5, a real Mongo, change-streams reactivity) under load, and ask "what does flipping the transport actually buy a real app?" The transport micro-benchmarks (ours and the PR's) isolate just the WebSocket layer to show the ceiling. Different lenses, same direction.

Real-app benchmarks (the performance harness)

These were run with Meteor's performance benchmark framework against the tasks-3.x app pinned to METEOR@3.5, the only variable changed between runs being DDP_TRANSPORT. Every run was pushed to the performance dashboard (tags uws-blogpost-sockjs / uws-blogpost-uws), so the raw JSON is reproducible, not hand-typed. Source JSON: results/harness/.

This is the honest middle ground: a real machine, a real app, a real database, not loopback, but not production-at-scale either.

Methods / RPC (scenario ddp-non-reactive-light): 150 virtual users hammering insertTask / removeTask over DDP for 30 s at a fixed arrival rate. Because the load is rate-limited (both transports serve the same ~311 msgs/s, the same 6,150 method calls), the interesting question isn't "who does more", it's how much it costs the server to do the same work:

Same workload on both transports (~311 msgs/s, 6,150 method calls):

  • App CPU, avg — sockjs 8.85%, uws 8.03% (−9.3%)
  • App RAM, avg — sockjs 199.6 MB, uws 177.5 MB (−11.1%)
  • GC total pause — sockjs 43.2 ms, uws 31.9 ms (−26.0%)
  • insertTask p95 — sockjs 0.525 ms, uws 0.487 ms (−7.3%)
  • removeTask p95 — sockjs 0.408 ms, uws 0.359 ms (−11.9%)

Same throughput, measurably less CPU, RAM, and GC pressure under uws, headroom you can spend on more concurrent users before the box saturates. That's the real-world version of the micro-benchmark throughput win: at saturation it shows up as higher ceiling; below saturation it shows up as lower cost.

Pub/sub fan-out (scenario fanout-light): 50 subscribers, 1 writer, measuring write→last-subscriber propagation latency.

Propagation latency (50 subscribers, 1 writer):

  • avg — sockjs 96.2 ms, uws 95.9 ms
  • p50 — sockjs 99 ms, uws 97 ms
  • p95 — sockjs 105 ms, uws 101 ms
  • p99 — sockjs 105 ms, uws 102 ms

Practically a tie, uws shaves a few milliseconds off the tail and nothing off the median. This is the single most important honesty in this whole post: for pub/sub-bound apps, the transport is not your bottleneck. Fan-out cost lives above the socket (computing and diffing document changes and pushing them to every subscriber), so swapping SockJS for uws there buys you almost nothing. The harness reproduces exactly what the PR authors saw (~100 ms across the board).

Transport micro-benchmarks

Two ways of isolating just the WebSocket layer, they agree, and both show a bigger gap than the real app, because here there's no app logic diluting the transport's share of the work.

Our local re-run (bench/bench.js via bench/run-bench.sh): raw DDP over a native WebSocket to /websocket, a 100-connection connect burst, a sequential echo RTT loop (1,000 iterations), then a sustained throughput window of 50 clients with 4 in-flight calls each for ~10 s. Outputs in results/sockjs.json / results/uws.json, verbatim:

Metric sockjs uws ratio
Connect, single 9.893 ms 8.518 ms 0.86×
Connect burst, p50 25.744 ms 27.886 ms 1.08×
RTT p50 0.126 ms 0.091 ms 0.72×
RTT p99 0.269 ms 0.191 ms 0.71×
Throughput 33,556 calls/sec 54,021 calls/sec 1.61×

Ratio column: for Connect and RTT rows lower is better (a ratio below 1.00 favors uws); for Throughput higher is better. Read these as relative, not absolute, localhost loopback strips out network latency, which is why the calls/sec dwarf any real deployment. The signal that survives: ~1.61× method throughput and ~28–29% lower RTT for uws. Connect is a wash (the burst p50 even came out marginally worse for uws; run-to-run noise dominates there).

The PR #14231 authors' micro-benchmarks corroborate it on a different machine (same build, Node 24.14.0, 5–10 randomized runs): RTT p50 0.336→0.232 ms, and sustained throughput 8,156→14,300 calls/sec, roughly 1.75×. Same shape as our loopback run.

What you actually gain, and should you switch?

So what do you actually get for one env var?

Lower cost (and a higher ceiling) on method-heavy apps. uWebSockets.js is a C/C++ WebSocket server, and it shows up most where RPC dominates. In the full-app harness, serving the same method load cost ~9% less CPU, ~11% less RAM, and ~26% less GC pause under uws, that's headroom for more concurrent users. Push past saturation and the same advantage reads as throughput: ~1.61× in our loopback micro-benchmark and ~1.75× in the PR's, with RTT down ~28–29% (all numbers and methods in the Benchmarks section above). Different machines, same direction.

A simpler client path at runtime. When you select a non-SockJS transport, the browser uses native WebSocket instead of running Meteor's SockJS shim, fewer layers between your method call and the wire. (Note: the SockJS code still ships in the client bundle on 3.5, socket-stream-client/browser.js imports it statically, it's just never instantiated when uws is selected. So this is a runtime simplification, not a smaller download.)

Opt-in, reversible, and a real foundation. It's one variable (DDP_TRANSPORT=uws), SockJS stays the default, and switching back is just removing the flag. Under the hood the transport is now a clean, swappable boundary, so future transports can be benchmarked fairly without surgery on the DDP stack.

When NOT to switch (yet)

  • It adds a moving part. uws listens on its own internal port (default 5001) and the main server proxies /websocket upgrades to it. One extra hop, one more thing to reason about.
  • Multi-instance deploys need distinct ports. Every instance sharing a kernel network namespace must set its own uws.port (covered in Step 5 and the Multitenancy section), changing only PORT isn't enough, and the second instance fails loudly.
  • Pub/sub-bound apps may see little change. Fan-out cost lives mostly above the transport layer; PR #14231 saw ~100 ms across all transports. uws is not a magic bullet here.
  • Restrictive networks still need SockJS. Corporate proxies and firewalls that block raw WebSocket are exactly why SockJS exists and stays the default fallback.
  • It's newer than SockJS. uws landed in 3.5, where SockJS has thirteen years of production mileage behind it. It stays opt-in by design, so you adopt it deliberately.

The recommendation

Try uws if your app is method/RPC-heavy, you control your network, and you can give each instance its own port. Keep SockJS if you're pub/sub-bound, you ship to restrictive corporate networks, or you'd rather not add the proxy hop yet.

Either way, the bigger win is structural: Meteor's transport is finally a boundary you can swap and measure. uws is the first alternative through that door, and a genuinely good one for the right workload.

Try it yourself

Everything in this post is reproducible from the companion files:

  • Demo app: ./app, a minimal Meteor app wired for the echo loop above.
  • Micro-benchmark harness: ./bench/run-bench.sh, drives bench/bench.js to produce the results/*.json numbers (raw DDP over native WebSocket: connect burst, sequential RTT echo, sustained throughput).
  • Real-app harness: the performance framework. The exact runs behind the real-app tables, and how to push your own to the dashboard:
# sockjs baseline
node bench.js run --scenario ddp-non-reactive-light --app tasks-3.x \
  --tag uws-blogpost-sockjs --env DDP_TRANSPORT=sockjs

# uws (separate internal port so multiple instances don't collide)
node bench.js run --scenario ddp-non-reactive-light --app tasks-3.x \
  --tag uws-blogpost-uws --env DDP_TRANSPORT=uws \
  --env METEOR_SETTINGS='{"packages":{"ddp-server":{"transport":"uws","uws":{"port":5005}}}}'

# then publish to the dashboard
node bench.js push --result results/<file>.json
Enter fullscreen mode Exit fullscreen mode

Saved result JSON for this post lives in ./results/harness.

Run either harness on your own machine and compare against the tables above. Remember: the loopback micro-benchmark numbers are relative, not absolute, the real-app harness is the one that translates to production.

References

  • PR #14231, Feature/pluggable transport (dupontbertrand)
  • PR #14425, Fix/uws transport settings and port collision (@italojs)
  • PR #14206, DISABLE_SOCKJS=1 end-to-end (dupontbertrand)
  • PR #14190, Earlier opt-in uWebSockets attempt (alextaaa, closed/superseded)
  • PR #10120, Earlier ClusterWS/uws community attempt (mrauhu, closed)
  • WeKan PR #6365, a real-world multi-tenant uws deployment on one host (@italojs)
  • uWebSockets.js, the Node.js binding the uws transport is built on (uNetworking)
  • uWebSockets, the C/C++ WebSocket and HTTP server core underneath it (uNetworking)
  • Environment variables documentation
  • Meteor performance benchmark framework, the real-app harness + dashboard used for the tables above

Top comments (0)