How Buzz Controls Concurrency with Connection and Handler Semaphores

Buzz uses two Tokio semaphores stored in AppState to enforce back-pressure: a connection semaphore that caps active WebSocket sockets and a handler semaphore that limits concurrent EVENT/REQ processing, returning 503 errors when either limit is reached.

The block/buzz Nostr relay implementation manages high-load scenarios through a dual-layer semaphore system that separates network capacity from processing capacity. By isolating connection limits from message handler limits, the relay prevents resource exhaustion while maintaining long-lived WebSocket connections typical of the Nostr protocol.

Connection Semaphore: Capping Active WebSockets

The conn_semaphore restricts the total number of simultaneous WebSocket connections to prevent memory and network stack overflow. This semaphore is created with a fixed capacity based on the max_connections configuration value.

Creation in AppState

In crates/buzz-relay/src/state.rs, lines 55–58, both semaphores initialize during AppState::new:

// state.rs lines 55-58
let conn_semaphore = Arc::new(Semaphore::new(config.max_connections));
let handler_semaphore = Arc::new(Semaphore::new(config.max_concurrent_handlers));

The semaphore is stored as an Arc<Semaphore> within the shared AppState struct, allowing it to be cloned cheaply for each incoming connection attempt.

Permit Acquisition During Handshake

When a client initiates a WebSocket upgrade, crates/buzz-relay/src/connection.rs at line 159 attempts to acquire an owned permit:

let permit = match state.conn_semaphore.clone().try_acquire_owned() {
    Ok(p) => p,
    Err(_) => {
        // Semaphore exhausted — reject before reading request data
        return Err(Response::with_status(StatusCode::SERVICE_UNAVAILABLE));
    }
};
let connection = Connection { permit, /* ... */ };

If acquisition succeeds, the owned permit is stored inside the Connection struct and held for the socket's entire lifetime. When the connection closes, the permit drops automatically and returns the slot to the semaphore. If the semaphore is exhausted, the handshake aborts immediately with a 503 Service Unavailable response.

Handler Semaphore: Throttling Event Processing

While the connection semaphore manages socket count, the handler_semaphore governs how many EVENT or REQ messages process simultaneously across all connections. This protects CPU, database, and Redis resources from being overwhelmed by bursts of Nostr events.

Per-Request Permit Acquisition

Inside crates/buzz-relay/src/connection.rs, each inbound request handler acquires a separate permit before executing business logic. The analysis references lines 571, 599, and 621 where the code attempts to acquire handler permits:

let _handler_permit = match state.handler_semaphore.clone().try_acquire_owned() {
    Ok(p) => p,
    Err(_) => {
        // Too many handlers running — signal busy state
        return Err(Response::with_status(StatusCode::SERVICE_UNAVAILABLE));
    }
};
// ... execute DB queries, Redis updates, or media processing ...

The permit is held only for the duration of request processing. Once the handler completes—successfully or with an error—the permit drops and frees capacity for the next message.

Back-Pressure Behavior

If the handler semaphore is full, the relay returns a 503-style "busy" message to the client immediately. This back-pressure mechanism prevents the relay from queuing unlimited tasks, ensuring that admitted work completes within predictable time bounds. Idle connections remain open even when the handler semaphore is saturated, allowing clients to wait for capacity without reconnecting.

Configuration and Resource Tuning

Both semaphore limits are configurable via the relay's TOML configuration or environment variables:

  • max_connections: Sets the conn_semaphore capacity. Increasing this allows more simultaneous sockets but consumes more memory for connection state.
  • max_concurrent_handlers: Sets the handler_semaphore capacity. Raising this permits more parallel database and CPU work but increases load on downstream resources.

These values are fixed at startup when AppState::new executes; changes require a relay restart. Operators should tune these based on available RAM, database connection pool size, and CPU cores.

Summary

  • conn_semaphore: Created in state.rs with capacity max_connections; acquired at WebSocket handshake in connection.rs line 159; released automatically on disconnect to cap total socket count.
  • handler_semaphore: Created alongside the connection semaphore with capacity max_concurrent_handlers; acquired per-message at lines 571, 599, and 621 in connection.rs to limit active EVENT/REQ processing.
  • Back-pressure: Both semaphores return 503 Service Unavailable when exhausted, preventing resource exhaustion without terminating existing connections.
  • Configuration: Limits are set via max_connections and max_concurrent_handlers and loaded during AppState initialization.

Frequently Asked Questions

What happens when the connection semaphore is full?

When conn_semaphore has no available permits, try_acquire_owned() returns an error and the relay aborts the WebSocket handshake immediately with a 503 Service Unavailable status. This rejection occurs before any request data is read, protecting the server from accepting more sockets than it can sustain.

How does the handler semaphore protect the database?

The handler semaphore limits concurrent access to database queries, Redis operations, and media processing. By capping active handler tasks with max_concurrent_handlers, the relay prevents CPU and I/O saturation that would slow response times for all clients, effectively isolating processing load from the number of open connections.

Can these semaphore limits be changed at runtime?

No, both semaphores are initialized once during AppState::new at startup based on configuration values. The underlying tokio::sync::Semaphore does not support resizing, so changes to max_connections or max_concurrent_handlers require a relay restart to take effect.

Why does Buzz use two separate semaphores instead of one?

Separating connection limits from processing limits allows the relay to maintain many idle WebSocket connections (typical for Nostr's long-lived architecture) while restricting active work. This design prevents a burst of incoming EVENT messages from overwhelming the database, providing independent control over network file descriptors versus compute and I/O resources.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →