Load testing for async message queues. Built for engineers who test more than just HTTP.
Inspired by k6. Same philosophy, different protocol.
Modern backend systems rely heavily on async workers: services that consume messages from queues and process them in the background. Tools like k6 and Locust are excellent for HTTP load testing, and while they can be extended to work with queues, the setup isn't always straightforward.
QStorm aims to bring that same familiar experience (stages, rates, live metrics) to message queues with zero configuration overhead. Define a config, point it at your queue, and run.
QStorm is a client-side tool. It runs from your machine or CI pipeline and publishes to the queue. No need to deploy it alongside your workers.
- Stage-based load profiles: define multi-stage tests with different rates and durations
- Template variables:
{{uuid}}and{{timestamp}}generate unique values per message - Live progress: real-time terminal output during test execution
- Accurate metrics: HDR Histogram for latency percentiles (p50, p75, p90, p99)
- Error summary: distinct errors aggregated by type with counts
- Graceful shutdown:
Ctrl+Cstops the test and prints collected results
| Queue | Status | |
|---|---|---|
| Google Cloud PubSub | Supported | |
| Apache Kafka | Supported | |
| RabbitMQ | Supported | |
| Apache Pulsar | Supported | |
| Apache ActiveMQ | Planned |
- Go 1.26+
- Docker (for running queues locally)
go install github.com/nawafswe/qstorm/cmd/qstorm@latestThis installs the qstorm binary to $GOPATH/bin (or $HOME/go/bin by default). If qstorm is not found after install, add Go's bin directory to your PATH:
# add to ~/.zshrc or ~/.bashrc
export PATH="$PATH:$(go env GOPATH)/bin"Verify:
qstorm --versiongit clone https://github.com/nawafswe/qstorm.git
cd qstorm
make build
./bin/qstorm --version# Start all services
make environment
# Or start individually
make gcp-pubsub # PubSub emulator (port 8095)
make kafka # Kafka plaintext (9092) + SASL (9093)
make rabbitmq # RabbitMQ (port 5672)
make pulsar # Pulsar standalone (port 6650)make env # copies .env.sample -> .envEdit .env to match your queue. See Queue Configuration for all options.
See Queue Configuration for full details per queue type.
# positional argument
qstorm config.json
# with flags
qstorm --config config.json --env .env| Flag | Default | Description |
|---|---|---|
--config |
(required) | Path to the JSON test config file |
--env |
.env |
Path to the .env connection file |
___ ____ _
/ _ \/ ___|| |_ ___ _ __ _ __ ___
| | | \___ \| __/ _ \| '__| '_ ` _ \
| |_| |___) | || (_) | | | | | | | |
\__\_\____/ \__\___/|_| |_| |_| |_|
execution: local
queue: apache-kafka
topic: qstorm-topic
stages: 2 configured, ~40s total
expected: ~600 messages
-> stage 1: 30s @ 10 msg/s
-> stage 2: 10s @ 30 msg/s
------------------------------------------------------------------
v published......: 598
x failed.........: 2
connection refused.......: 2
success_rate...: 99.67%
error_rate.....: 0.33%
publish_latency: avg=2.1ms p50=1.9ms p75=2.4ms p90=3.2ms p99=8.1ms
duration.......: 40.012s
------------------------------------------------------------------
QStorm uses two separate sources:
- A JSON config file defines the test (what to publish, how fast, for how long)
- A
.envfile provides connection credentials
Every queue type uses the same top-level structure:
{
"QUEUE": {
"TYPE": "gcp-pubsub | apache-kafka | rabbitmq | apache-pulsar",
"PAYLOAD": "...",
"ATTRIBUTES": "...",
"PUBSUB": { },
"KAFKA": { },
"RABBITMQ": { },
"PULSAR": { }
},
"STAGES": [ ]
}| Field | Type | Required | Description |
|---|---|---|---|
TYPE |
string | yes | Queue type: gcp-pubsub, apache-kafka, rabbitmq, apache-pulsar |
PAYLOAD |
string | yes | Message body. Supports template variables |
ATTRIBUTES |
string | no | JSON key-value pairs attached to each message. Supports template variables. Maps to PubSub attributes, Kafka headers, RabbitMQ headers, Pulsar properties |
Each stage runs sequentially. Define as many as needed.
| Field | Type | Required | Description |
|---|---|---|---|
DURATION |
string | yes | How long the stage runs (e.g. "30s", "2m") |
RATE |
int | yes | Messages per second during this stage |
{
"QUEUE": {
"TYPE": "gcp-pubsub",
"PAYLOAD": "{\"order_id\": \"{{uuid}}\"}",
"ATTRIBUTES": "{\"SOURCE\": \"qstorm\"}",
"PUBSUB": {
"TOPIC": "qstorm-topic",
"ORDERING_KEY": "customer-123"
}
},
"STAGES": [
{ "DURATION": "30s", "RATE": 50 }
]
}| Field | Type | Required | Description |
|---|---|---|---|
TOPIC |
string | yes | Full topic name (e.g. projects/my-project/topics/my-topic for real GCP, or just my-topic for emulator) |
ORDERING_KEY |
string | no | Enables ordered delivery. Messages with the same key are delivered in order |
| Variable | Required | Description |
|---|---|---|
PUBSUB__PROJECT_ID |
yes | GCP project ID |
PUBSUB__EMULATOR_HOST |
no | Emulator address (e.g. localhost:8095). When set, connects to emulator instead of GCP |
PUBSUB__CREDENTIALS_FILE |
no | Service account JSON credentials |
See Google Cloud PubSub documentation for more details.
{
"QUEUE": {
"TYPE": "apache-kafka",
"PAYLOAD": "{\"order_id\": \"{{uuid}}\"}",
"ATTRIBUTES": "{\"SOURCE\": \"qstorm\"}",
"KAFKA": {
"TOPIC": "qstorm-topic",
"KEY": "order-service",
"PARTITION": -1,
"PRODUCER": {
"ACKS": -1,
"COMPRESSION_TYPE": "snappy",
"LINGER_MS": 10,
"BATCH_SIZE": 32768
}
}
},
"STAGES": [
{ "DURATION": "30s", "RATE": 50 }
]
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
TOPIC |
string | yes | - | Kafka topic name |
KEY |
string | no | empty (round-robin) | Message key. Used for partition assignment. Messages with the same key go to the same partition. When empty, Kafka distributes messages via round-robin |
PARTITION |
int | no | -1 (any) | Explicit partition number. 0 or unset defaults to automatic assignment via key hash |
All optional. Sensible defaults are used when omitted.
| Field | Type | Default | Description |
|---|---|---|---|
ACKS |
int (nullable) | -1 (all) | Broker acknowledgments. 0 = fire-and-forget, 1 = leader only, -1 = all in-sync replicas |
COMPRESSION_TYPE |
string | none | Compression: none, gzip, snappy, lz4, zstd |
LINGER_MS |
int | 0 | Batching delay in ms. Higher = more batching = higher throughput |
BATCH_SIZE |
int | librdkafka default | Max bytes per batch |
See librdkafka configuration reference for all available producer options.
| Variable | Required | Description |
|---|---|---|
KAFKA__BOOTSTRAP_SERVERS |
yes | Comma-separated broker addresses (e.g. localhost:9092) |
KAFKA__SECURITY_PROTOCOL |
no | PLAINTEXT (default), SASL_PLAINTEXT, SASL_SSL, SSL |
KAFKA__SASL_MECHANISM |
no | PLAIN, SCRAM-SHA-256, SCRAM-SHA-512 |
KAFKA__SASL_USERNAME |
no | SASL username |
KAFKA__SASL_PASSWORD |
no | SASL password (redacted in logs) |
Local (no auth):
KAFKA__BOOTSTRAP_SERVERS=localhost:9092Cloud (Confluent Cloud, AWS MSK, Aiven, etc.):
KAFKA__BOOTSTRAP_SERVERS=broker.cloud:9093
KAFKA__SECURITY_PROTOCOL=SASL_SSL
KAFKA__SASL_MECHANISM=PLAIN
KAFKA__SASL_USERNAME=api-key
KAFKA__SASL_PASSWORD=api-secretSee Apache Kafka documentation for more details on security configuration.
{
"QUEUE": {
"TYPE": "rabbitmq",
"PAYLOAD": "{\"order_id\": \"{{uuid}}\"}",
"ATTRIBUTES": "{\"SOURCE\": \"qstorm\"}",
"RABBITMQ": {
"QUEUE": {
"NAME": "qstorm-queue",
"DURABLE": true,
"AUTO_DELETE": false,
"EXCLUSIVE": false,
"NO_WAIT": false,
"ARGS": {
"x-queue-type": "quorum"
}
},
"EXCHANGE": {
"NAME": "qstorm-exchange",
"KIND": "direct",
"DURABLE": true,
"AUTO_DELETE": false,
"INTERNAL": false,
"NO_WAIT": false
},
"CHANNEL": {
"CONFIRM_MODE": true
},
"PUBLISHER": {
"ROUTING_KEY": "qstorm-routing-key",
"CONTENT_TYPE": "application/json",
"DELIVERY_MODE": 2
}
}
},
"STAGES": [
{ "DURATION": "30s", "RATE": 50 }
]
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
NAME |
string | yes | - | Queue name |
DURABLE |
bool | no | false | Queue survives broker restarts. Required for quorum queues |
AUTO_DELETE |
bool | no | false | Queue deleted when last consumer disconnects. Not supported with quorum queues |
EXCLUSIVE |
bool | no | false | Queue only accessible by declaring connection. Not supported with quorum queues |
NO_WAIT |
bool | no | false | Skip server confirmation of declaration |
ARGS |
object | no | nil | Queue arguments. Set "x-queue-type" to "classic", "quorum", or "stream" |
Optional. When NAME is empty, messages are published to the default exchange (routed directly by queue name).
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
NAME |
string | no | "" (default exchange) | Exchange name |
KIND |
string | no | - | Exchange type: direct, fanout, topic, headers |
DURABLE |
bool | no | false | Exchange survives broker restarts |
AUTO_DELETE |
bool | no | false | Exchange deleted when no bindings remain |
INTERNAL |
bool | no | false | Exchange cannot receive publishes directly |
NO_WAIT |
bool | no | false | Skip server confirmation |
ARGS |
object | no | nil | Exchange arguments |
| Field | Type | Default | Description |
|---|---|---|---|
CONFIRM_MODE |
bool | false | Enable publisher confirms. When true, each published message is acknowledged by the broker. Adds overhead but gives accurate delivery success metrics |
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
ROUTING_KEY |
string | yes | - | Routing key. For default exchange, this must be the queue name |
MANDATORY |
bool | no | false | Return unroutable messages instead of silently dropping them |
CONTENT_TYPE |
string | no | "" | MIME type (e.g. application/json). Informational only |
DELIVERY_MODE |
int | no | 0 (transient) | 1 = transient (in-memory, fastest), 2 = persistent (written to disk, survives broker restart) |
PRIORITY |
int | no | 0 | Message priority (0-9). Requires queue with x-max-priority argument |
| Variable | Required | Description |
|---|---|---|
RABBITMQ__URL |
yes | AMQP connection URL |
Local:
RABBITMQ__URL=amqp://guest:guest@localhost:5672TLS:
RABBITMQ__URL=amqps://user:pass@broker.prod:5671See RabbitMQ documentation for more details.
{
"QUEUE": {
"TYPE": "apache-pulsar",
"PAYLOAD": "{\"order_id\": \"{{uuid}}\"}",
"ATTRIBUTES": "{\"SOURCE\": \"qstorm\"}",
"PULSAR": {
"TOPIC": "qstorm-topic",
"PARTITION_KEY": "customer-123",
"PUBLISHER": {
"ORDERING_KEY": "",
"DISABLE_BATCHING": false,
"BATCHING_MAX_PUBLISH_DELAY": "10ms",
"BATCHING_MAX_MESSAGES": 1000,
"BATCHING_MAX_SIZE": 131072
}
}
},
"STAGES": [
{ "DURATION": "30s", "RATE": 50 }
]
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
TOPIC |
string | yes | - | Pulsar topic (e.g. persistent://public/default/my-topic or just my-topic for standalone) |
PARTITION_KEY |
string | no | "" | Partition routing key. Messages with the same key go to the same partition |
All optional. Sensible defaults are used when omitted.
| Field | Type | Default | Description |
|---|---|---|---|
NAME |
string | auto-generated | Producer name. Must be unique per topic if set |
ORDERING_KEY |
string | "" | Overrides partition key for ordering without affecting partition routing |
DISABLE_BATCHING |
bool | false | Send each message immediately (lower throughput, lower latency) |
BATCHING_MAX_PUBLISH_DELAY |
duration | 1ms | Max time to wait before sending a batch |
BATCHING_MAX_MESSAGES |
int | 1000 | Max messages per batch |
BATCHING_MAX_SIZE |
int | 128KB | Max batch size in bytes |
DISABLE_BLOCK_IF_QUEUE_FULL |
bool | false | Return error instead of blocking when send queue is full |
DELIVER_AFTER |
duration | 0 | Delayed delivery. Message is visible to consumers after this delay |
| Variable | Required | Description |
|---|---|---|
PULSAR__URL |
yes | Broker address (pulsar://host:6650 or pulsar+ssl://host:6651 for TLS) |
PULSAR__AUTH_TOKEN |
no | JWT token for authentication |
PULSAR__BASIC_AUTH__USERNAME |
no | Basic auth username |
PULSAR__BASIC_AUTH__PASSWORD |
no | Basic auth password (redacted in logs) |
Local (standalone, no auth):
PULSAR__URL=pulsar://localhost:6650Cloud (StreamNative, etc.):
PULSAR__URL=pulsar+ssl://broker.streamnative.cloud:6651
PULSAR__AUTH_TOKEN=your-jwt-tokenSee Apache Pulsar documentation for more details.
Stages define how traffic changes over time. Each stage has a duration and a rate (messages per second). Stages run sequentially. Use them to model ramp-ups, sustained load, spikes, and cooldowns.
---
config:
xyChart:
xAxis:
label: "Time"
yAxis:
label: "Messages/sec"
---
xychart-beta
title "Ramp -> Sustain -> Spike -> Cooldown"
x-axis ["0s", "30s", "60s", "90s", "120s", "150s", "180s"]
y-axis 0 --> 1000
line [0, 100, 500, 500, 1000, 200, 200]
Template variables are supported in PAYLOAD and ATTRIBUTES only. They are not processed in queue-specific configuration fields (topic, key, partition, etc.) to avoid unexpected behavior.
| Variable | Description | Example |
|---|---|---|
{{uuid}} |
Unique UUID per occurrence | f47ac10b-58cc-4372-a567-0e02b2c3d479 |
{{timestamp}} |
Current UTC time (RFC 3339) | 2026-03-23T14:30:00Z |
Each {{uuid}} in a single message resolves to a different value.
QStorm collects metrics using HDR Histogram for accurate latency percentiles:
- published / failed: total message counts
- errors overview: distinct error messages with occurrence counts
- success_rate / error_rate: as percentages
- publish_latency: avg, p50, p75, p90, p99
- duration: total test time
- Consumer lag metrics (cross-queue)
- Threshold assertions (fail if p99 > Xms or error rate > Y%)
- Result export (JSON, CSV) for CI/CD integration
- Custom template functions (
{{rand_int 1 100}},{{rand_string 10}}) - Apache ActiveMQ support (on request)