FireMQ
FireMQ is a message broker written in Rust. It speaks RabbitMQ's protocol, AMQP 0-9-1, so the RabbitMQ client you already use connects to FireMQ unchanged. It also speaks its own small protocol for FireMQ's SDKs and for browsers.
FireMQ keeps RabbitMQ's model: publishers send messages to exchanges, exchanges route them to queues through bindings, and consumers acknowledge what they process. It drops what makes RabbitMQ heavy to run: there is no Erlang runtime, no plugins to enable, and no configuration to write before the first message.
firemq
That one command starts a broker with:
- AMQP 0-9-1 on port 5672, for RabbitMQ clients
- the FireMQ protocol on port 5673, for FireMQ SDKs
- the management UI and a RabbitMQ-compatible HTTP API on port 15672
- Prometheus metrics at
/metricsand WebSocket at/fireon the same port - durable storage in
./firemq-data
What you get
- RabbitMQ compatibility. Exchanges (direct, fanout, topic, headers), publisher confirms, mandatory returns, prefetch, TTL, dead-lettering, maximum length, priorities, single active consumer, transactions, policies, vhosts, users and permissions behave as they do in RabbitMQ. CI runs pika, amqplib, amqp091-go, the Java client, the .NET client and
rabbitmqadminagainst every change. - Durability you can test. Persistent messages are confirmed only after they are on disk. A test kills the broker with
kill -9while it confirms publishes, restarts it, and checks that no confirmed message is missing. - A small footprint. One static binary with no runtime dependencies. An idle broker uses about 8 MB of memory on Linux.
- Tools for developers.
firemq pub,firemq sub,firemq ctl,firemq benchandfiremq doctorwork from the terminal, and the broker can run inside Rust tests as a library.
What FireMQ is not (yet)
FireMQ runs on a single node. It does not cluster or replicate, and it does not implement AMQP 1.0, MQTT, STOMP or streams. What FireMQ does not support lists every gap.
Install
FireMQ is a single binary. Choose one of the following methods.
Install script (Linux and macOS)
The script downloads the latest release for your platform, checks its SHA-256 checksum, and installs firemq to /usr/local/bin when that directory is writable, or to ~/.local/bin otherwise.
curl -fsSL https://raw.githubusercontent.com/FireMQ/firemq/main/install.sh | sh
To pin a version or choose the directory, set FIREMQ_VERSION and FIREMQ_INSTALL_DIR:
curl -fsSL https://raw.githubusercontent.com/FireMQ/firemq/main/install.sh | FIREMQ_VERSION=v0.1.0 FIREMQ_INSTALL_DIR=$HOME/bin sh
Docker
docker run -d --name firemq -p 5672:5672 -p 5673:5673 -p 15672:15672 -v firemq-data:/data ghcr.io/firemq/firemq
The image is built FROM scratch and contains only the static binary. Data is stored in the /data volume. Inside the container, the guest user may connect from any host, because connections arrive through the container network rather than from localhost. Set FIREMQ_DEFAULT_USER and FIREMQ_DEFAULT_PASS to create a different first user.
A Compose file is in deploy/docker-compose.yml.
Homebrew (macOS and Linux)
brew install firemq/tap/firemq
To start it at login, with data in Homebrew's var/firemq directory, run brew services start firemq.
GitHub Actions
FireMQ/setup-firemq starts a broker for the rest of a job, on Linux, macOS and Windows runners:
steps:
- uses: FireMQ/setup-firemq@v1
- run: npm test # connect to amqp://guest:guest@localhost:5672
Release archives
Download an archive for Linux (static, x86_64 or arm64), macOS (Apple silicon or Intel) or Windows from the releases page, unpack it, and put firemq on your PATH.
With Cargo
You need Rust 1.89 or later.
cargo install firemq
To build the latest code on main instead, run cargo install --git https://github.com/FireMQ/firemq firemq.
Run as a service on Linux
deploy/firemq.service is a hardened systemd unit that stores data in /var/lib/firemq. Its comments list the commands to install it.
Check the installation
firemq --version
firemq doctor
firemq doctor reports the open-files limit, whether the default ports are free, and whether a broker is running.
Quickstart
This page takes about five minutes. You start a broker, send and receive messages from the terminal and from code, and look at the management UI.
Start the broker
firemq
FireMQ prints its connection strings:
FireMQ 0.1.0 is ready in 40 ms
AMQP 0-9-1 amqp://guest:guest@localhost:5672
FireMQ fire://guest:guest@localhost:5673
WebSocket ws://localhost:15672/fire
Management http://localhost:15672
Metrics http://localhost:15672/metrics
Data firemq-data (0 queues, 0 messages recovered)
Leave it running and open a second terminal.
If port 5672 is in use, RabbitMQ is probably running. Stop it, or start FireMQ on other ports, for example
firemq --amqp 6672.
Send and receive from the terminal
Declare a queue, publish to it, and read it back:
firemq ctl declare-queue jobs
firemq pub --queue jobs "hello from the terminal"
firemq sub jobs --count 1
firemq pub waits for the broker's confirm, so when it returns the message is on disk. firemq sub prints each message and acknowledges it.
Send and receive from code
Any RabbitMQ client works. With Python and pika:
pip install pika
import pika
connection = pika.BlockingConnection(pika.URLParameters("amqp://guest:guest@localhost:5672"))
channel = connection.channel()
channel.queue_declare(queue="hello")
channel.basic_publish(exchange="", routing_key="hello", body=b"Hello World!")
method, properties, body = channel.basic_get("hello", auto_ack=True)
print(body)
connection.close()
The tutorials cover every messaging pattern in Python, JavaScript and Rust.
Look at the management UI
Open http://localhost:15672 and log in as guest with password guest. The UI shows queues, exchanges, connections and rates, and lets you publish, peek at messages, and manage users and policies.
Next steps
- Read Configuration to change ports, storage and limits.
- Read Migrating from RabbitMQ to move an existing setup.
- Run
firemq bench --embeddedto measure throughput on your machine.
Tutorials
These tutorials follow RabbitMQ's classic tutorials. Each pattern has a complete program that sends messages and receives them, so you can run it as is. CI runs every program on this page against FireMQ, so the code always works.
Start a broker first:
firemq
The Python programs use pika and the JavaScript programs use amqplib; both talk to FireMQ over AMQP 0-9-1 exactly as they would to RabbitMQ. The Rust programs use FireMQ's own SDK, firemq-client, over the FireMQ protocol. Set AMQP_URL or FIREMQ_URL if your broker is not on localhost.
Hello world
A producer sends a message to a queue and a consumer receives it. The empty exchange name "" is the default exchange, which delivers to the queue named by the routing key.
Python:
"""Hello world: send one message to a queue and receive it."""
import os
import pika
connection = pika.BlockingConnection(pika.URLParameters(os.environ.get("AMQP_URL", "amqp://localhost")))
channel = connection.channel()
channel.queue_declare(queue="hello")
channel.basic_publish(exchange="", routing_key="hello", body=b"Hello World!")
print(" [x] Sent 'Hello World!'")
for method, properties, body in channel.consume("hello", auto_ack=True, inactivity_timeout=5):
print(f" [x] Received {body.decode()!r}")
break
connection.close()
JavaScript:
// Hello world: send one message to a queue and receive it.
import amqp from "amqplib";
const conn = await amqp.connect(process.env.AMQP_URL ?? "amqp://localhost");
const ch = await conn.createChannel();
await ch.assertQueue("hello", { durable: false });
ch.sendToQueue("hello", Buffer.from("Hello World!"));
console.log(" [x] Sent 'Hello World!'");
await new Promise((resolve) =>
ch.consume("hello", (msg) => {
console.log(` [x] Received '${msg.content}'`);
resolve();
}, { noAck: true }),
);
await conn.close();
Rust:
//! Publish and consume one message. //! //! Start a broker with `firemq`, then run `cargo run -p firemq-client --example hello`. use firemq_client::{Client, QueueOptions, SubscribeOptions}; #[tokio::main] async fn main() -> Result<(), firemq_client::Error> { let url = std::env::var("FIREMQ_URL").unwrap_or_else(|_| "fire://localhost".into()); let client = Client::connect(&url).await?; client .declare_queue("hello.rust", QueueOptions::default()) .await?; client.publish("", "hello.rust", "Hello World!").await?; println!(" [x] Sent 'Hello World!'"); let mut sub = client .subscribe("hello.rust", SubscribeOptions::default()) .await?; if let Some(msg) = sub.next().await { println!(" [x] Received '{}'", msg.body_str().unwrap_or_default()); msg.ack()?; } client.close().await }
Work queues
Several workers share the tasks of one queue. Two settings make this safe and fair:
- Workers acknowledge a task only after finishing it, so a crashed worker's task goes to another worker.
- A prefetch of 1 gives each worker one unacknowledged task at a time, so a busy worker does not hoard tasks.
The queue is durable and the messages are persistent, so tasks survive a broker restart.
Python:
"""Work queues: share tasks between workers, one unacknowledged task each."""
import os
import pika
connection = pika.BlockingConnection(pika.URLParameters(os.environ.get("AMQP_URL", "amqp://localhost")))
channel = connection.channel()
channel.queue_declare(queue="task_queue", durable=True)
for n in range(1, 4):
channel.basic_publish(
exchange="",
routing_key="task_queue",
body=f"task {n}".encode(),
properties=pika.BasicProperties(delivery_mode=pika.DeliveryMode.Persistent),
)
channel.basic_qos(prefetch_count=1) # at most one unacknowledged task per worker
done = 0
for method, properties, body in channel.consume("task_queue", inactivity_timeout=5):
print(f" [x] Done {body.decode()}")
channel.basic_ack(method.delivery_tag)
done += 1
if done == 3:
break
connection.close()
JavaScript:
// Work queues: share tasks between workers, one unacknowledged task each.
import amqp from "amqplib";
const conn = await amqp.connect(process.env.AMQP_URL ?? "amqp://localhost");
const ch = await conn.createChannel();
await ch.assertQueue("task_queue", { durable: true });
for (let n = 1; n <= 3; n++) ch.sendToQueue("task_queue", Buffer.from(`task ${n}`), { persistent: true });
await ch.prefetch(1); // at most one unacknowledged task per worker
let done = 0;
await new Promise((resolve) =>
ch.consume("task_queue", (msg) => {
console.log(` [x] Done ${msg.content}`);
ch.ack(msg);
if (++done === 3) resolve();
}),
);
await conn.close();
Rust (the SDK makes queues durable and messages persistent by default):
//! Work queues: share tasks between workers, a few unacknowledged tasks each. use firemq_client::{Client, QueueOptions, SubscribeOptions}; #[tokio::main] async fn main() -> Result<(), firemq_client::Error> { let url = std::env::var("FIREMQ_URL").unwrap_or_else(|_| "fire://localhost".into()); let client = Client::connect(&url).await?; client .declare_queue("task_queue.rust", QueueOptions::default()) .await?; for n in 1..=3 { // Resolves once the broker has written the task to disk. client .publish("", "task_queue.rust", format!("task {n}")) .await?; } let opts = SubscribeOptions { prefetch: 1, ..Default::default() }; let mut tasks = client.subscribe("task_queue.rust", opts).await?; for _ in 0..3 { let Some(task) = tasks.next().await else { break; }; println!(" [x] Done {}", task.body_str().unwrap_or_default()); task.ack()?; } client.close().await }
Publish/subscribe
A fanout exchange copies every message to every queue bound to it. Each subscriber declares its own temporary queue: an empty name asks the broker for a unique one, and exclusive deletes it when the connection closes.
Python:
"""Publish/subscribe: a fanout exchange copies each message to every bound queue."""
import os
import pika
connection = pika.BlockingConnection(pika.URLParameters(os.environ.get("AMQP_URL", "amqp://localhost")))
channel = connection.channel()
channel.exchange_declare(exchange="logs", exchange_type="fanout")
# Each subscriber gets its own temporary queue.
queues = [channel.queue_declare(queue="", exclusive=True).method.queue for _ in range(2)]
for q in queues:
channel.queue_bind(exchange="logs", queue=q)
channel.basic_publish(exchange="logs", routing_key="", body=b"info: hello subscribers")
for q in queues:
for method, properties, body in channel.consume(q, auto_ack=True, inactivity_timeout=5):
print(f" [{q[-6:]}] {body.decode()}")
break
channel.cancel() # stop this consumer before reading the next queue
connection.close()
JavaScript:
// Publish/subscribe: a fanout exchange copies each message to every bound queue.
import amqp from "amqplib";
const conn = await amqp.connect(process.env.AMQP_URL ?? "amqp://localhost");
const ch = await conn.createChannel();
await ch.assertExchange("logs", "fanout", { durable: false });
const queues = [];
for (let i = 0; i < 2; i++) {
const { queue } = await ch.assertQueue("", { exclusive: true });
await ch.bindQueue(queue, "logs", "");
queues.push(queue);
}
ch.publish("logs", "", Buffer.from("info: hello subscribers"));
for (const q of queues) {
await new Promise((resolve) => ch.consume(q, (msg) => { console.log(` [${q.slice(-6)}] ${msg.content}`); resolve(); }, { noAck: true }));
}
await conn.close();
Rust:
//! Publish/subscribe: a fanout exchange copies every message to every bound queue. use firemq_client::{Client, ExchangeOptions, QueueOptions, SubscribeOptions}; #[tokio::main] async fn main() -> Result<(), firemq_client::Error> { let url = std::env::var("FIREMQ_URL").unwrap_or_else(|_| "fire://localhost".into()); let client = Client::connect(&url).await?; client .declare_exchange("logs.rust", "fanout", ExchangeOptions::default()) .await?; // Two subscribers, each with its own temporary queue. let mut subs = Vec::new(); for _ in 0..2 { let queue = client.declare_queue("", QueueOptions::temporary()).await?; client.bind_queue(&queue.name, "logs.rust", "").await?; subs.push( client .subscribe(&queue.name, SubscribeOptions::default()) .await?, ); } // The routing key is ignored by fanout exchanges. client.publish("logs.rust", "", "info: hello").await?; for (n, sub) in subs.iter_mut().enumerate() { let Some(msg) = sub.next().await else { break }; println!(" [subscriber {n}] {}", msg.body_str().unwrap_or_default()); msg.ack()?; } client.close().await }
Routing
A direct exchange delivers a message to the queues bound with exactly its routing key. Here only the error message reaches the queue.
Python:
"""Routing: a direct exchange delivers by exact routing key."""
import os
import pika
connection = pika.BlockingConnection(pika.URLParameters(os.environ.get("AMQP_URL", "amqp://localhost")))
channel = connection.channel()
channel.exchange_declare(exchange="direct_logs", exchange_type="direct")
errors = channel.queue_declare(queue="", exclusive=True).method.queue
channel.queue_bind(exchange="direct_logs", queue=errors, routing_key="error")
for severity in ("info", "warning", "error"):
channel.basic_publish(exchange="direct_logs", routing_key=severity, body=f"{severity} event".encode())
for method, properties, body in channel.consume(errors, auto_ack=True, inactivity_timeout=5):
print(f" [x] {method.routing_key}: {body.decode()}") # only the error event arrives
break
connection.close()
Rust, with a queue bound to two keys:
//! Routing: a direct exchange delivers a message to the queues bound with its exact key. use firemq_client::{Client, ExchangeOptions, QueueOptions, SubscribeOptions}; #[tokio::main] async fn main() -> Result<(), firemq_client::Error> { let url = std::env::var("FIREMQ_URL").unwrap_or_else(|_| "fire://localhost".into()); let client = Client::connect(&url).await?; client .declare_exchange("direct_logs.rust", "direct", ExchangeOptions::default()) .await?; let queue = client.declare_queue("", QueueOptions::temporary()).await?; for severity in ["warning", "error"] { client .bind_queue(&queue.name, "direct_logs.rust", severity) .await?; } for (severity, text) in [ ("info", "disk 40% full"), ("warning", "disk 85% full"), ("error", "disk full"), ] { client .publish("direct_logs.rust", severity, format!("{severity}: {text}")) .await?; } // The info message matched no binding, so only two arrive. let mut sub = client .subscribe(&queue.name, SubscribeOptions::default()) .await?; for _ in 0..2 { let Some(msg) = sub.next().await else { break }; println!(" [x] {}", msg.body_str().unwrap_or_default()); msg.ack()?; } client.close().await }
Topics
A topic exchange matches routing keys made of dot-separated words against binding patterns. * matches exactly one word and # matches zero or more words, so *.critical matches kern.critical but not kern.info.
Python:
"""Topics: route with word patterns; * matches one word, # matches zero or more."""
import os
import pika
connection = pika.BlockingConnection(pika.URLParameters(os.environ.get("AMQP_URL", "amqp://localhost")))
channel = connection.channel()
channel.exchange_declare(exchange="topic_logs", exchange_type="topic")
critical = channel.queue_declare(queue="", exclusive=True).method.queue
channel.queue_bind(exchange="topic_logs", queue=critical, routing_key="*.critical")
for key in ("kern.critical", "kern.info", "app.critical"):
channel.basic_publish(exchange="topic_logs", routing_key=key, body=key.encode())
received = 0
for method, properties, body in channel.consume(critical, auto_ack=True, inactivity_timeout=5):
print(f" [x] {body.decode()}")
received += 1
if received == 2:
break
connection.close()
JavaScript:
// Topics: route with word patterns; * matches one word, # matches zero or more.
import amqp from "amqplib";
const conn = await amqp.connect(process.env.AMQP_URL ?? "amqp://localhost");
const ch = await conn.createChannel();
await ch.assertExchange("topic_logs", "topic", { durable: false });
const { queue } = await ch.assertQueue("", { exclusive: true });
await ch.bindQueue(queue, "topic_logs", "*.critical");
for (const key of ["kern.critical", "kern.info", "app.critical"]) ch.publish("topic_logs", key, Buffer.from(key));
let received = 0;
await new Promise((resolve) =>
ch.consume(queue, (msg) => {
console.log(` [x] ${msg.content}`);
if (++received === 2) resolve();
}, { noAck: true }),
);
await conn.close();
Rust:
//! Topics: route with word patterns; `*` matches one word, `#` matches zero or more. use firemq_client::{Client, QueueOptions, SubscribeOptions}; #[tokio::main] async fn main() -> Result<(), firemq_client::Error> { let url = std::env::var("FIREMQ_URL").unwrap_or_else(|_| "fire://localhost".into()); let client = Client::connect(&url).await?; client .declare_exchange("topic_logs.rust", "topic", Default::default()) .await?; let queue = client.declare_queue("", QueueOptions::temporary()).await?; client .bind_queue(&queue.name, "topic_logs.rust", "*.critical") .await?; for key in ["kern.critical", "kern.info", "app.critical"] { client.publish("topic_logs.rust", key, key).await?; } let mut sub = client .subscribe(&queue.name, SubscribeOptions::default()) .await?; for _ in 0..2 { let Some(msg) = sub.next().await else { break }; println!(" [x] {}", msg.body_str().unwrap_or_default()); msg.ack()?; } client.close().await }
RPC
A client sends a request with two properties: reply_to names a queue for the answer, and correlation_id matches the answer to the request. The server publishes its answer to reply_to with the same correlation_id.
Python:
"""RPC: a request carries reply_to and correlation_id; the server answers there."""
import os
import threading
import uuid
import pika
URL = pika.URLParameters(os.environ.get("AMQP_URL", "amqp://localhost"))
def server():
connection = pika.BlockingConnection(URL)
channel = connection.channel()
for method, props, body in channel.consume("rpc_queue", inactivity_timeout=5):
n = int(body)
channel.basic_publish(
exchange="",
routing_key=props.reply_to,
properties=pika.BasicProperties(correlation_id=props.correlation_id),
body=str(n * n).encode(),
)
channel.basic_ack(method.delivery_tag)
break
connection.close()
connection = pika.BlockingConnection(URL)
channel = connection.channel()
channel.queue_declare(queue="rpc_queue")
threading.Thread(target=server).start()
callback_queue = channel.queue_declare(queue="", exclusive=True).method.queue
corr_id = str(uuid.uuid4())
channel.basic_publish(
exchange="",
routing_key="rpc_queue",
properties=pika.BasicProperties(reply_to=callback_queue, correlation_id=corr_id),
body=b"7",
)
for method, props, body in channel.consume(callback_queue, auto_ack=True, inactivity_timeout=5):
if props.correlation_id == corr_id:
print(f" [.] square(7) = {body.decode()}")
break
connection.close()
Rust:
//! RPC: a request carries `reply_to` and `correlation_id`; the server answers there. use firemq_client::{Client, PublishOptions, QueueOptions, SubscribeOptions}; #[tokio::main] async fn main() -> Result<(), firemq_client::Error> { let url = std::env::var("FIREMQ_URL").unwrap_or_else(|_| "fire://localhost".into()); let client = Client::connect(&url).await?; client .declare_queue("rpc_queue.rust", QueueOptions::default()) .await?; // The server squares numbers. let server = client.clone(); let mut requests = server .subscribe("rpc_queue.rust", SubscribeOptions::default()) .await?; tokio::spawn(async move { if let Some(req) = requests.next().await { let n: u64 = req.body_str().unwrap_or("0").parse().unwrap_or(0); let mut reply = PublishOptions::default(); reply.properties.correlation_id = req.properties.correlation_id.clone(); let to = req.properties.reply_to.clone().unwrap_or_default(); let _ = server .publish_with("", &to, (n * n).to_string(), reply) .await; let _ = req.ack(); } }); // The client sends a request and waits for the matching reply. let replies = client.declare_queue("", QueueOptions::temporary()).await?; let mut answers = client .subscribe(&replies.name, SubscribeOptions::default()) .await?; let mut request = PublishOptions::default(); request.properties.reply_to = Some(replies.name.clone()); request.properties.correlation_id = Some("req-1".into()); client .publish_with("", "rpc_queue.rust", "7", request) .await?; while let Some(answer) = answers.next().await { if answer.properties.correlation_id.as_deref() == Some("req-1") { println!(" [.] square(7) = {}", answer.body_str().unwrap_or_default()); answer.ack()?; break; } } client.close().await }
Run all the examples
./examples/run.sh
The script runs every program above against a broker on localhost. It needs uv, Node.js and Rust.
Clients
You can connect to FireMQ in two ways:
- With a RabbitMQ client over AMQP 0-9-1. Use this to move existing applications, or when your language already has a good AMQP client.
- With a FireMQ SDK over the FireMQ protocol. Use this for new code. The SDKs share one API across languages, confirm every publish by default, and also run in browsers over WebSocket.
Both kinds of client share the same broker: a message published over one protocol can be consumed over the other, with its properties and headers.
RabbitMQ clients
Every AMQP 0-9-1 client works. CI tests these against every change:
| Language | Library |
|---|---|
| Python | pika |
| JavaScript | amqplib |
| Go | amqp091-go (plus the library's own integration test suite) |
| Java | RabbitMQ Java client |
| .NET | RabbitMQ.Client |
Connect with the usual URL, for example amqp://guest:guest@localhost:5672/. See RabbitMQ client compatibility for the details.
FireMQ SDKs
| Language | Repository | Package |
|---|---|---|
| Rust | crates/firemq-client | firemq-client on crates.io |
| Go | FireMQ/firemq-go | github.com/FireMQ/firemq-go |
| TypeScript: Node.js, browsers | FireMQ/firemq-js | @firemq/client on npm |
| Java, Kotlin | FireMQ/firemq-java | io.github.firemq:firemq-client and firemq-kotlin on Maven Central |
| .NET | FireMQ/firemq-dotnet | FireMQ.Client on NuGet |
| Elixir | FireMQ/firemq-elixir | firemq on Hex |
| PHP | FireMQ/firemq-php | firemq/firemq on Packagist |
| Python | bindings/python | firemq on PyPI |
| Ruby | bindings/ruby | firemq on RubyGems |
| Swift: macOS, iOS | FireMQ/firemq-swift | Swift package https://github.com/FireMQ/firemq-swift |
| Dart | bindings/dart | firemq on pub.dev, with the C library from a release |
| C, C++ | bindings/c | firemq-ffi-<target> archives on the releases page |
| Kotlin on Android | bindings/kotlin | Build from source; on the JVM, use firemq-kotlin |
The Python, Ruby, Swift, Dart and C clients are bindings over the Rust SDK, so they ship a native library. Every SDK runs the conformance suite against a real broker in its CI.
Track progress in the roadmap issue.
Connection URLs
fire://user:password@host:5673/vhost FireMQ protocol over TCP
fires://user:password@host:5674/vhost over TLS
ws://host:15672/fire?vhost=dev over WebSocket (browsers)
wss://host/fire over secure WebSocket, through a TLS proxy
Missing parts default to user and password guest, vhost / and the standard ports, so fire://localhost is enough for local development.
Rust
[dependencies]
firemq-client = "0.1"
tokio = { version = "1", features = ["full"] }
#![allow(unused)] fn main() { use firemq_client::{Client, SubscribeOptions}; async fn run() -> Result<(), firemq_client::Error> { let client = Client::connect("fire://localhost").await?; client.declare_queue("jobs", Default::default()).await?; client.publish("", "jobs", "hello").await?; // returns when the broker confirms let mut jobs = client.subscribe("jobs", SubscribeOptions::default()).await?; while let Some(msg) = jobs.next().await { println!("{}", msg.body_str().unwrap_or_default()); msg.ack()?; } Ok(()) } }
The SDK's defaults favour safety: queues and exchanges are durable, messages are persistent, and publish waits for the confirm. Errors are typed: Error::Unroutable for a mandatory message nobody received, Error::Nacked when a queue rejected it, and Error::Server { code, .. } with an AMQP reply code for refused requests.
Reconnection
The Rust client reconnects on its own when the connection drops, for example while the broker restarts. After reconnecting, it does the following:
- Declares again the exchanges, queues and bindings it declared. A queue declared with an empty name gets a new name from the broker, and the client moves its bindings and subscriptions to the new name.
- Resumes every subscription. Your
while let Some(msg) = sub.next().awaitloop keeps running. - Publishes again any message that was waiting for a confirm. Delivery is at least once, so a consumer can see a message twice.
Messages delivered before the drop and not yet acknowledged go back to the queue, and the broker delivers them again with redelivered set. The subscription skips the old copies, and acknowledging one does nothing.
While the client is reconnecting, calls wait up to 30 seconds by default and then fail with Error::Closed. Attempts back off from 100 ms to 5 s and continue until you call close. To change this, set ConnectOptions::reconnect:
#![allow(unused)] fn main() { use std::time::Duration; use firemq_client::{Client, ConnectOptions, Reconnect}; async fn run() -> Result<(), firemq_client::Error> { let mut opts = ConnectOptions::from_url("fire://localhost")?; opts.reconnect = Some(Reconnect { max_attempts: Some(10), wait: Duration::from_secs(5), ..Default::default() }); let client = Client::connect_with(opts).await?; Ok(()) } }
Set reconnect to None to get an error as soon as the connection drops, and handle reconnection yourself.
Write your own client
The FireMQ protocol is small: length-prefixed frames with a MessagePack header and a raw body. A client needs a MessagePack library and a few hundred lines. spec/test-vectors/frames.json has an encoded example of every frame to test your codec against.
Testing with an embedded broker (Rust)
The firemq crate runs a broker inside your tests, on free ports and in memory:
#![allow(unused)] fn main() { async fn run() -> std::io::Result<()> { let server = firemq::builder().ephemeral().random_ports().start().await?; let amqp_url = server.amqp_url().unwrap(); let fire_url = server.fire_url().unwrap(); // ... connect your code to the broker ... server.shutdown().await; Ok(()) } }
Command-line tool
The firemq binary is the broker and a set of tools for working with it. Run firemq <command> --help for every option.
| Command | What it does |
|---|---|
firemq | Starts the broker. Same as firemq serve. |
firemq --dev | Starts an in-memory broker that accepts guest from any host and logs at debug level. |
firemq init | Writes a commented firemq.toml with every setting and its default. |
firemq ctl ... | Manages a running broker through its HTTP API. |
firemq pub ... | Publishes messages and waits for the confirms. |
firemq sub ... | Consumes messages and prints them. |
firemq bench | Measures throughput and latency. |
firemq doctor | Checks file limits, ports, the data directory and a running broker. |
firemq completions <shell> | Prints completions for bash, zsh, fish, PowerShell or Elvish. |
Manage a broker with ctl
ctl talks to the management API at http://localhost:15672 as guest by default. Change that with --url, --user and --pass, or with FIREMQ_HTTP_URL, FIREMQ_USER and FIREMQ_PASS. --vhost selects the virtual host, and --json prints raw JSON instead of a table.
firemq ctl overview
firemq ctl list queues # also: exchanges, bindings, connections, consumers, users, vhosts, policies, permissions
firemq ctl declare-queue orders --arg x-message-ttl=60000 --arg x-dead-letter-exchange=dlx
firemq ctl declare-exchange events topic
firemq ctl bind events orders --key 'order.*'
firemq ctl get orders --count 5 # peek; add --take to remove the messages
firemq ctl purge orders
firemq ctl delete-queue orders
firemq ctl add-user app s3cret --tags management
firemq ctl set-permissions app --configure '^app\.' --write '^app\.' --read '^app\.'
firemq ctl set-policy ttl '^tmp\.' '{"message-ttl": 60000}'
firemq ctl export definitions.json
firemq ctl import definitions.json # also accepts a RabbitMQ export
firemq ctl close-connection '127.0.0.1:50000 -> 127.0.0.1:5672'
Publish and consume
pub and sub use the FireMQ protocol at fire://localhost. Set --url or FIREMQ_URL to change it.
firemq pub events order.created '{"id": 1}' --content-type application/json
firemq pub --queue jobs "one task" # straight to a queue
firemq pub --queue jobs --count 10000 "load" # publish many, pipelined
cat payload.json | firemq pub --queue jobs # body from stdin
firemq pub --queue jobs --file payload.bin -H trace=abc
firemq sub jobs # print and acknowledge until Ctrl-C
firemq sub jobs --count 10 # stop after 10
firemq sub jobs --peek --count 1 # look without removing (requeue)
firemq sub jobs --json # JSON lines with properties and headers
Benchmark
firemq bench --embedded # an in-process broker, nothing else needed
firemq bench --publishers 4 --consumers 4 --size 1024 --duration 30
firemq bench --persistent # durable queue, persistent messages
bench prints published and consumed messages per second every second, then the averages and the end-to-end latency percentiles. Publishers keep --window unconfirmed messages in flight.
Configuration
FireMQ needs no configuration. When you need to change something, each setting can come from three places. The first one found wins:
- a command-line flag, such as
--amqp 6672 - an environment variable, such as
FIREMQ_AMQP=6672 - the settings file,
firemq.tomlin the working directory or the file given with--config
firemq init writes a firemq.toml that lists every setting with its default and a comment. FireMQ rejects unknown keys in the file, so a typo fails at startup instead of being ignored.
Listeners
A listener value is host:port, a bare port (all interfaces), or off.
| Setting | Flag | Environment | Default |
|---|---|---|---|
| AMQP 0-9-1 | --amqp | FIREMQ_AMQP | 0.0.0.0:5672 |
| AMQP over TLS | --amqps | FIREMQ_AMQPS | 0.0.0.0:5671, only with TLS |
| FireMQ protocol | --fire | FIREMQ_FIRE | 0.0.0.0:5673 |
| FireMQ over TLS | --fires | FIREMQ_FIRES | 0.0.0.0:5674, only with TLS |
| HTTP: UI, API, metrics, WebSocket | --http | FIREMQ_HTTP | 0.0.0.0:15672 |
| TLS certificate chain (PEM) | --tls-cert | FIREMQ_TLS_CERT | none |
| TLS private key (PEM) | --tls-key | FIREMQ_TLS_KEY | none |
In firemq.toml, listeners go in [listeners] and the TLS files in [tls] as cert and key.
Storage
| Setting | Flag | Environment | [storage] key | Default |
|---|---|---|---|---|
| Data directory | --data-dir | FIREMQ_DATA_DIR | data_dir | firemq-data |
| Keep everything in memory | --ephemeral | FIREMQ_EPHEMERAL | ephemeral | false |
| Sync before confirming | --fsync | FIREMQ_FSYNC | fsync | true |
With fsync = false, a confirmed persistent message survives a broker crash but can be lost if the machine loses power. See Durability and recovery.
Limits
| Setting | Flag | Environment | [limits] key | Default |
|---|---|---|---|---|
| Memory high watermark (fraction of RAM) | --memory-high-watermark | FIREMQ_MEMORY_HIGH_WATERMARK | memory_high_watermark | 0.4 |
| Absolute memory limit | --memory-limit | FIREMQ_MEMORY_LIMIT | memory_limit | none |
| Free disk space limit | --disk-free-limit | FIREMQ_DISK_FREE_LIMIT | disk_free_limit | 50MB |
Sizes take units: 512MB, 2GiB, 1000000. When a limit is crossed, publishers are paused; see Operations.
Users
| Setting | Flag | Environment | [auth] key | Default |
|---|---|---|---|---|
| First user | --default-user | FIREMQ_DEFAULT_USER | default_user | guest |
| First user's password | --default-pass | FIREMQ_DEFAULT_PASS | default_pass | guest |
| Users limited to localhost | --loopback-users | FIREMQ_LOOPBACK_USERS | loopback_users | guest |
The first user is created only when the broker has no users, and gets the administrator tag and full access to the / vhost. An empty FIREMQ_LOOPBACK_USERS lets every user connect from any host.
Logging
| Setting | Flag | Environment | [log] key | Default |
|---|---|---|---|---|
| Level or filter | --log-level | FIREMQ_LOG | level | info |
Format: text or json | --log-format | FIREMQ_LOG_FORMAT | format | text |
The level also accepts per-module filters, for example info,firemq_amqp=debug.
Development mode
firemq --dev is shorthand for an in-memory broker that accepts guest from any host and logs at debug level. Use it in containers for local development and in CI.
Management UI, API and metrics
FireMQ's HTTP listener, port 15672 by default, serves four things:
| Path | What |
|---|---|
/ | The management UI |
/api/... | The management API, compatible with RabbitMQ's |
/metrics | Prometheus metrics |
/healthz | An unauthenticated liveness probe |
/fire | The FireMQ protocol over WebSocket |
UI
Open http://localhost:15672 and log in with a user that has the administrator, management, monitoring or policymaker tag. The UI shows totals and rates, connections, queues and exchanges. From a queue's page you can publish, peek at or take messages, bind it, purge it and delete it. The Admin page manages users, vhosts, policies and definitions.
API
The API follows RabbitMQ's management API: the same paths, JSON shapes and status codes. Tools written for RabbitMQ, including rabbitmqadmin, work with it. Authenticate with HTTP Basic:
curl -u guest:guest http://localhost:15672/api/overview
curl -u guest:guest http://localhost:15672/api/queues/%2F
curl -u guest:guest -X PUT -H 'content-type: application/json' \
-d '{"durable": true, "arguments": {"x-max-length": 1000}}' \
http://localhost:15672/api/queues/%2F/orders
The default vhost / is written %2F in URLs, and the default exchange is amq.default.
Access
| User tag | Can |
|---|---|
administrator | Everything |
monitoring or management | Read everything; management users can also fetch messages with /get |
policymaker | Read everything and manage policies |
A user without one of these tags cannot use the API. Users in loopback_users can only log in from localhost.
Endpoints
| Area | Endpoints |
|---|---|
| Node | GET /api/overview, /api/nodes, /api/whoami, /api/cluster-name |
| Virtual hosts | GET /api/vhosts; GET, PUT, DELETE /api/vhosts/{vhost} |
| Users | GET /api/users; GET, PUT, DELETE /api/users/{user}; GET /api/users/{user}/permissions |
| Permissions | GET /api/permissions; GET, PUT, DELETE /api/permissions/{vhost}/{user} |
| Policies | GET /api/policies[/{vhost}]; GET, PUT, DELETE /api/policies/{vhost}/{name} |
| Exchanges | GET /api/exchanges[/{vhost}]; GET, PUT, DELETE /api/exchanges/{vhost}/{name}; POST .../publish; GET .../bindings/source, .../bindings/destination |
| Queues | GET /api/queues[/{vhost}]; GET, PUT, DELETE /api/queues/{vhost}/{name}; DELETE .../contents; POST .../get; GET .../bindings |
| Bindings | GET /api/bindings[/{vhost}]; GET, POST /api/bindings/{vhost}/e/{exchange}/q/{queue} (or /e/{exchange}); GET, DELETE .../{properties_key} |
| Connections | GET /api/connections, /api/vhosts/{vhost}/connections; GET, DELETE /api/connections/{name} |
| Channels and consumers | GET /api/channels, /api/consumers[/{vhost}] |
| Definitions | GET, POST /api/definitions[/{vhost}] |
| Health | GET /api/aliveness-test/{vhost}, /api/health/checks/alarms, local-alarms, virtual-hosts, port-listener/{port}, protocol-listener/{protocol} |
List endpoints accept ?columns=name,messages (dotted paths such as message_stats.publish work) and ?name= to filter by name.
Prometheus metrics
/metrics needs no authentication. Metric names follow RabbitMQ's Prometheus plugin, so existing dashboards work:
rabbitmq_connections,rabbitmq_channels,rabbitmq_consumers,rabbitmq_queuesrabbitmq_queue_messages_ready,rabbitmq_queue_messages_unacked,rabbitmq_queue_messagesrabbitmq_global_messages_received_total,_delivered_total,_acknowledged_total,_confirmed_total,_unroutable_dropped_totalrabbitmq_process_resident_memory_bytes,rabbitmq_resident_memory_limit_bytes,rabbitmq_disk_space_available_bytes,rabbitmq_alarms_memory_used_watermark,rabbitmq_alarms_free_disk_space_watermark
Per-queue series carry vhost and queue labels: firemq_queue_messages_ready, firemq_queue_messages_unacked, firemq_queue_consumers, firemq_queue_messages_published_total, firemq_queue_messages_delivered_total and firemq_queue_messages_acked_total.
# prometheus.yml
scrape_configs:
- job_name: firemq
static_configs:
- targets: ["localhost:15672"]
Durability and recovery
This page explains what survives a restart or a crash, and what a publisher confirm promises.
What survives a restart
A message survives a restart when both of these are true:
- the queue is durable (
durable: truewhen declared) - the message is persistent (
delivery_mode: 2in AMQP,persistent: truein the FireMQ protocol)
Durable exchanges, durable queues, the bindings between them, users, permissions, vhosts and policies also survive. Transient messages and non-durable queues are kept in memory and are gone after a restart, as in RabbitMQ. Messages that were delivered but not acknowledged come back after a restart with redelivered set.
--ephemeral keeps everything in memory, including durable queues.
What a confirm means
With publisher confirms (confirm.select in AMQP, always on in the FireMQ protocol), the broker acknowledges a publish when it has taken responsibility for the message:
| Message | Queue | Confirmed after |
|---|---|---|
| Persistent | Durable | The message is written and synced to disk (fsync) |
| Persistent | Non-durable | The message is in the queue's memory |
| Transient | Any | The message is in the queue's memory |
| Any | No queue matched | Routing finished; the message is dropped, or returned first when mandatory |
Syncing is batched. One thread waits about 2 ms after the first pending write, then syncs every queue file that changed and confirms everything written before the sync. Under load, one sync covers thousands of publishes.
With --fsync=false, confirms are sent once the message is written to the operating system. The message survives a broker crash but not a power loss.
How FireMQ stores messages
firemq-data/
node.json format version
definitions.json users, vhosts, permissions, policies, durable topology
firemq.lock prevents two brokers from using the directory
queues/<vhost>/<queue>/
meta.json queue name, vhost and arguments
0000000001.seg messages, appended in order
0000000001.ack which messages were acknowledged
- Each durable queue appends messages to segment files of about 32 MiB. Every record carries a CRC32C checksum.
- When a message is acknowledged, its position is appended to the segment's
.ackfile. A segment is deleted as soon as all its messages are acknowledged, so disk use follows the backlog. - A durable queue keeps up to 8 MiB of message bodies in memory and reads the rest from disk, so a large backlog does not fill memory.
definitions.jsonis rewritten atomically (write, sync, rename) shortly after any durable change. It uses RabbitMQ's definitions format, so you can read it, back it up, or import it into RabbitMQ.
Crash recovery
At startup FireMQ:
- reads
definitions.jsonand recreates users, vhosts, policies, exchanges, queues and bindings - restores queues that have data on disk but are missing from the definitions, which happens if the broker crashed right after a queue was declared
- scans each queue's segments, verifies every checksum, and truncates a segment at the first damaged record, which is where a crash interrupted a write
- skips acknowledged and transient messages and requeues the rest as redelivered
The startup banner and the Data line report how many queues and messages were recovered.
Tested behaviour
A test in the repository (crates/firemq/tests/chaos.rs) starts the broker, publishes persistent messages with 256 confirms in flight, kills the process with kill -9 at a random moment, restarts it, and repeats four times. It then checks that every confirmed message is present. Tens of thousands of messages are confirmed per run, and none are lost.
Security
Users and passwords
On first start, FireMQ creates one user, guest with password guest by default, with the administrator tag and full access to the / vhost. Like RabbitMQ, guest can only connect from localhost. Before exposing FireMQ to a network:
-
Create your own users and grant them access. These commands run as
guest, from the broker's machine:firemq ctl add-user admin 'a-long-password' --tags administrator firemq ctl set-permissions admin firemq ctl add-user orders-service 'another-password' firemq ctl set-permissions orders-service --configure '^orders\.' --write '^orders\.' --read '^orders\.' -
Delete
guest, logged in as the new administrator:firemq ctl delete-user guest --user admin --pass 'a-long-password'
Alternatively, set FIREMQ_DEFAULT_USER and FIREMQ_DEFAULT_PASS before the first start, so guest is never created.
Passwords are stored as salted SHA-256 hashes in RabbitMQ's format, so users exported from RabbitMQ keep their passwords when imported.
Permissions
Permissions work as in RabbitMQ. Each user has, per vhost, three regular expressions matched against queue and exchange names:
| Permission | Needed to |
|---|---|
| configure | declare and delete queues and exchanges |
| write | publish to an exchange, bind a queue or exchange as destination |
| read | consume from or get from a queue, purge it, bind an exchange as source |
An empty pattern grants nothing. Publishing to the default exchange checks write access to amq.default. A user without permissions on a vhost cannot connect to it.
TLS
Pass a PEM certificate chain and private key:
firemq --tls-cert /etc/firemq/cert.pem --tls-key /etc/firemq/key.pem
This starts AMQPS on 5671 and the FireMQ protocol over TLS on 5674, next to the plain listeners. To accept only TLS, turn the plain ones off with --amqp off --fire off. FireMQ uses rustls and supports TLS 1.2 and 1.3.
The HTTP listener serves plain HTTP. To serve the UI, API and WebSocket over HTTPS, put a TLS-terminating proxy such as Caddy, nginx or a cloud load balancer in front of port 15672.
Network exposure
Every listener binds to all interfaces by default. To keep a port local, bind it to the loopback address, for example --http 127.0.0.1:15672.
Operations
Health checks
| Check | Use |
|---|---|
GET /healthz | Liveness. Needs no credentials. Returns 200 while the HTTP listener runs. |
GET /api/health/checks/alarms | Readiness. Returns 503 while a memory or disk alarm is active. |
GET /api/aliveness-test/{vhost} | End to end: declares a queue, publishes and consumes a message. |
GET /api/health/checks/port-listener/5672 | Checks that a listener is up. |
firemq health | Exit status 0 when /healthz answers. Used by the Docker image's HEALTHCHECK. |
Resource alarms
FireMQ samples its memory use and the free disk space of the data directory every second. It raises an alarm when:
- resident memory exceeds the memory high watermark: 40% of system memory by default, or the absolute
--memory-limit - free disk space drops below
--disk-free-limit, 50 MB by default
While an alarm is active, FireMQ stops reading from connections that publish. Consumers keep working, so queues drain and memory is freed. AMQP clients that support it receive connection.blocked and connection.unblocked. FireMQ SDKs receive a flow frame. The UI shows a banner, and rabbitmq_alarms_* metrics are 1.
Monitoring
Scrape /metrics with Prometheus; see Management UI, API and metrics. Useful alerts:
rabbitmq_alarms_memory_used_watermark == 1orrabbitmq_alarms_free_disk_space_watermark == 1: publishers are pausedfiremq_queue_messages_readygrowing steadily: consumers are not keeping upfiremq_queue_consumers == 0on a queue that should be consumed
Logs
Logs go to standard error. Use --log-format json for log collectors, and --log-level debug or a filter such as info,firemq_amqp=debug to investigate client behaviour.
Backups
- Topology and users:
firemq ctl export definitions.json, or copyfiremq-data/definitions.json. Restore withfiremq ctl import. - Messages: stop the broker and copy the whole data directory. Copying it while the broker runs can capture a segment in the middle of a write; recovery truncates such a segment, losing the messages after the damaged record.
Upgrades
Stop the broker, replace the binary, and start it again. FireMQ refuses to start on a data directory written by a newer format than it understands, rather than misreading it.
Open files
Each connection and each segment file uses a file descriptor. FireMQ raises its soft limit to the hard limit at startup; firemq doctor shows both. For many connections or large backlogs, set the hard limit to at least 65536 (LimitNOFILE in the systemd unit).
Migrating from RabbitMQ
Most applications move to FireMQ without code changes, because FireMQ speaks AMQP 0-9-1 and serves RabbitMQ's management API.
1. Check that you use supported features
Read What FireMQ does not support. The main questions:
- Do you rely on clustering, quorum queue replication, streams, federation or the shovel plugin?
- Do you use plugin exchange types, such as
x-delayed-messageorx-consistent-hash? - Do you connect with MQTT, STOMP or AMQP 1.0?
If not, your applications should work unchanged.
2. Export your definitions from RabbitMQ
rabbitmqadmin export rabbitmq-definitions.json
# or: curl -u admin:password http://rabbitmq:15672/api/definitions > rabbitmq-definitions.json
3. Import them into FireMQ
firemq ctl import rabbitmq-definitions.json
Users keep their passwords, because FireMQ verifies RabbitMQ's SHA-256 and SHA-512 password hashes. Vhosts, permissions, policies, exchanges, queues and bindings are created. Queues declared as x-queue-type: quorum become durable FireMQ queues.
4. Point your applications at FireMQ
Change the host in your AMQP URLs. Ports, vhosts and credentials stay the same.
5. Move the messages, if you need to
Definitions do not contain messages. To move messages that are still queued, either let the RabbitMQ queues drain before switching publishers, or run a small program that consumes from RabbitMQ and publishes to FireMQ with confirms.
Differences you may notice
- Management UI. FireMQ's UI is different from RabbitMQ's and smaller. The API underneath is compatible.
- Product name.
connection.startand/api/overviewreport the product asFireMQ. Code that checks forRabbitMQby name, which is rare, needs a change. - Message TTL. Expired messages are removed when they reach the head of the queue, as in RabbitMQ classic queues, with a timer precision of a few milliseconds.
- Storage. Durable queues always page to disk instead of switching between lazy and default modes;
x-queue-modeis accepted and ignored. - Errors. Reply codes and messages follow RabbitMQ's, including
PRECONDITION_FAILED - inequivalent arg ..., so existing error handling keeps working.
RabbitMQ client compatibility
FireMQ speaks AMQP 0-9-1, so RabbitMQ client libraries connect to it unchanged. CI runs every client below against the firemq binary on every pull request.
| Language | Library | What CI runs | Status |
|---|---|---|---|
| Python | pika | The six RabbitMQ tutorials, publisher confirms, mandatory returns, redeclaration errors, headers exchange, dead-lettering | Passing |
| JavaScript | amqplib | Tutorials, confirm channels, returns, channel errors | Passing |
| Go | amqp091-go | Tutorials, confirms, returns, channel errors | Passing |
| Go | amqp091-go's own integration test suite (v1.10.0) | The library's upstream tests, unmodified | Tracked in CI |
| Java | RabbitMQ Java client 5.x | Tutorials, confirms, returns, consumer cancel notifications, transactions | Passing |
| .NET | RabbitMQ.Client 7.x | Tutorials, tracked publisher confirms, returns, channel errors | Passing |
The suites live in compat/. To run one locally, start firemq and set AMQP_URL:
AMQP_URL=amqp://guest:guest@localhost:5672/ uv run --with pika --with pytest pytest compat/python
Behaviour that matches RabbitMQ on purpose
basic.publishwithimmediate=truecloses the connection with 540 NOT_IMPLEMENTED.channel.flowwithactive=falseis answered with 540 NOT_IMPLEMENTED.basic.qoswith a non-zeroprefetch_sizeis answered with 540 NOT_IMPLEMENTED.- Deleting a queue or exchange that does not exist succeeds.
- The
guestuser can only connect from localhost. SetFIREMQ_LOOPBACK_USERS=to lift the restriction, for example in containers.
Management API
FireMQ serves a subset of RabbitMQ's management HTTP API on port 15672 with the same paths, JSON shapes and status codes. CI drives it with RabbitMQ's own rabbitmqadmin script: show overview, declare and delete exchanges, queues, bindings and policies, publish, get, list and export definitions.
Covered endpoints: /api/overview, /api/nodes, /api/whoami, /api/vhosts, /api/users, /api/permissions, /api/policies, /api/exchanges (including /publish and bindings), /api/queues (including /get, /contents and bindings), /api/bindings, /api/connections, /api/channels, /api/consumers, /api/definitions (export and import, whole broker or one vhost), /api/aliveness-test/{vhost} and /api/health/checks/*.
Prometheus metrics are at /metrics on the same port, using RabbitMQ's metric names (rabbitmq_queue_messages_ready, rabbitmq_global_messages_received_total and so on) plus per-queue firemq_queue_* series. /healthz is an unauthenticated liveness probe.
What FireMQ does not support
FireMQ aims to be honest about its gaps. If something you need is missing, open an issue on GitHub.
Not supported
| Feature | Notes |
|---|---|
| Clustering and replication | FireMQ runs on one node. Leader-follower replication is on the roadmap. |
| Quorum queue semantics | x-queue-type: quorum is accepted and creates a durable classic queue. Delivery limits (x-delivery-limit) and replication are not implemented. |
| Streams | x-queue-type: stream is rejected. Streams are on the roadmap. |
| MQTT, STOMP, AMQP 1.0 | Only AMQP 0-9-1 and the FireMQ protocol. MQTT is on the roadmap. |
| Plugins | No plugin system. Plugin exchange types (x-delayed-message, x-consistent-hash, x-random) are rejected with COMMAND_INVALID. |
| Federation and shovel | Not supported. |
| LDAP, OAuth 2, x509 client authentication | Only username and password (PLAIN, AMQPLAIN). |
| Topic permissions | Only configure, write and read permissions per vhost. |
immediate publishing | Closes the connection with 540, as in RabbitMQ 3.x. |
channel.flow with active=false | Rejected with 540, as in RabbitMQ. |
basic.recover with requeue=false | Rejected with 540, as in RabbitMQ. |
prefetch_size (byte-based prefetch) | Rejected with 540, as in RabbitMQ. |
Consumer priorities (x-priority on consumers) | Accepted and ignored. |
Direct reply-to (amq.rabbitmq.reply-to) | Not supported; use a temporary exclusive queue. |
| Vhost and user limits, operator policies, parameters | Not supported. |
| HTTPS on the management listener | Use a TLS-terminating proxy. |
| Management API statistics history | Rates are current only; there is no sampling history or ?lengths_age. |
Supported
Everything else in RabbitMQ's core AMQP 0-9-1 model works, including:
- exchanges: direct, fanout, topic and headers (
x-matchall, any, all-with-x and any-with-x), exchange-to-exchange bindings, alternate exchanges, internal and auto-delete exchanges - queues: durable, exclusive, auto-delete, server-named,
x-message-ttl, per-messageexpiration,x-expires,x-max-length,x-max-length-bytes,x-overflow(drop-head,reject-publish,reject-publish-dlx),x-dead-letter-exchange,x-dead-letter-routing-keywithx-deathheaders,x-max-priority,x-single-active-consumer - consuming: prefetch per consumer and per channel (
global), acks, nacks and rejects with requeue,basic.get,basic.recover, consumer cancel notifications - publishing: publisher confirms, mandatory returns, transactions (
tx.select,tx.commit,tx.rollback) - policies, vhosts, users, permissions, definitions import and export, memory and disk alarms with
connection.blocked
The FireMQ protocol
Version 1, release candidate. The version freezes once the Rust, Go and TypeScript SDKs pass the conformance suite; from then on, changes are additive only and gated by capabilities.
The FireMQ protocol is the native protocol of the FireMQ broker. It carries the same model as AMQP 0-9-1 (exchanges, queues, bindings, acknowledgements, publisher confirms) in a format that is small enough to implement in an afternoon in any language with a MessagePack library.
FireMQ also speaks AMQP 0-9-1 on port 5672. Both protocols share one broker, so a message published over one is consumed over the other.
Design rules
- One connection is one session. There are no channels.
- Every frame has a MessagePack header and an optional raw payload. Message bodies are never MessagePack-encoded.
- Requests carry a request id (
rid) and get exactly one reply:ok,error, or an op-specific reply. - Omitted header fields take their zero value:
false,0, empty string, empty map. Implementations ignore header keys they do not know. - All integers in framing are big-endian.
Transports
| Transport | Default address | Framing |
|---|---|---|
| TCP | port 5673 | Client first sends the 4 ASCII bytes FMQ1, then length-prefixed frames. |
| TLS | port 5674 | As TCP, inside TLS. |
| WebSocket | ws://host:15672/fire | One binary WebSocket message per frame, without the length prefix and without FMQ1. |
Connection URLs use the schemes fire://, fires:// (TLS), ws:// and wss://:
fire://user:password@host:5673/vhost
For fire and fires, the vhost is the URL path with the leading / removed and percent-decoded; an empty path means vhost /. For ws and wss, the path is the WebSocket endpoint (/fire when empty) and the vhost is the vhost query parameter, for example ws://localhost:15672/fire?vhost=dev. The default user and password are guest and guest. The default ports are 5673 (fire), 5674 (fires), 15672 (ws) and 15671 (wss).
Frame format
On TCP and TLS:
+-----------+--------+------------+---------------------+------------------+
| len : u32 | op : u8| hlen : u32 | header (hlen bytes) | payload |
+-----------+--------+------------+---------------------+------------------+
lencounts every byte after thelenfield:1 + 4 + hlen + payload length.headeris a MessagePack map with string keys.hlenmay be 0, meaning an empty map.payloadis the rest of the frame. Onlypublish,return,deliverandget_okcarry a payload.
On WebSocket the frame is the same without len.
Before welcome, a frame may be at most 65 536 bytes. After welcome, the limit is welcome.max_frame (16 MiB by default). A message body must fit in one frame.
Op codes
| Op | Name | Direction | Payload |
|---|---|---|---|
0x01 | hello | client → server | |
0x02 | welcome | server → client | |
0x03 | ping | both | |
0x04 | pong | both | |
0x05 | close | both | |
0x06 | ok | server → client | |
0x07 | error | server → client | |
0x08 | flow | server → client | |
0x10 | declare_exchange | client → server | |
0x11 | delete_exchange | client → server | |
0x12 | declare_queue | client → server | |
0x13 | delete_queue | client → server | |
0x14 | purge_queue | client → server | |
0x15 | bind | client → server | |
0x16 | unbind | client → server | |
0x20 | publish | client → server | message body |
0x21 | confirm | server → client | |
0x22 | return | server → client | message body |
0x30 | subscribe | client → server | |
0x31 | unsubscribe | client → server | |
0x32 | credit | client → server | |
0x33 | deliver | server → client | message body |
0x34 | cancelled | server → client | |
0x35 | ack | client → server | |
0x36 | nack | client → server | |
0x37 | get | client → server | |
0x38 | get_ok | server → client | message body |
A server that receives an unknown op replies error with code 540 and keeps the connection open.
Connection lifecycle
client server
| -- FMQ1 (TCP only) ------------------> |
| -- hello ----------------------------> |
| <-------------------- welcome or error |
| ... requests, publishes, deliveries ...|
| -- close ----------------------------> |
| <------------------------------- close |
hello
| Field | Type | Meaning |
|---|---|---|
ver | uint | Protocol major version. Must be 1. |
vhost | str | Virtual host. Empty means /. |
auth | map | {"mech": "plain", "user": str, "pass": str}. |
heartbeat | uint | Desired heartbeat interval in seconds. 0 disables heartbeats. |
client | map | Free-form client info, for example {"name": "firemq-go", "version": "0.1.0", "lang": "go"}. Shown in the management UI. |
caps | [str] | Capabilities the client supports. |
welcome
| Field | Type | Meaning |
|---|---|---|
ver | uint | Protocol version in use. |
server | map | {"name": "firemq", "version": str}. |
conn_id | str | Connection name, as shown in the management API. |
heartbeat | uint | Negotiated heartbeat interval in seconds: the smaller non-zero value of the client's and the server's. |
max_frame | uint | Largest frame the server accepts, in bytes. |
caps | [str] | Capabilities the server supports. Version 1 servers send confirms, credit, mandatory, exchange-bindings, dead-letter and ttl. |
If authentication fails, the server replies error with code 403 and fatal: true, then closes the socket.
Heartbeats
When the negotiated interval h is non-zero, each side sends ping when it has sent nothing for h / 2 seconds. Each side considers the peer dead when it has received nothing for 2 × h seconds. A peer that receives ping replies pong.
close
{"code": uint, "msg": str}. The receiver replies with its own close and closes the socket. Code 200 means a normal shutdown.
Replies
ok
| Field | Type | Present for |
|---|---|---|
rid | uint | always |
name | str | declare_queue (the queue's name, useful for server-named queues) |
messages | uint | declare_queue, delete_queue, purge_queue |
consumers | uint | declare_queue |
empty | bool | get when the queue is empty |
error
| Field | Type | Meaning |
|---|---|---|
rid | uint | The failed request, or absent for errors not tied to a request. |
code | uint | An AMQP reply code (table below). |
msg | str | Human-readable reason. |
fatal | bool | true when the server closes the connection after this frame. |
| Code | Name | Typical cause |
|---|---|---|
| 320 | connection-forced | Server shutting down. |
| 403 | access-refused | Bad credentials, or no permission for the resource. |
| 404 | not-found | Queue or exchange does not exist. |
| 405 | resource-locked | Exclusive queue owned by another connection. |
| 406 | precondition-failed | Redeclared with different properties, unknown delivery tag, if_unused or if_empty failed. |
| 501 | frame-error | Malformed frame. Fatal. |
| 502 | syntax-error | Header is not a MessagePack map, or a field has the wrong type. |
| 503 | command-invalid | Request not allowed in the current state, such as a request before hello. |
| 506 | resource-error | Server out of a resource. |
| 530 | not-allowed | Operation not allowed, such as deleting a built-in exchange. |
| 540 | not-implemented | Unknown op or unsupported feature. |
| 541 | internal-error | Bug in the server. |
flow
{"pause": bool}. The server sends pause: true when a memory or disk alarm fires. Clients stop publishing until pause: false. A client that keeps publishing is throttled by the server no longer reading its socket.
Topology
declare_exchange
| Field | Type | Meaning |
|---|---|---|
rid | uint | Request id. |
name | str | Exchange name. Names starting with amq. are reserved. |
type | str | direct, fanout, topic or headers. |
durable | bool | Survives a broker restart. |
auto_delete | bool | Deleted when its last binding is removed. |
internal | bool | Cannot be published to directly. |
passive | bool | Only check that it exists. |
args | map | Exchange arguments, for example alternate-exchange. |
Reply: ok. Redeclaring an existing exchange with different type, durable, auto_delete or internal fails with 406.
delete_exchange
{"rid", "name", "if_unused": bool}. Reply: ok.
declare_queue
| Field | Type | Meaning |
|---|---|---|
rid | uint | Request id. |
name | str | Queue name. Empty means the server picks a unique name starting with amq.gen-. |
durable | bool | Survives a broker restart. |
exclusive | bool | Used only by this connection and deleted when it closes. |
auto_delete | bool | Deleted when its last subscription is removed. |
passive | bool | Only check that it exists. |
args | map | Queue arguments with RabbitMQ's names: x-message-ttl, x-expires, x-max-length, x-max-length-bytes, x-overflow, x-dead-letter-exchange, x-dead-letter-routing-key, x-single-active-consumer, x-max-priority. |
Reply: ok with name, messages and consumers.
delete_queue
{"rid", "name", "if_unused": bool, "if_empty": bool}. Reply: ok with messages, the number of messages deleted.
purge_queue
{"rid", "name"}. Reply: ok with messages, the number of messages purged.
bind and unbind
| Field | Type | Meaning |
|---|---|---|
rid | uint | Request id. |
exchange | str | Source exchange. |
queue | str | Destination queue. |
to_exchange | str | Destination exchange, for exchange-to-exchange bindings. Exactly one of queue and to_exchange is set. |
key | str | Binding key. |
args | map | Binding arguments, for example x-match for headers exchanges. |
Reply: ok.
Publishing
publish
| Field | Type | Meaning |
|---|---|---|
seq | uint | Client-assigned sequence number, starting at 1 and increasing by 1. 0 means no confirm is wanted. |
exchange | str | Target exchange. Empty is the default exchange, which routes to the queue named by key. |
key | str | Routing key. |
mandatory | bool | Return the message with return when no queue receives it. |
persistent | bool | Write the message to disk. Survives a restart when the queue is durable. |
props | map | Message properties (below). |
headers | map | Application headers. Values are any MessagePack type. |
The payload is the message body.
Message properties:
| Key | Type | AMQP property |
|---|---|---|
content_type | str | content-type |
content_encoding | str | content-encoding |
correlation_id | str | correlation-id |
reply_to | str | reply-to |
expiration | uint | expiration, in milliseconds |
message_id | str | message-id |
timestamp | uint | timestamp, in seconds since the Unix epoch |
type | str | type |
user_id | str | user-id. When set, it must equal the connection's user. |
app_id | str | app-id |
priority | uint | priority, 0 to 255 |
confirm
The server confirms every publish with a non-zero seq exactly once.
{"upto": n}confirms every outstanding publish withseq <= n. The broker has taken responsibility for those messages: they are on disk when persistent and routed to a durable queue, and in memory otherwise.{"seq": n, "nack": true, "code": uint, "msg": str}rejects one publish, for example because a queue withx-overflow: reject-publishis full. The message was not enqueued in every queue that should have received it.
A nack for a sequence number is always sent before any upto that covers it.
return
{"seq", "code", "msg", "exchange", "key", "props", "headers"} with the message body as payload. Sent for a mandatory publish that no queue received, with code 312 (no-route). The return is sent before the publish's confirm.
Consuming
subscribe
| Field | Type | Meaning |
|---|---|---|
rid | uint | Request id. |
sub | uint | Client-chosen subscription id, unique on the connection. |
queue | str | Queue to consume from. |
credit | uint | Initial credit: how many messages the server may send before the client grants more. |
no_ack | bool | Messages count as acknowledged once sent. |
exclusive | bool | Fail with 405 if the queue has other consumers, and block new ones. |
tag | str | Consumer tag shown in the management API. Defaults to fire-<sub>. |
args | map | Consumer arguments. |
Reply: ok.
credit
{"sub": uint, "credit": uint} adds credit to a subscription. The server sends at most as many deliver frames as the credit granted so far. Clients usually grant their buffer size at subscribe, then top up by half that size each time they have processed half of it.
deliver
| Field | Type | Meaning |
|---|---|---|
sub | uint | Subscription id. |
tag | uint | Delivery tag, unique and increasing on the connection, starting at 1. |
redelivered | bool | The message was delivered before and not acknowledged. |
exchange | str | Exchange the message was published to. |
key | str | Routing key it was published with. |
props | map | Message properties. |
headers | map | Application headers. |
The payload is the message body.
ack and nack
ack:{"tag": uint, "multiple": bool}. Withmultiple, acknowledges every unacknowledged delivery with a tag up to and includingtag.nack:{"tag", "multiple", "requeue": bool}. Requeued messages are delivered again withredelivered: true. Messages that are not requeued are dead-lettered when the queue has a dead-letter exchange, and dropped otherwise.
Neither has a reply. An unknown tag produces a non-fatal error with code 406.
unsubscribe
{"rid", "sub"}. Reply: ok. The server sends no more deliveries for sub. Unacknowledged deliveries stay unacknowledged until the client acks them or the connection closes, when they are requeued.
cancelled
{"sub", "code", "msg"}. The server ended a subscription, for example because its queue was deleted.
get and get_ok
get is {"rid", "queue", "no_ack": bool}. When a message is ready, the server replies get_ok:
| Field | Type | Meaning |
|---|---|---|
rid | uint | Request id. |
tag | uint | Delivery tag for ack and nack. |
redelivered | bool | As in deliver. |
exchange | str | As in deliver. |
key | str | As in deliver. |
remaining | uint | Messages still ready in the queue. |
props | map | Message properties. |
headers | map | Application headers. |
When the queue is empty, the reply is ok with empty: true.
Interoperability with AMQP 0-9-1
| FireMQ | AMQP 0-9-1 |
|---|---|
persistent: true | delivery-mode: 2 |
props.expiration (uint ms) | expiration (decimal string) |
| MessagePack nil, bool, int, float, str, bin, array, map | field table V, t, l, d, S, x, A, F |
AMQP D (decimal) and T (timestamp) | float and uint |
Reserved for later versions
These names are reserved so later versions can add them without breaking version 1 clients:
- Streams:
declare_queuewithargs["x-queue-type"] = "stream", asubscribefieldoffset("first","last","next", a uint, or{"ts": uint}), adeliverfieldoffset, and an op0x39commit_offset. - Capabilities
streams,zstdandscram.
Test vectors
test-vectors/frames.json lists one encoded frame per op with its decoded header and payload. Decoders must decode every vector to the listed header. Encoders do not have to reproduce the bytes exactly, because MessagePack allows several encodings of the same map.
SDK conformance suite
Every FireMQ SDK runs the same conformance scenarios. The runner, firemq-conformance, starts an in-memory broker and drives the SDK through a small driver: a program, written with the SDK, that reads JSON commands on stdin and writes JSON replies on stdout. A driver is usually 150 to 300 lines.
cargo run -p firemq-conformance -- run --driver "go run ./conformance/driver"
The runner prints one line per scenario and exits with status 1 if any scenario fails. --only <name> runs one scenario and --list lists them.
Driver protocol
The runner writes one JSON object per line. Each has an id, an op, and the op's fields. The driver writes exactly one reply line per command, with the same id, in order:
{"id": 3, "op": "publish", "exchange": "", "key": "jobs", "body": "hi", "persistent": true, "mandatory": false, "properties": {}, "headers": {}}
{"id": 3, "ok": true, "result": {}}
A failed command replies with ok: false and an error:
{"id": 4, "ok": false, "error": {"kind": "server", "code": 406, "message": "PRECONDITION_FAILED - ..."}}
kind | When |
|---|---|
server | The broker refused a request; code is the AMQP reply code. |
unroutable | A mandatory publish reached no queue. |
nacked | The broker rejected a publish. |
closed | The connection is closed. |
unsupported | The SDK does not implement the op. |
other | Anything else. |
The driver must not write anything else to stdout. It may log to stderr. It exits when stdin closes.
Ops
The runner always sends every field listed, so drivers need no defaults.
| Op | Fields | Result |
|---|---|---|
hello | {"sdk": "firemq-go", "version": "0.1.0", "capabilities": []} | |
connect | url | {} |
close | {} | |
declare_queue | name, durable, exclusive, auto_delete, args | {"name", "messages", "consumers"} |
delete_queue | name | {"messages"} |
purge_queue | name | {"messages"} |
declare_exchange | name, type, durable | {} |
delete_exchange | name | {} |
bind_queue | queue, exchange, key, args | {} |
unbind_queue | queue, exchange, key | {} |
bind_exchange | destination, source, key | {} |
publish | exchange, key, body or body_base64, persistent, mandatory, properties, headers | {} after the broker confirms |
subscribe | queue, prefetch, no_ack | {"sub": "<id>"} |
next | sub, timeout_ms | {"message": <message or null>} |
ack | tag | {} |
nack | tag, requeue | {} |
unsubscribe | sub | {} |
get | queue, no_ack | {"message": <message or null>} |
args and headers are JSON objects whose values are strings, numbers, booleans, nulls, arrays or objects. properties uses the keys of the protocol's message properties: content_type, content_encoding, correlation_id, reply_to, expiration (milliseconds), message_id, timestamp, type, user_id, app_id and priority.
A message is:
{
"body": "text, or null when the body is not UTF-8",
"body_base64": "the body, base64-encoded",
"exchange": "orders",
"routing_key": "order.created",
"redelivered": false,
"tag": "17",
"properties": {"content_type": "application/json"},
"headers": {"trace": "abc"}
}
tag is an opaque string the runner passes back to ack and nack.
Scenarios
| Scenario | Checks |
|---|---|
connect_and_close | Handshake, hello, clean close |
wrong_password | Connecting with a bad password fails with code 403 |
publish_and_get | Publish with confirm; get returns body, exchange and key; empty queue returns null |
consume_and_ack | 100 messages consumed in order and acknowledged; the queue ends empty |
prefetch_limits_deliveries | With prefetch 2, a third delivery waits for an ack |
nack_requeue | A nacked message comes back with redelivered: true |
nack_dead_letters | A message nacked without requeue goes to the dead-letter exchange |
mandatory_unroutable | A mandatory publish to nowhere fails with kind unroutable |
overflow_nack | A publish to a full reject-publish queue fails with kind nacked |
topic_routing | Topic patterns route as specified |
exchange_to_exchange | Exchange-to-exchange bindings route |
properties_and_headers | Every property and header type round-trips |
binary_and_large_bodies | A non-UTF-8 body and a 1 MiB body arrive intact |
error_codes | Redeclaring with other properties gives 406; binding to a missing exchange gives 404; the connection stays usable |
purge_and_delete_counts | Purge and delete report message counts |
unacked_requeued_on_close | Unacknowledged messages return when the connection closes |
exclusive_queue_deleted_on_close | An exclusive queue disappears with its connection |
unsubscribe_stops_delivery | No deliveries after unsubscribe |
amqp_interop | A message published by the SDK reaches an AMQP consumer, and back |
Benchmarks
This page compares FireMQ with RabbitMQ on the same machine, under the same limits, using the same load generator. Run bench/run.sh to reproduce it on your hardware; numbers from a laptop are a comparison, not a capacity plan.
Method
Each broker runs in a Docker container limited to 4 CPUs and 2 GiB of memory. The load generators run in a second container, also limited to 4 CPUs, on the same Docker network. Nothing crosses the macOS host's network stack.
Two load generators produce the same workload:
amqp_bench, an AMQP 0-9-1 client built on amqprs, drives both brokers. Its source iscrates/firemq-amqp/examples/amqp_bench.rs.firemq benchdrives FireMQ over its own protocol.
Both work the same way:
- Every publisher uses publisher confirms with up to 1,000 unconfirmed messages in flight.
- Every consumer uses a prefetch of 1,000 and acknowledges each message individually.
- The queue is durable. "Persistent" scenarios publish persistent messages, which both brokers write to disk before confirming; "transient" scenarios publish non-persistent messages.
- Each message body starts with its send time, so the consumer measures end-to-end latency from publish to delivery.
- Throughput scenarios publish as fast as confirms allow. Latency scenarios publish a fixed 1,000 messages per second, so latency isn't dominated by queueing.
A fresh broker container starts for every run. Each scenario runs three times for 20 seconds, and the tables show the median run: the run with the median delivered rate. Memory is the container's peak memory use during the run, sampled every 2 seconds with docker stats.
Throughput is the rate at which consumers received messages. When a broker accepts messages faster than it delivers them, the queue grows, and latency then measures mostly time spent waiting in the queue. The results call out those runs.
The recovery test fills a durable queue with 2,000,000 persistent 256-byte messages, stops the broker gracefully, starts it again, and measures how long until the queue holds every message again. It counts messages with a passive queue.declare, which both brokers answer exactly; RabbitMQ's management API refreshes its counts only every 5 seconds. Startup and recovery times include about 0.2 seconds to start the container that measures them.
Results
Measured on November 7, 2026, on an Apple M2 Pro with 10 cores and Docker Desktop 29.4.3. FireMQ ran at commit bf67ac7. RabbitMQ ran from the rabbitmq:4-management image: RabbitMQ 4.4.0 on Erlang/OTP 29. RabbitMQ's numbers come from a run earlier the same evening. FireMQ's runs were repeated after a change that lowered its idle memory, which doesn't affect RabbitMQ. The raw logs and CSV files are in bench/results/2026-11-07.
Throughput
Messages delivered to consumers per second:
| Scenario | RabbitMQ | FireMQ, AMQP | FireMQ, FireMQ protocol |
|---|---|---|---|
| Transient, 1 publisher and 1 consumer, 256 B | 113,000 | 135,000 | 156,000 |
| Persistent, 1 publisher and 1 consumer, 1 KiB | 79,000 | 134,000 | 174,000 |
| Transient, 4 publishers and 4 consumers, 256 B | 123,000 | 274,000 | 331,000 |
| Persistent, 4 publishers and 4 consumers, 1 KiB | 57,000 | 261,000 | 300,000 |
Driven by the same AMQP client, FireMQ delivered 1.2 to 4.6 times as many messages per second as RabbitMQ. The gap is widest with persistent messages and several publishers, because FireMQ syncs the writes of all publishers together, at most once every 2 ms. The FireMQ protocol adds another 10 to 25 percent.
In the two AMQP scenarios with 4 publishers, FireMQ accepted messages faster than it delivered them:
- Transient messages: 313,000 published and 274,000 delivered per second.
- Persistent messages: 283,000 published and 261,000 delivered per second.
The broker was at its 4-CPU limit in both. FireMQ doesn't slow publishers down when consumers fall behind, so the queue grew for the length of the run. RabbitMQ slows publishers in that situation with its credit flow. Over the FireMQ protocol, whose acknowledgments are cumulative and cheaper to process, publishing and delivery stayed level in every scenario.
Latency
End-to-end latency with publishers held at 1,000 messages per second, p50 / p99:
| Scenario | RabbitMQ | FireMQ, AMQP | FireMQ, FireMQ protocol |
|---|---|---|---|
| Transient, 256 B | 0.65 ms / 1.07 ms | 0.35 ms / 0.63 ms | 0.39 ms / 0.73 ms |
| Persistent, 1 KiB | 0.66 ms / 1.52 ms | 0.26 ms / 0.71 ms | 0.24 ms / 0.70 ms |
Persistent messages include the wait for the disk: neither broker confirms or delivers them before the write that holds them is synced.
End-to-end latency at full throughput, p50 / p99:
| Scenario | RabbitMQ | FireMQ, AMQP | FireMQ, FireMQ protocol |
|---|---|---|---|
| Transient, 1 publisher and 1 consumer, 256 B | 6.18 ms / 8.39 ms | 7.21 ms / 8.29 ms | 6.25 ms / 7.16 ms |
| Persistent, 1 publisher and 1 consumer, 1 KiB | 6.34 ms / 12 ms | 1.78 ms / 3.98 ms | 0.88 ms / 3.17 ms |
| Transient, 4 publishers and 4 consumers, 256 B | 35 ms / 42 ms | 1.3 s / 2.5 s | 13 ms / 26 ms |
| Persistent, 4 publishers and 4 consumers, 1 KiB | 28 ms / 64 ms | 833 ms / 1.6 s | 5.04 ms / 15 ms |
At full throughput, latency mostly measures time spent waiting. When the consumer is the bottleneck, it holds up to its prefetch of 1,000 messages that it hasn't processed yet, so latency approaches 1,000 divided by the delivery rate: about 7 ms at 140,000 messages per second. The FireMQ AMQP rows with 4 publishers show the queue that grew during those runs.
Memory
Peak broker memory during the throughput runs:
| Scenario | RabbitMQ | FireMQ, AMQP | FireMQ, FireMQ protocol |
|---|---|---|---|
| Transient, 1 publisher and 1 consumer, 256 B | 254 MiB | 9 MiB | 9 MiB |
| Persistent, 1 publisher and 1 consumer, 1 KiB | 252 MiB | 18 MiB | 20 MiB |
| Transient, 4 publishers and 4 consumers, 256 B | 285 MiB | 204 MiB | 22 MiB |
| Persistent, 4 publishers and 4 consumers, 1 KiB | 302 MiB | 129 MiB | 48 MiB |
Most of RabbitMQ's memory is the Erlang runtime, which uses about 245 MiB before the first message. FireMQ's memory follows the messages it holds. In the AMQP runs with 4 publishers, that includes the backlog that built up during the run.
Startup and recovery
| RabbitMQ | FireMQ | |
|---|---|---|
| Ready to accept connections after the container starts | 2.04 s | 0.36 s |
| Memory when idle | 246 MiB | 3 MiB |
| Restart with 2,000,000 queued messages, until every message is back | 2.40 s | 1.34 s |
| Memory holding those messages, before the restart | 231 MiB | 156 MiB |
| Memory holding those messages, after the restart | 255 MiB | 124 MiB |
| Container image | 456 MB | 13.8 MB |
On startup FireMQ reads each queue's segment files and verifies every record's checksum, so recovery time grows with the amount of queued data on disk. See Durability and recovery.
Reproduce
bench/run.sh # 20 seconds per run, 3 runs
bench/run.sh 60 5 # longer runs
CPUS=2 MEMORY=1g bench/run.sh
The script builds the FireMQ image from the working tree, builds the load generators for Linux, and writes raw logs and CSV files to bench/results/<date>/. To turn them into the tables on this page, run:
bench/summarize.py bench/results/<date>