Skip to content

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.

VariableDescription
HUB_URLURL of the hub to test.
JWTPublisher JWT.
SUBSCRIBER_JWTSubscriber JWT. Falls back to JWT when private updates are tested.
INITIAL_SUBSCRIBERSConcurrent subscribers connected at the start.
SUBSCRIBERS_RATE_FROM / SUBSCRIBERS_RATE_TORange for additional subscriber connection rate (per second).
PUBLISHERS_RATE_FROM / PUBLISHERS_RATE_TORange for publication rate (per second).
INJECTION_DURATIONHow long the publisher load runs.
CONNECTION_DURATIONHow long subscribers stay connected.
RANDOM_CONNECTION_DURATIONRandomize subscriber lifetime up to CONNECTION_DURATION.
PRIVATE_UPDATESIf 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 your ulimit -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_timeout cuts them off.
  • Matcher complexity. Exact matchers are O(1); regex and CEL matchers cost time per evaluation. Use topic_selector_cache for 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

SymptomProbable cause
accept: too many open files in logsulimit -n too low. Set 100000 or higher on the host.
CPU spent in matcher evaluationRegex/CEL matchers; raise topic_selector_cache.
Dispatch latency rising under loadSlow subscribers; lower dispatch_timeout to bound the impact.
Memory growth that doesn't plateauGoroutine leak; capture a pprof heap and goroutine snapshot (Debugging) and file an issue.
Test plateaus before the box doesBackpressure 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:

#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