Open Source
mqemitter-valkey

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.

Packagemqemitter-valkey (opens in a new tab) v0.1.0
LicenseMIT
Valkey clientiovalkey (opens in a new tab)
Wire encodingMessagePack (msgpack-lite)
De-duplicationhyperid packet ids + lru-cache
Node.js≥ 20
TypeScript declarationsIncluded
Sourcegithub.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:

  1. 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.
  2. Any multi-process topic system has the same hole. Worker pools, cluster mode, 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.

basic-usage.js
const valkey = require('mqemitter-valkey')

const mq = valkey({
  port: 6379,
  host: '127.0.0.1',
  password: 'my secret',
  db: 4
})

mq.on('hello/world', function (message, cb) {
  console.log(message)
  cb() // call the callback when you are done; never pass an error
})

// topic is mandatory
mq.emit({ topic: 'hello/world', payload: 'or any other fields' }, function () {
  // the emitter never returns an error
})

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:

WildcardMatchesExample subscriptionMatches topic
+exactly one levelhello/+hello/world
#zero or more levelshello/#hello/a/b
wildcards.js
// one level: fires for sensors/kitchen but not sensors/kitchen/temp
mq.on('sensors/+', function (message, cb) {
  console.log('one level:', message.topic)
  cb()
})

// many levels: fires for sensors/kitchen/temp, sensors/a/b/c, ...
mq.on('sensors/#', function (message, cb) {
  console.log('any depth:', message.topic)
  cb()
})

mq.emit({ topic: 'sensors/kitchen/temp', payload: 21.5 })

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:

OptionDescriptionDefault
connectionStringA single connection URI instead of discrete options.—
pubConnReuse an existing Valkey client for publishing.new client
subConnReuse an existing Valkey client for subscribing.new client
concurrencyMaximum number of messages processed concurrently (from MQEmitter).0
maxLRUCacheMaximum number of packet ids kept for de-duplication.10000
ttlLRUCacheTime-to-live of the de-duplication cache, in milliseconds.60000
reuse-connections.js
const Valkey = require('iovalkey')
const mqemitterValkey = require('mqemitter-valkey')

// bring your own clients — e.g. shared with the rest of the app
const pub = new Valkey('valkey://:[email protected]:6379/4')
const sub = new Valkey('valkey://:[email protected]:6379/4')

const mq = mqemitterValkey({ pubConn: pub, subConn: sub })

// or the one-liner form
const mq2 = mqemitterValkey({
  connectionString: 'valkey://:[email protected]:6379/4'
})

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:

StepWhereWhat happens
1. EncodePublisher processPacket gets a unique hyperid and is MessagePack-encoded
2. PublishPublisher → ValkeyPUBLISH to a channel named after the topic
3. Fan-outValkeyEvery subscribing connection with a matching SUBSCRIBE/PSUBSCRIBE receives it
4. DecodeSubscriber processPacket is MessagePack-decoded
5. De-duplicateSubscriber processPacket id checked against a short-lived LRU cache; duplicates dropped
6. DispatchSubscriber processRe-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.