Relays are judged at the socket layer. A Nostr relay lives or dies by two numbers: how many events per second it can accept from publishers, and how many live deliveries per second it can push to subscribers without dropping one.
I build nostrfy, a Nostr relay written in Rust. Instead of arguing about performance, I ran its bundled WebSocket load client against a release build on a laptop-class dev container and wrote down every number. Then I'll show the exact code that makes those numbers possible.
The results first
Machine: 8 vCPU Intel i5-8265U @ 1.60 GHz, 7.4 GiB RAM, Fedora, 64-bit Linux. Release build (cargo build --release), all traffic over loopback WebSocket, no TLS, no proxy, default limits except the per-IP cap raised to 128 for the fan-out scenarios.
| Scenario | Load | Result |
|---|---|---|
| Single publisher | 2,000 events | 22,912 ev/s (all OK) |
| Single publisher | 10,000 events, warm DB | 9,606 ev/s (all OK) |
| 32 concurrent publishers | 16,000 events | 12,685 ev/s aggregate |
| Fan-out | 120 subscribers x 500 events | 60,000 deliveries in 0.17 s (~353k deliveries/s) |
| Fan-out | 120 subscribers x 2,000 events | 240,000 deliveries in 0.87 s (~276k deliveries/s) |
Stored REQ
|
1,000 events returned | 0.022 s |
| NIP-50 search | 500 events returned | 0.017 s |
And the counters after the whole session, which is where the real story is:
{"connections":{"active":0,"total":158},"subscriptions":{"active":0,"total":122},"events":{"received":32000,"accepted":32000,"rejected":0,"duplicate":0},"buffers_dropped":0,"db_errors":0,"messages":{"in":32122,"out":273795},"bytes":{"in":11526398,"out":91101699},"db_size_bytes":50581504}
32,000 events accepted, zero rejects, zero duplicates, zero dropped buffers, zero database errors — while pushing 240,000 live deliveries plus 273,795 WebSocket messages out in total. In a relay, correctness under load is the performance feature. A 276k/s fan-out that silently loses 1% is a 2.7k/s loss channel that clients cannot see.
Two caveats, stated honestly: this is loopback on a shared container, so treat the numbers as a reproducible floor, not a benchmark crown; and the ingest rate is bounded by durable writes, not by the socket. On a machine with real NVMe and a dedicated core, the same shape scales up. The commands are at the end so you can run this on your own hardware.
Now, why it holds up.
1. Connection accounting that survives the WebSocket upgrade
The first thing that breaks under connection floods is not throughput, it is accounting. A WebSocket connection is an HTTP request that outlives the HTTP task: when hyper hands the upgraded socket to a detached task, the accept task exits. If its connection slot is released at that moment, thousands of live sockets become invisible to the global and per-IP caps.
nostrfy transfers ownership of the slot instead. src/conn.rs is a tiny CAS state machine where the handover and the release race, and exactly one side wins:
pub(crate) fn handover(self: &Arc<Self>) -> Option<ConnSlotGuard> {
if self
.state
.compare_exchange(PENDING, OWNED, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
Some(ConnSlotGuard { slot: Arc::clone(self) })
} else {
None
}
}
fn release_unless_handed_over(&self) {
if self
.state
.compare_exchange(PENDING, RELEASED, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
self.release();
}
}
The upgraded WebSocket task holds the guard for its entire life; the HTTP task's guard becomes a no-op. One connection, one slot, released once — even across panics. Without this, capacity leaks and your "10,000 connection" relay slowly stops accepting at 4,000.
2. Backpressure that is visible instead of silent
Slow readers are not special: mobile clients, background tabs, and rate-limited readers all stop draining. The relay cannot block on them and cannot buffer forever.
Every outgoing queue is bounded by count (OUT_QUEUE_LIMIT = 4096 frames) and bytes (limits.max_out_queue_bytes, default 256 KiB per connection):
// src/ws/mod.rs
pub(crate) fn send_tagged(&mut self, msg: Message, event_sub: Option<u64>) -> bool {
let size = message_size(&msg);
let over_byte_cap = self.out_queue_bytes > 0
&& !self.outgoing.is_empty()
&& self.out_bytes.saturating_add(size) > self.out_queue_bytes;
if self.outgoing.len() >= OUT_QUEUE_LIMIT || over_byte_cap {
self.dropped += 1;
self.relay.stats.bump(&self.relay.stats.buffers_dropped, 1);
return false;
}
self.out_bytes += size;
self.out_msgs += 1;
self.out_bytes_total += size as u64;
self.outgoing.push_back(OutFrame { message: msg, event_sub });
true
}
Two policies keep this from being a silent loss channel:
-
Control frames are different. A dropped live event is recoverable; a dropped
EOSEorCLOSEDleaves a client hanging forever. So completion-critical frames (send_control) bypass the byte cap, while still respecting a last-resort count cap against inbound frame floods. -
Overflow closes the connection. When a live-delivery queue overflows, the relay signals the client with
CLOSEDso it reconnects and resyncs instead of living with an invisible gap. The measured run hadbuffers_dropped: 0, but the failure mode matters more than the happy path.
Explicit failure beats silent loss. Clients can only resync if they know they are out of sync.
3. Fan-out that scales with subscriptions, not connections
Broadcasting every event to every connection is the classic relay bottleneck. With 158 connections and 240,000 deliveries, most of that work would be "no".
nostrfy keeps a subscription index (src/relay/index.rs): kinds, authors, and tag constraints map to connection ids, plus a set for match-everything filters. The live bus wakes candidates only:
// src/relay/mod.rs — the live batching task
let conns = {
let index = sub_index.read().unwrap_or_else(|p| p.into_inner());
let mut conns = std::collections::HashSet::new();
for (event, _) in batch.iter() {
index.extend_candidates(event, &mut conns);
}
conns
};
Three properties make the measured 276k deliveries/s possible:
-
Batching. Events are collected for
live_batch_interval_ms(20 ms) orlive_batch_size(32), so a burst becomes a handful of fan-out passes. -
Per-connection registration. A re-
REQorCLOSEupdates only that connection's entries, instead of walking the whole index under a write lock and stalling live delivery for everyone. - The index only narrows. The per-connection filter match remains the final check, so a fuzzy candidate can never leak an event that should not be visible.
4. Bounded memory for stored queries
Streaming a stored REQ response is where relays quietly allocate hundreds of megabytes. "Collect all matches, then send" is easy and wrong.
nostrfy moves a response into the capped outgoing queue in chunks; the remainder stays in a PendingReq. On top of the per-response budget (max_req_response_bytes, default 32 MiB) there is a relay-wide budget of 16x that (512 MiB), shared across connections:
// src/ws/mod.rs — relay-wide pending-response budget
pub(crate) fn try_reserve(&self, bytes: u64, limit: u64) -> Option<u64> {
if bytes == 0 || limit == 0 {
return Some(0);
}
let mut current = self.used.load(Ordering::Relaxed);
loop {
if current.saturating_add(bytes) > limit {
return None; // caller refuses with a retryable CLOSED
}
match self.used.compare_exchange_weak(
current, current + bytes, Ordering::Relaxed, Ordering::Relaxed,
) {
Ok(_) => return Some(bytes),
Err(actual) => current = actual,
}
}
}
The reservation is held by an RAII guard, so it is released on completion, replacement, CLOSE, disconnect, or panic. Per-connection caps alone would not stop 10,000 connections from each pinning 128 MiB — the global counter is what makes the 30,700-event session above run in a 7 GiB container.
The REQ number (1,000 events in 22 ms) also comes from keeping the database off the socket: a single writer thread batches puts into one LMDB transaction per drain, readers run on their own threads, and OK is only sent after a successful commit.
5. Slow peers get a deadline, not patience
Every wait a client can influence has an upper bound:
-
limits.http_read_timeout_secs(default 30) reaps sockets that never complete a request head — the relay listener deliberately speaks HTTP/1 so this timer always applies. - The idle deadline is measured from the last inbound frame, so pings and live deliveries cannot keep a dead peer alive. With
ws_idle_timeout_secs(default 300), the relay sends aPINGeverymax(idle/3, 5s), and adds up to 2 seconds of jitter so a simultaneous cohort does not time out together. - Final flush and close are wrapped in a timeout, because a peer that stops reading can park
send()forever — and a task that never ends never releases its slot.
This is what keeps a capacity of 10,000 declared connections from becoming 10,000 stuck tasks.
6. Negentropy: the socket-native sync
NIP-77 negentropy is the other WebSocket-heavy workload. Instead of shipping every event id, peers exchange range fingerprints over NEG-OPEN/NEG-MSG/NEG-CLOSE and transfer only differences. nostrfy bounds rounds (MAX_NEG_MSG_ROUNDS = 128), concurrent opens per connection (MAX_NEG_OPENS = 256), ids per message, and total items — and routes every id list through the same relay-wide memory budget. A sync client that stops reading gets backpressure, not an OOM.
Reproduce it yourself
git clone https://github.com/iqbqioza/nostrfy
cd nostrfy
cargo build --release
./target/release/nostrfy --config nostrfy.toml start --foreground
# in another shell (release mode is required; the signer is the bottleneck otherwise)
cargo run --release --example bench -- ws://127.0.0.1:18999 ingest 10000
cargo run --release --example bench -- ws://127.0.0.1:18999 parallel-ingest 32 500
cargo run --release --example bench -- ws://127.0.0.1:18999 fanout 120 2000
cargo run --release --example bench -- ws://127.0.0.1:18999 req 2000
cargo run --release --example bench -- ws://127.0.0.1:18999 search yourterm
Raise limits.max_connections_per_ip if you want more than 64 subscribers (the relay refuses the rest by design, which is itself worth watching). The load client signs events with the same secp256k1 crate the relay uses, so it measures the relay rather than a Python signer.
The takeaway
A relay's WebSocket layer is not a while let Some(msg) loop. It is connection accounting that survives upgrades, backpressure that fails visibly, a fan-out index that wakes only interested subscribers, and memory budgets that are global rather than per-connection. Get those four right and a quarter million live deliveries per second on a laptop CPU stops being surprising.
The code is small enough to read: src/ws/, src/conn.rs, and src/relay/index.rs. The benchmark above is in the repository, so the numbers are yours to check.