Load testing
The Mercure repository ships a Gatling-based load test. Use it to measure your own infrastructure before users do.
For reference, a public benchmark by Glory4Gamers reached 40,000 concurrent connections on a single EC2 t3.micro running the open-source hub. Your numbers will vary with kernel limits, NIC, and publish rate. Don't take 40k as a ceiling; take it as "one node holds a lot."
Run the Mercure Gatling load test
# Run the Mercure Gatling Load Test git clone https://github.com/dunglas/mercure cd mercure/gatling ./mvnw gatling:test
Without configuration, the test hits a local hub on https://localhost. To target a real hub, set HUB_URL and a publisher JWT.
Mercure load test configuration
All variables are optional.
| Variable | Description |
|---|---|
HUB_URL | URL of the hub to test. |
JWT | Publisher JWT. |
SUBSCRIBER_JWT | Subscriber JWT. Falls back to JWT when private updates are tested. |
INITIAL_SUBSCRIBERS | Concurrent subscribers connected at the start. |
SUBSCRIBERS_RATE_FROM / SUBSCRIBERS_RATE_TO | Range for additional subscriber connection rate (per second). |
PUBLISHERS_RATE_FROM / PUBLISHERS_RATE_TO | Range for publication rate (per second). |
INJECTION_DURATION | How long the publisher load runs. |
CONNECTION_DURATION | How long subscribers stay connected. |
RANDOM_CONNECTION_DURATION | Randomize subscriber lifetime up to CONNECTION_DURATION. |
PRIVATE_UPDATES | If set, send private updates with random topics instead of public updates with one topic. |
A useful starting recipe (build up to your expected traffic):
# Mercure Load Test Configuration HUB_URL=https://hub.example.com/.well-known/mercure \ JWT=<publisher JWT> \ INITIAL_SUBSCRIBERS=1000 \ SUBSCRIBERS_RATE_FROM=50 \ SUBSCRIBERS_RATE_TO=200 \ PUBLISHERS_RATE_FROM=10 \ PUBLISHERS_RATE_TO=100 \ INJECTION_DURATION=300 \ CONNECTION_DURATION=600 \ ./mvnw gatling:test
What to measure during a Mercure load test
While the test runs, watch:
mercure_subscribers_connected: should track the configured ramp.CPU and memory of the hub process: establishes the per-subscriber cost on your hardware.
Open file descriptors (
ls /proc/<pid>/fd | wc -l): every subscriber takes one. Compare to yourulimit -n.Publish latency: Caddy request duration histogram on
POST /.well-known/mercure.Subscriber receive latency: built into the Gatling report.
What changes the numbers
Connections themselves are cheap. What scales the cost:
Publish rate x number of matching subscribers per topic. A 1-publish-per-second feed to 100k subscribers is far heavier than 1k publishes per second to 100 subscribers each.
Dispatch timeout. Slow subscribers blocking dispatch eat goroutines until
dispatch_timeoutcuts them off.Matcher complexity. Exact matchers are O(1); URL Pattern matchers cost time per evaluation. Use
topic_matcher_cachefor repeated patterns.History writes. BoltDB syncs to disk; write throughput is bounded by your storage. The Postgres transport is faster on bursty writes; Redis is the fastest.
Common Mercure hub bottlenecks
| Symptom | Probable cause |
|---|---|
accept: too many open files in logs | ulimit -n too low. Set 100000 or higher on the host. |
| CPU spent in matcher evaluation | URL Pattern matchers; raise topic_matcher_cache. |
| Dispatch latency rising under load | Slow subscribers; lower dispatch_timeout to bound the impact. |
| Memory growth that doesn't plateau | Goroutine leak; capture a pprof heap and goroutine snapshot (Debugging) and file an issue. |
| Test plateaus before the box does | Backpressure from the hub's listener; check net.core.somaxconn and net.ipv4.tcp_max_syn_backlog on Linux. |
File descriptor limits for the Mercure hub
The single most common limit. On Linux:
# Per process (the running hub) prlimit --pid $(pgrep mercure) # Set globally for the next process you start ulimit -n 100000 # Persist via systemd # /etc/systemd/system/mercure.service.d/override.conf [Service] LimitNOFILE=100000
In Docker:
# File Descriptor Limits for the Mercure Hub services: mercure: ulimits: nofile: soft: 100000 hard: 100000
In Kubernetes, the host's limit applies to the container by default. If the host is set to 1024, that's your ceiling. Bump it on the node.
Mercure conformance vs. Load testing
Load tests measure throughput. Conformance tests check correctness. Run both:
Load test (this page).
Conformance tests: Playwright suite that verifies the hub follows the protocol.
When to scale beyond one node
Symptoms that mean a single node won't get you any further:
CPU pinned at 100% during normal traffic, no headroom for spikes.
Network bandwidth saturated by fan-out (publish x subscribers per topic exceeds what your NIC can deliver).
You need geographic redundancy, not just headroom.
At that point: High availability.
Next steps for Mercure load testing
Debugging:
pproffor figuring out where the time goes.Health monitoring: what to watch in steady state.
High availability: multi-node options.