ZeroQ moves pub/sub and work-queue messaging directly between browser peers, so small-scale event fan-out does not require operating a broker.
WARNING β npm name collision. The package name
zeroqon the npm registry is owned by an unrelated author (hisco). Runningnpm install zeroqinstalls their package, not this one. This is a silent failure β your project will compile against the wrong library. A rename of this project is pending the author's decision. Do not usenpm install zeroq.
- What is ZeroQ?
- Installation
- Usage Examples
- API Reference
- Message Delivery Guarantees
- Architecture Overview
- Go Discovery Server
- Known Limitations
- Comparison with Competitors
- FAQ
- Author & License
ZeroQ is a serverless, peer-to-peer message broker. By leveraging WebRTC DataChannels for mesh routing and IndexedDB for persistent storage, ZeroQ creates a decentralised queue directly in the browser or Node.js. A lightweight Go discovery server handles WebRTC signaling β actual message payloads flow directly between peers.
- Zero Infrastructure: No dedicated broker servers required for message routing.
- P2P Mesh Network: Single-hop broadcast over WebRTC DataChannels. Every message goes to every directly-connected peer; there is no multi-hop routing and no per-topic mesh partitioning.
- Local Persistence: IndexedDB-backed message log (browser only). See Message Delivery Guarantees for what this does and does not buy you.
- Patterns: Pub/Sub, Work Queues, Request/Reply, Dead-Letter inspection.
- Gossip Discovery: Peer ids propagate through the mesh via gossip (topic metadata does not).
- Auto-Reconnect: Exponential backoff with jitter on both WebSocket and peer connections, capped at 10 attempts each.
This library is not published to npm. Use one of these two paths:
<script type="module">
import { ZeroQ } from 'https://cdn.jsdelivr.net/gh/itsoumya-d/zeroq@main/dist/index.mjs';
</script>git clone https://github.com/itsoumya-d/zeroq.git
cd zeroq
npm install
npm run build
# dist/ is now available locallyimport { ZeroQ } from '...'; // from dist/index.mjs
const zeroq = new ZeroQ({ discoveryUrl: 'wss://discovery.yourdomain.com/ws' });
// discoveryUrl defaults to 'ws://localhost:8080' if omitted.
// NOTE: the bundled Go server serves the WebSocket upgrade on /ws only, so the
// default value does not match it β pass an explicit URL ending in /ws.
// topicId (default 'default-topic') is the signaling room; peers must share it.A peer never receives its own publishes.
publish()broadcasts to connected peers only; it does not dispatch to handlers registered on the same instance. Publisher and subscriber must therefore be two different peers (two tabs, two processes, two devices). Every example below shows the two sides separately for that reason.
// ---- Peer A (subscriber) ----
const a = new ZeroQ({ discoveryUrl: 'wss://discovery.yourdomain.com/ws' });
await a.subscribe('news.updates', (msg) => {
console.log('Received:', msg.payload);
});
// ---- Peer B (publisher), a separate browser tab / process ----
const b = new ZeroQ({ discoveryUrl: 'wss://discovery.yourdomain.com/ws' });
await b.createTopic('news.updates');
await b.publish('news.updates', { headline: 'ZeroQ hits pre-release!' });// Worker
await zeroq.consume('image-processing', (msg, ack, nack) => {
try {
processImage(msg.payload.url);
ack();
} catch (err) {
nack(); // Requeue; moved to DLQ after 3 attempts
}
});
// Producer
await zeroq.createQueue('image-processing');
await zeroq.enqueue('image-processing', { url: 'https://example.com/img1.jpg' });// Server
await zeroq.reply('rpc.getUser', async (msg) => {
return { user: await db.getUser(msg.payload.id) };
});
// Client
const response = await zeroq.request('rpc.getUser', { id: 123 }, 5000);priority is carried on the message envelope and is readable by the consumer as msg.priority.
It does not reorder delivery β there is no priority scheduler; messages are delivered in the
order they arrive on the DataChannel. delay is accepted for API compatibility and is ignored.
await producer.enqueue('alerts', { severity: 'CRITICAL' }, { priority: 1 });
await producer.enqueue('alerts', { severity: 'INFO' }, { priority: 10 });
await worker.consume('alerts', (msg, ack) => {
console.log(msg.priority); // 1, then 10 β arrival order, not priority order
ack();
});Dual-licensed β choose either:
-
AGPL-3.0-or-later β free for any purpose, including commercial and production use. No payment, no permission, no key required. The obligation it carries: if you modify this software and let users interact with it over a network, you must offer those users your modified source under the same licence.
-
Commercial licence β for organisations that cannot or prefer not to meet the AGPL's source-disclosure obligation. This buys an exception, not access.
Contributions are accepted under AGPL-3.0-or-later. Full terms: LICENSING.md.
ZeroQ provides best-effort, at-most-once delivery. It is not an at-least-once queue and it is not an exactly-once queue. Read this section before designing around it.
What actually happens:
publish/enqueuewrites the message to the local IndexedDB store (browser only; a no-op in Node without a polyfill), then broadcasts it to every currently-connected peer.- Each receiving peer suppresses duplicates (by message id + delivery attempt), persists the message, then dispatches it to that peer's handlers.
subscribehandlers all receive the message.consumehandlers on a given peer are served round-robin, so exactly one consumer per peer is invoked.ack()deletes the message from the local store.nack()incrementsretryCountand re-broadcasts, up to 3 attempts.
Known gaps you must design around:
- No delivery confirmation.
publish()resolves as soon as the frame has been handed to the DataChannel. It resolves successfully even when there are zero peers, when the channel is congested, or when the payload is too large to send β in all three cases the message is dropped. Subscribe to themessage_droppedevent on the mesh if you need to observe this. - Work queues fan out across peers, they do not load-balance across them. A message is delivered to one consumer on each peer, so N peers each running one worker means the job is processed N times. Competing-consumer semantics only hold within a single peer.
- No visibility timeout / no redelivery on consumer death. If a consumer receives a message
and never calls
ack()ornack()(e.g. the tab closes), nothing redelivers it. nack()retries reach other peers, not the local consumer pool. A peer does not receive its own broadcasts, so a retry can only be picked up by a different peer that also consumes that queue.- Offline peers miss everything. There is no log replay, no consumer offsets and no retention policy. A peer that is not connected when a message is broadcast never receives it, and the IndexedDB store grows without bound until the browser's storage quota is hit.
- Dead letters are stored but there is no public API to read them.
PersistenceLayer.getDeadLetterQueue()filters the store forretryCount >= 3, butPersistenceLayeris internal andZeroQexposes no accessor. Dead letters are currently only reachable by opening thezeroq-dbIndexedDB database directly.
Pub/Sub messages have no persistence guarantee β if no subscriber is connected when a message is broadcast, it is lost.
Measured on a single machine with an in-process loopback DataChannel (no network, no DTLS/SCTP), which is a strict upper bound:
| Configuration | publish() rate | end-to-end delivered |
|---|---|---|
| With IndexedDB persistence | ~480 /s | ~480 /s |
| Persistence disabled (no IndexedDB) | ~100,000 /s | ~61,000 /s |
publish() awaits one IndexedDB transaction per message with no batching, so enabling the
documented durability path costs roughly two orders of magnitude of throughput. Reproduce with a
loop of publish() calls against a subscriber on a second instance.
graph TD
subgraph Browser / Node Instances
P1[Peer 1 - Publisher]
P2[Peer 2 - Consumer]
P3[Peer 3 - Worker]
end
subgraph Infrastructure
DS[Go Discovery Server]
end
P1 -.->|WebSocket Signaling| DS
P2 -.->|WebSocket Signaling| DS
P3 -.->|WebSocket Signaling| DS
P1 ===|WebRTC DataChannel Mesh| P2
P1 ===|WebRTC DataChannel Mesh| P3
P2 ===|WebRTC DataChannel Mesh| P3
Message deduplication tracks up to 10,000 message IDs in a sliding LRU window. Peer health is monitored with ping/pong every 10 seconds; peers unseen for 30 seconds are dropped.
cd discovery
go mod tidy
go build -o zeroq-discovery
./zeroq-discoveryEndpoints:
GET /wsβ WebSocket signaling upgradeGET /api/topicsβ List active topicsGET /api/queuesβ List active queues
β οΈ The bundled server does not relay signaling yet.discovery/handler.goreads each client frame and recordstopic/queuenames for the two/api/*endpoints, but nothing is ever written to a client'ssendchannel: there is nopeer_joinednotification and no forwarding ofoffer,answerorice_candidatebetween clients. BecausePeerMesh.connectToPeer()is only reached from apeer_joinedmessage, no WebRTC peer connection is ever established against this server, on any network. You must supply a signaling server that implements the client protocol below.Client β server:
{type:'join', topicId},{type:'offer'|'answer', peerId, sdp},{type:'ice_candidate', peerId, candidate}, plus the bookkeeping messages{type:'create_topic'|'subscribe', topic}and{type:'create_queue'|'consume', queue}.Server β client (all currently missing):
{type:'peer_joined', peerId}for each other member of the sametopicId, andoffer/answer/ice_candidateforwarded to the addressedpeerIdwithpeerIdrewritten to the sender's id.Note also that
CheckOriginreturnstrueunconditionally and there is no authentication, so any origin can join any room, and thetopics/queuesmaps grow without bound from untrusted input.
- Pre-release status. Not on npm. No production adopters. API may change.
- No npm publication. Running
npm install zeroqinstalls an unrelated library. See Installation above. - The bundled Go discovery server does not relay signaling, so no peer connection is ever established against it. See Go Discovery Server for the protocol you must implement. This is the single largest blocker to using ZeroQ today.
- No TURN relay β connections fail behind symmetric or carrier-grade NAT. The ICE configuration is
hardcoded to a single public STUN server (
stun:stun.l.google.com:19302). STUN cannot traverse symmetric NAT or many mobile carrier-grade NAT deployments; those peers cannot connect at all. There is currently no constructor option to supply your owniceServers/TURN credentials β you must editsrc/peer-mesh.tsand rebuild.iceTransportPolicyandiceCandidatePoolSizeare not set either. - ICE failures are reported, but coarsely. On
'failed'/'disconnected'the mesh emitspeer_connection_failed(peerId, iceConnectionState)followed bypeer_disconnected(peerId), andpeer_unreachable(peerId, reason)once the 10-attempt reconnect budget is spent. There is no aggregate "the whole mesh is unreachable" signal. - Dead-letter queue requires IndexedDB (browser only) and has no public accessor. In Node.js,
PersistenceLayer.init()is a no-op and messages are not persisted. zeroq/persistencedoes not exist as an importable subpath.PersistenceLayeris an internal class.- No authentication and no encryption above DTLS. Any peer knowing the discovery URL and topic ID
can join, read every message on every topic (the signaling room is one flat namespace), and publish
as anyone. A peer can also permanently suppress a message by claiming its id first, since duplicate
suppression trusts the sender-supplied
id. - Topic and queue names share one namespace. A single
enqueue()fires bothsubscribehandlers andconsumehandlers registered under the same name. - No message chunking. WebRTC DataChannels cap a single message at
sctp.maxMessageSize(spec minimum 64 KiB; 256 KiB in Chrome). Larger payloads fail insidebroadcast()and are dropped with amessage_droppedevent;publish()still resolves successfully. Message.seqis always 0 and is never read. There is no sequencing or reordering logic; ordering relies entirely on the ordered delivery of a single DataChannel.- WebRTC polyfills needed for Node.js backend use (
node-datachannelorwrtc, plusfake-indexeddb). Note that Node β₯ 22 provides a globalWebSocket, so a ZeroQ instance opens real signaling sockets in Node even without polyfills.
| Feature | ZeroQ | Kafka | AWS SQS | Redis Pub/Sub | NATS |
|---|---|---|---|---|---|
| Infra cost | $0 for routing (a signaling server is still required) | High | Pay-per-req | Medium | Low |
| Licence cost | $0 (AGPL-3.0-or-later); commercial exception from $299/yr | Apache-2.0 | usage-based | BSD | Apache-2.0 |
| Topology | P2P broadcast, single hop | Centralized | Cloud | Centralized | Centralized |
| Delivery | at-most-once, best-effort | at-least-once / exactly-once | at-least-once | at-most-once | at-most-once / at-least-once (JetStream) |
| Persistence | IndexedDB, browser only, no retention | Disk log | AWS-managed | In-memory | Disk/Mem |
| Ordered | per DataChannel only | per partition | per group (FIFO queues) | per connection | per subject |
| Replay / offsets | No | Yes | No | No | Yes (JetStream) |
| DLQ Support | Stored, but no public accessor | Manual | Yes | No | Yes |
| RPC Patterns | Native | Complex | Complex | Manual | Native |
ZeroQ is not a drop-in replacement for any of these. It targets browser-to-browser fan-out where running a broker is not worth it and message loss is acceptable.
Q: Does ZeroQ support Node.js?
A: Yes, with polyfills: node-datachannel (or wrtc) for WebRTC and fake-indexeddb for persistence.
Q: Are messages stored on the discovery server? A: No. The server only routes WebRTC handshakes (SDP/ICE candidates).
Author: Soumya Debnath
Email: soumyadebnath1619@gmail.com
GitHub: github.com/itsoumya-d
This software is free under AGPL-3.0-or-later β including for commercial and production use. The prices below buy one specific thing: an exception to the AGPL's requirement that you publish your modifications if you run a modified version as a network service.
| Tier | Price | For |
|---|---|---|
| Indie | $299/year | Solo developer, <$100K revenue |
| Startup | $1,999/year | Up to 10-25 devs, <$5M revenue |
| Enterprise | $9,999/year | Unlimited seats, unlimited revenue |
| OEM / White-Label | $19,999/year | Embed in your product |
| Full IP Buyout | $750,000 | Complete ownership transfer |
Free under AGPL-3.0-or-later: any use, including production and commercial, provided you meet the AGPL's terms.
Β© 2024-2026 Soumya Debnath. All Rights Reserved.