Published: October 26, 2026 · by Srinu Desetti (creator & maintainer)
mqemitter-valkey: Shared Pub/Sub Topics for Node.js on Valkey
mqemitter-valkey is a multi-process message emitter backed by Valkey (opens in a new tab) Pub/Sub. MQEmitter (opens in a new tab) is the in-memory publish/subscribe emitter with MQTT-style topic matching (+ for one level, # for many) that sits inside MQTT brokers like Aedes — but on its own it only reaches subscribers in the same Node.js process. This package relays every emit through Valkey Pub/Sub, so a message published by one process is delivered to matching subscribers in every process connected to the same Valkey. I created and maintain it as the Valkey-native transport layer for MQEmitter.
| Package | mqemitter-valkey (opens in a new tab) v0.1.0 |
| License | MIT |
| Valkey client | iovalkey (opens in a new tab) |
| Wire encoding | MessagePack (msgpack-lite) |
| De-duplication | hyperid packet ids + lru-cache |
| Node.js | ≥ 20 |
| TypeScript declarations | Included |
| Source | github.com/webdevelopersrinu/mqemitter-valkey (opens in a new tab) |
The Problem: An Emitter That Stops at the Process Boundary
MQEmitter is a beautiful primitive: topic-based pub/sub with MQTT wildcard matching, backpressure-aware callbacks, pluggable concurrency. It is the message bus inside Aedes and other topic-routed systems. But it is an in-memory emitter — mq.emit() reaches only the listeners registered in the same process.
The moment you scale out, that becomes the whole problem:
- A clustered MQTT broker breaks. Run two Aedes instances behind a load balancer and a client connected to instance A publishes
sensors/temp— subscribers connected to instance B never hear it. Each broker has its own private emitter. - Any multi-process topic system has the same hole. Worker pools,
clustermode, Kubernetes replicas: the publisher and the subscriber land in different processes, and in-memory routing silently delivers to nobody.
The fix is to keep MQEmitter's API and matcher exactly as they are, and swap the transport underneath: every emit goes out through a shared broker, and every process's matcher receives it. mqemitter-valkey uses Valkey Pub/Sub as that shared broker — same on/emit/removeListener/close API, now cluster-wide.
The same mq running in another process — pointed at the same Valkey — receives the message. That single sentence is the entire value proposition: nothing about your listener code changes; the delivery scope does.
Topics and Wildcards
Topics are /-separated by default, and the two MQTT wildcards work when subscribing:
| Wildcard | Matches | Example subscription | Matches topic |
|---|---|---|---|
+ | exactly one level | hello/+ | hello/world |
# | zero or more levels | hello/# | hello/a/b |
Under the hood the mapping to Valkey is direct: exact topics use plain SUBSCRIBE, and wildcard subscriptions are translated to Valkey pattern subscriptions (PSUBSCRIBE). Valkey does the coarse channel-level fan-out; MQEmitter's matcher does the precise MQTT-semantics matching on arrival.
Connection Options
The constructor accepts all iovalkey (opens in a new tab) connection options (host, port, password, db, tls, …), plus a handful of its own:
| Option | Description | Default |
|---|---|---|
connectionString | A single connection URI instead of discrete options. | — |
pubConn | Reuse an existing Valkey client for publishing. | new client |
subConn | Reuse an existing Valkey client for subscribing. | new client |
concurrency | Maximum number of messages processed concurrently (from MQEmitter). | 0 |
maxLRUCache | Maximum number of packet ids kept for de-duplication. | 10000 |
ttlLRUCache | Time-to-live of the de-duplication cache, in milliseconds. | 60000 |
Two connections are not an implementation accident — they are a Valkey protocol requirement. A connection in subscriber mode can only issue subscription commands, so publishing needs its own connection. The emitter manages both for you by default; pubConn/subConn exist so you can pool them yourself.
How a Message Travels
Each instance holds two Valkey connections. The full path of one emit:
| Step | Where | What happens |
|---|---|---|
| 1. Encode | Publisher process | Packet gets a unique hyperid and is MessagePack-encoded |
| 2. Publish | Publisher → Valkey | PUBLISH to a channel named after the topic |
| 3. Fan-out | Valkey | Every subscribing connection with a matching SUBSCRIBE/PSUBSCRIBE receives it |
| 4. Decode | Subscriber process | Packet is MessagePack-decoded |
| 5. De-duplicate | Subscriber process | Packet id checked against a short-lived LRU cache; duplicates dropped |
| 6. Dispatch | Subscriber process | Re-emitted through MQEmitter's matcher, which invokes the right listeners |
Step 5 matters more than it looks. A single incoming message can match both a plain subscription and a pattern subscription on the same connection (hello/world matches the channel hello/world and the pattern hello/* derived from hello/+) — without the id cache, a subscriber would see the same message twice. The cache is bounded (maxLRUCache, default 10,000 ids) and short-lived (ttlLRUCache, default 60 seconds), so it costs near-nothing at steady state.
Internal Sentinel chatter (__sentinel__:hello) is recognized and ignored, so running against a Sentinel-managed deployment does not leak heartbeat noise into your listeners.
Design Notes — Decisions I Kept and Why
MessagePack on the wire, not JSON
Packets carry arbitrary fields — including binary payloads, which an MQTT broker pushes through constantly. MessagePack round-trips Buffers natively and encodes smaller and faster than JSON for the small, frequent packets a pub/sub bus moves. JSON would force base64-ing every binary payload.
De-duplication at the edge, not the broker
Valkey's Pub/Sub has no built-in "deliver once per connection across SUBSCRIBE and PSUBSCRIBE" semantics, and adding broker-side bookkeeping would mean extra round-trips on the hot path. A per-process LRU of recently seen packet ids solves it locally, with explicit knobs (maxLRUCache, ttlLRUCache) when your throughput profile differs from the defaults.
iovalkey, not a Redis client
The emitter is Valkey-native end to end: the connection layer is iovalkey (opens in a new tab), the Valkey project's own Node.js client. That keeps the dependency graph inside the Valkey ecosystem — no Redis-branded transitive packages under a Valkey deployment — and tracks Valkey server features as they land.
Provenance
mqemitter-valkey is a Valkey-native port of mqemitter-redis (by Matteo Collina, MIT) — the same architecture MQTT brokers have clustered on for years, retargeted to Valkey. Behavior parity is deliberate: if you have deployed Aedes with mqemitter-redis, swapping the emitter is mechanical, while publishing and maintenance move inside the Valkey ecosystem.
This is a solo project — I designed, built, and maintain mqemitter-valkey independently as the Valkey-native pub/sub transport for MQEmitter-based systems.
What's Next?
mqemitter-valkey sits on top of the Valkey Node.js client stack — the same stack whose failure semantics and wire protocol I maintain in valkey-errors and valkey-parser. Together they cover the path from raw TCP bytes to cluster-wide topic delivery.
← Back to Open Source overview
Frequently Asked Questions
Why not just use mqemitter-redis against Valkey?
Mostly it would work — Valkey speaks the same protocol. But you would be running a Redis-branded client (ioredis) and package under a Valkey deployment, with naming, publishing cadence, and maintenance outside the Valkey ecosystem. mqemitter-valkey keeps the architecture identical while building on iovalkey, the Valkey project's own Node.js client, so the whole dependency graph stays Valkey-native and swapping from mqemitter-redis remains mechanical.
Why does each emitter hold two Valkey connections?
It is a protocol requirement, not a design preference: a Valkey connection in subscriber mode can only issue subscription commands, so publishing must happen on a separate connection. The emitter creates and manages both by default, and the pubConn/subConn options let you supply existing iovalkey clients if you pool connections yourself.
Why does it need a de-duplication cache at all?
Because one message can reach the same subscriber connection twice: an exact SUBSCRIBE and a wildcard PSUBSCRIBE can both match the same channel. Every packet carries a unique hyperid, and each process keeps a short-lived LRU cache (10,000 ids for 60 seconds by default, tunable via maxLRUCache and ttlLRUCache) of ids it has already dispatched. Duplicates are dropped at the edge before your listeners ever see them.
Is delivery guaranteed if a subscriber is down?
No — this is Valkey Pub/Sub, which is fire-and-forget: a message published while a process is disconnected is gone for that process. That is the right trade-off for the MQEmitter use case (live topic routing inside brokers, where QoS and retained-message persistence are handled a layer above, by the broker itself). If you need durable, replayable delivery, you want a stream, not a pub/sub emitter.