Table of Contents
- Nostr Websocket Transport Specification
- 1. Connection Establishment
- Rule 1.1: WebSocket Protocol
- Rule 1.2: URL Normalization
- Rule 1.3: Connection States
- Rule 1.4: Connection Readiness
- Rule 1.5: CORS Requirements (Relay)
- 2. Message Framing
- Rule 2.1: Frame Format
- Rule 2.2: Message Size Limits
- Rule 2.3: Write Safety
- Rule 2.4: Message Parsing
- 3. Client-to-Relay Messages
- Rule 3.1: REQ - Subscribe to Events
- Rule 3.2: CLOSE - End Subscription
- Rule 3.3: EVENT - Publish Event
- Rule 3.4: AUTH - Authenticate
- Rule 3.5: COUNT - Request Count
- 4. Relay-to-Client Messages
- Rule 4.1: EVENT - Deliver Event
- Rule 4.2: EOSE - End of Stored Events
- Rule 4.3: OK - Event Acceptance Response
- Rule 4.4: CLOSED - Subscription Terminated
- Rule 4.5: NOTICE - Human-Readable Message
- Rule 4.6: AUTH - Request Authentication
- Rule 4.7: COUNT - Count Response
- 5. Subscription Lifecycle
- Rule 5.1: Creating Subscriptions
- Rule 5.2: Multiple Subscriptions
- Rule 5.3: Subscription Replacement
- Rule 5.4: Closing Subscriptions
- Rule 5.5: Subscription State Tracking
- 6. Event Publishing
- 7. Connection Health Monitoring
- Rule 7.1: Ping-Pong Protocol (Server Environments)
- Rule 7.2: Application-Level Keep-Alive (Browser)
- Rule 7.3: Read Deadline Management
- Rule 7.4: Idle Connection Timeout
- 8. Error Handling
- Rule 8.1: Connection Errors
- Rule 8.2: Protocol Errors
- Rule 8.3: Rate Limiting
- Rule 8.4: CLOSED Message Handling
- Rule 8.5: Unmatched Message Handling
- 9. Authentication (NIP-42)
- Rule 9.1: AUTH Challenge
- Rule 9.2: AUTH Response
- Rule 9.3: Auth-Required Error Pattern
- Rule 9.4: Authentication State
- 10. Connection Termination
- Optional Features
- Optional: Reconnection Strategy
- Optional Rule 11.1: Exponential Backoff
- Optional Rule 11.2: Subscription Restoration
- Optional Rule 11.3: Event Retry
- Optional Rule 11.4: Extended Backoff for Specific Errors
- Optional: Multi-Relay Coordination
- Optional Rule 12.1: Connection Pooling
- Optional Rule 12.2: Event Deduplication
- Optional Rule 12.3: Subscription Fan-Out
- Optional Rule 12.4: EOSE Batching
- Optional Rule 12.5: Publish Broadcasting
- Optional: Dynamic Filter Updates
- Optional Rule 13.1: Filter Replacement
- Optional Rule 13.2: Filter Change Detection
- Optional Rule 13.3: Observable Filters
- Optional: Message Queuing
- Optional: Performance Optimizations
- Optional Rule 15.1: Fast Event ID Extraction
- Optional Rule 15.2: Subscription Ref-Counting
- Optional Rule 15.3: Event Validation Caching
- Optional Rule 15.4: Trusted Relays
- Optional: Connection Lifecycle Hooks
- Optional Rule 16.1: Connection State Callbacks
- Optional Rule 16.2: Message Interception
- Optional Rule 16.3: Error Callbacks
- Implementation Notes
Nostr Websocket Transport Specification
1. Connection Establishment
A client connects to a relay using the WebSocket protocol. A relay accepts connections and upgrades HTTP requests to WebSocket.
Rule 1.1: WebSocket Protocol
All communication uses WebSocket connections over ws:// or wss:// URLs.
Client behavior:
connection = create_websocket(relay_url)
wait_for_open_event()
Relay behavior:
receive HTTP GET with "Upgrade: websocket" header
validate_connection_request()
if accepted:
upgrade_to_websocket()
else:
return HTTP 429 or appropriate error
Rule 1.2: URL Normalization
Clients must normalize relay URLs before connecting.
Normalization rules:
- Remove trailing slash
- Ensure protocol prefix (
ws://orwss://) - Convert to lowercase for comparison
Example:
Input: WSS://Relay.Example.COM/
Output: wss://relay.example.com
Input: relay.example.com
Output: wss://relay.example.com
Rule 1.3: Connection States
A connection exists in exactly one state at any time:
- DISCONNECTED: No active connection
- CONNECTING: Connection attempt in progress
- CONNECTED: WebSocket open and ready
- CLOSING: Graceful shutdown initiated
State transitions:
DISCONNECTED → (connect) → CONNECTING
CONNECTING → (open_event) → CONNECTED
CONNECTING → (error) → DISCONNECTED
CONNECTED → (disconnect) → CLOSING
CLOSING → (close_complete) → DISCONNECTED
Example client code:
state = DISCONNECTED
connect():
state = CONNECTING
ws.connect()
on_open():
state = CONNECTED
on_close():
state = DISCONNECTED
Rule 1.4: Connection Readiness
Clients must wait for the WebSocket open event before sending messages. Relays must not send messages before the connection is fully established.
Client:
connection_promise = create_connection()
await connection_promise
send_message(["REQ", ...])
Relay:
on_websocket_upgrade():
create_connection_context()
mark_connection_ready()
execute_connect_hooks()
Rule 1.5: CORS Requirements (Relay)
Relays must implement permissive CORS for non-WebSocket HTTP endpoints:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: HEAD, GET, POST, PUT, PATCH, DELETE
Access-Control-Max-Age: 86400
2. Message Framing
Rule 2.1: Frame Format
All messages are WebSocket text frames containing UTF-8 encoded JSON arrays.
Structure:
["MESSAGE_TYPE", argument1, argument2, ...]
The first element identifies the message type. Remaining elements depend on the message type.
Example frame:
["EVENT", "subscription-id-123", {"id": "abc...", "kind": 1, ...}]
Rule 2.2: Message Size Limits
Implementations should enforce maximum message size limits.
Recommended limit: 512,000 bytes
Behavior when exceeded:
- Client: Close connection with error
- Relay: Close connection and remove client
Example enforcement:
on_message(text):
if length(text) > MAX_MESSAGE_SIZE:
close_connection("message too large")
return
process_message(text)
Rule 2.3: Write Safety
Implementations must prevent concurrent writes to the WebSocket.
Pattern:
mutex = create_mutex()
send_message(json_array):
mutex.lock()
websocket.send(JSON.stringify(json_array))
mutex.unlock()
Why this matters: Concurrent writes to a WebSocket cause panic or corruption in most implementations.
Rule 2.4: Message Parsing
Parse the JSON array and extract the message type from the first element.
Process:
on_text_frame(raw_text):
try:
json_array = JSON.parse(raw_text)
message_type = json_array[0]
dispatch_by_type(message_type, json_array)
catch parse_error:
send_notice("invalid JSON")
Unknown message types should trigger a NOTICE but must not terminate the connection.
Example:
Received: ["UNKNOWN_TYPE", "arg1"]
Action: Send ["NOTICE", "unknown message type"]
Keep connection alive
3. Client-to-Relay Messages
Rule 3.1: REQ - Subscribe to Events
Request events matching filter criteria.
Format:
["REQ", <subscription-id>, <filter>, <filter>, ...]
Subscription ID:
- String chosen by client
- Must be unique per connection
- Recommended: alphanumeric, 8-64 characters
Example:
["REQ", "sub-1", {"kinds": [1], "limit": 10}]
Multiple filters example:
["REQ", "sub-2",
{"kinds": [0], "authors": ["abc123..."]},
{"kinds": [1], "#p": ["abc123..."], "limit": 50}
]
Filters within one REQ are combined with OR logic.
Rule 3.2: CLOSE - End Subscription
Stop receiving events for a subscription.
Format:
["CLOSE", <subscription-id>]
Example:
["CLOSE", "sub-1"]
Effect:
- Relay stops sending events for this subscription
- Relay removes subscription from active set
- Client frees associated resources
Rule 3.3: EVENT - Publish Event
Submit an event to the relay for storage and distribution.
Format:
["EVENT", <event-object>]
Example:
["EVENT", {
"id": "4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65",
"pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
"created_at": 1673347337,
"kind": 1,
"tags": [],
"content": "Hello, relay!",
"sig": "908a15e..."
}]
Relay validation:
receive_event(event):
validate_event_structure(event)
validate_event_id(event)
validate_event_signature(event)
if valid:
store_event(event)
forward_to_matching_subscriptions(event)
send_ok_accepted(event.id)
else:
send_ok_rejected(event.id, reason)
Rule 3.4: AUTH - Authenticate
Respond to an authentication challenge.
Format:
["AUTH", <signed-event>]
The signed event must be kind 22242 with specific tags (see Authentication section).
Example:
["AUTH", {
"kind": 22242,
"tags": [
["relay", "wss://relay.example.com"],
["challenge", "abc123..."]
],
"content": "",
...
}]
Rule 3.5: COUNT - Request Count
Request a count of events matching filters without retrieving the events.
Format:
["COUNT", <subscription-id>, <filter>, <filter>, ...]
Example:
["COUNT", "count-1", {"kinds": [1], "authors": ["abc123..."], "since": 1673347337}]
4. Relay-to-Client Messages
Rule 4.1: EVENT - Deliver Event
Send an event matching an active subscription.
Format:
["EVENT", <subscription-id>, <event-object>]
Example:
["EVENT", "sub-1", {
"id": "4376c65d...",
"pubkey": "6e468422...",
"created_at": 1673347337,
"kind": 1,
"tags": [],
"content": "Hello!",
"sig": "908a15e..."
}]
Client processing:
on_event_message(sub_id, event):
subscription = find_subscription(sub_id)
if subscription is null:
return // discard unknown subscription
if already_have_event(event.id):
return // skip duplicate
if not verify_event_signature(event):
return // skip invalid
if matches_filters(event, subscription.filters):
deliver_to_application(event)
Rule 4.2: EOSE - End of Stored Events
Signal completion of stored event delivery for a subscription.
Format:
["EOSE", <subscription-id>]
Example:
["EOSE", "sub-1"]
Message order guarantee:
Client: ["REQ", "sub-1", {...}]
Relay: ["EVENT", "sub-1", {...stored event 1...}]
Relay: ["EVENT", "sub-1", {...stored event 2...}]
Relay: ["EOSE", "sub-1"]
Relay: ["EVENT", "sub-1", {...new real-time event...}]
After EOSE, events for this subscription are real-time only.
Rule 4.3: OK - Event Acceptance Response
Confirm whether a published event was accepted.
Format:
["OK", <event-id>, <true|false>, <message>]
Accepted example:
["OK", "4376c65d...", true, ""]
Rejected example:
["OK", "4376c65d...", false, "invalid: event timestamp too far in future"]
Client behavior:
pending_publishes = Map<event_id, promise>
send_event(event):
promise = create_promise()
pending_publishes.set(event.id, promise)
send(["EVENT", event])
return promise
on_ok_message(event_id, accepted, message):
promise = pending_publishes.get(event_id)
if promise:
if accepted:
promise.resolve()
else:
promise.reject(message)
pending_publishes.delete(event_id)
Rule 4.4: CLOSED - Subscription Terminated
Notify client that relay has ended a subscription.
Format:
["CLOSED", <subscription-id>, <message>]
Example:
["CLOSED", "sub-1", "rate limited: too many subscriptions"]
Client behavior:
on_closed_message(sub_id, message):
subscription = find_subscription(sub_id)
if subscription:
subscription.mark_closed()
invoke_close_callback(message)
remove_subscription(sub_id)
Rule 4.5: NOTICE - Human-Readable Message
Send informational or error messages to the client.
Format:
["NOTICE", <message>]
Example:
["NOTICE", "This relay requires payment for writes"]
NOTICE messages are for human consumption. Clients should log them but must not treat them as protocol errors.
Rule 4.6: AUTH - Request Authentication
Challenge client to authenticate.
Format:
["AUTH", <challenge-string>]
Example:
["AUTH", "a1b2c3d4e5f6g7h8"]
Relay behavior:
on_connect(client):
challenge = generate_random_hex(16)
client.challenge = challenge
send(["AUTH", challenge])
See Authentication section for complete flow.
Rule 4.7: COUNT - Count Response
Return count of events matching COUNT request.
Format:
["COUNT", <subscription-id>, {"count": <integer>}]
Example:
["COUNT", "count-1", {"count": 42}]
5. Subscription Lifecycle
Rule 5.1: Creating Subscriptions
A subscription begins when a client sends REQ and ends when either side sends CLOSE or CLOSED.
Client lifecycle:
subscription_id = generate_unique_id()
subscriptions.set(subscription_id, {
id: subscription_id,
filters: filters,
eosed: false,
closed: false
})
send(["REQ", subscription_id, ...filters])
start_eose_timeout(subscription_id)
Relay lifecycle:
on_req_message(client, sub_id, filters):
subscription = {
id: sub_id,
filters: filters,
client: client
}
active_subscriptions.add(subscription)
stored_events = query_events(filters)
for event in stored_events:
send_to_client(["EVENT", sub_id, event])
send_to_client(["EOSE", sub_id])
// Continue sending real-time matching events
Rule 5.2: Multiple Subscriptions
A client may have multiple active subscriptions on one connection. Each must have a unique subscription ID.
Example:
Client sends:
["REQ", "sub-1", {"kinds": [1], "limit": 10}]
["REQ", "sub-2", {"kinds": [0], "authors": ["abc..."]}]
["REQ", "sub-3", {"kinds": [7], "#e": ["xyz..."]}]
All three subscriptions active simultaneously.
Rule 5.3: Subscription Replacement
Sending REQ with an existing subscription ID replaces the previous subscription.
Behavior:
on_req_message(client, sub_id, new_filters):
if subscription_exists(sub_id):
remove_old_subscription(sub_id)
create_new_subscription(sub_id, new_filters)
query_and_send_events(sub_id, new_filters)
Example:
Client: ["REQ", "sub-1", {"kinds": [1]}]
Relay: [...events...] ["EOSE", "sub-1"]
Client: ["REQ", "sub-1", {"kinds": [1, 6, 7]}]
Relay: [...new events matching updated filters...] ["EOSE", "sub-1"]
The old subscription is implicitly closed.
Rule 5.4: Closing Subscriptions
Client-initiated:
close_subscription(sub_id):
send(["CLOSE", sub_id])
remove_subscription(sub_id)
Relay-initiated:
close_subscription(client, sub_id, reason):
send_to_client(["CLOSED", sub_id, reason])
remove_subscription(sub_id)
Example reasons for relay-initiated closure:
- Rate limit exceeded
- Filter policy violation
- Authentication required
- Relay shutting down
Rule 5.5: Subscription State Tracking
Clients should track whether EOSE has been received for each subscription.
State:
subscription = {
id: "sub-1",
filters: [{...}],
eosed: false, // set to true when EOSE received
closed: false // set to true when CLOSE or CLOSED sent
}
This allows clients to distinguish stored events from real-time events.
6. Event Publishing
Rule 6.1: Publishing Flow
Client:
publish(event):
promise = create_promise()
timeout = set_timeout(PUBLISH_TIMEOUT, () => {
promise.reject("timeout")
})
pending_publishes.set(event.id, {promise, timeout})
send(["EVENT", event])
return promise
Relay:
on_event_message(client, event):
validation_result = validate_and_store(event)
if validation_result.accepted:
forward_to_subscriptions(event)
send_to_client(["OK", event.id, true, ""])
else:
send_to_client(["OK", event.id, false, validation_result.reason])
Rule 6.2: Publish Timeout
Clients should implement timeouts for event publishing.
Recommended timeout: 4000-5000 milliseconds
Behavior:
PUBLISH_TIMEOUT = 4400 // milliseconds
publish_with_timeout(event):
promise = publish(event)
timeout_promise = sleep(PUBLISH_TIMEOUT).then(() => {
throw "publish timeout"
})
return race(promise, timeout_promise)
Rule 6.3: OK Message Matching
Clients must match OK messages to pending publishes by event ID.
Example:
Client publishes:
event_a = {id: "aaa...", ...}
event_b = {id: "bbb...", ...}
Send:
["EVENT", event_a]
["EVENT", event_b]
Receive (order may vary):
["OK", "bbb...", true, ""]
["OK", "aaa...", false, "duplicate: already have this event"]
Match OK to original publish promise by event ID
7. Connection Health Monitoring
Rule 7.1: Ping-Pong Protocol (Server Environments)
In environments with native WebSocket ping support (Node.js, Go, etc.), implementations should use WebSocket ping frames.
Relay:
on_connect(client):
start_ping_timer(client)
ping_timer():
every PING_INTERVAL:
send_websocket_ping()
wait_for_pong(PONG_TIMEOUT)
if no_pong_received:
close_connection("ping timeout")
Recommended timings:
- PING_INTERVAL: 20-30 seconds
- PONG_TIMEOUT: 20-30 seconds
Rule 7.2: Application-Level Keep-Alive (Browser)
Browsers don't expose WebSocket ping/pong. Use application-level heartbeat.
Client pattern:
start_heartbeat():
every HEARTBEAT_INTERVAL:
// Send dummy REQ for impossible event
nonce = random_hex(64)
send(["REQ", "ping-" + nonce, {"ids": [nonce]}])
wait_for_eose(HEARTBEAT_TIMEOUT)
if no_eose_received:
close_connection("heartbeat timeout")
send(["CLOSE", "ping-" + nonce])
The dummy REQ uses an ID that cannot exist (random 64-char hex), so relay responds with immediate EOSE.
Rule 7.3: Read Deadline Management
Relays should set read deadlines on WebSocket connections.
Pattern:
on_connect(client):
set_read_deadline(now() + READ_DEADLINE)
on_message(client, message):
reset_read_deadline(now() + READ_DEADLINE)
on_read_deadline_exceeded(client):
close_connection(client)
Recommended READ_DEADLINE: 60 seconds
Rule 7.4: Idle Connection Timeout
Relays may close idle connections after a period of inactivity.
Optional behavior:
IDLE_TIMEOUT = 30 seconds
on_connect(client):
set_idle_timer(client)
on_any_message(client):
reset_idle_timer(client)
on_idle_timeout(client):
close_connection(client)
This applies to non-WebSocket HTTP connections only. WebSocket connections use ping-pong or read deadline instead.
8. Error Handling
Rule 8.1: Connection Errors
When a WebSocket error occurs, both sides must clean up resources.
Client:
on_websocket_error(error):
log_error(error)
close_all_subscriptions("connection error")
reject_all_pending_publishes("connection error")
set_state(DISCONNECTED)
Relay:
on_websocket_error(client, error):
log_error(error)
close_all_client_subscriptions(client)
remove_client_from_registry(client)
Rule 8.2: Protocol Errors
Invalid messages should trigger NOTICE but not close the connection.
Relay behavior:
on_invalid_json(client, raw_text):
send_to_client(["NOTICE", "invalid JSON"])
// Keep connection alive
on_unknown_message_type(client, message_type):
send_to_client(["NOTICE", "unknown message type: " + message_type])
// Keep connection alive
on_malformed_message(client, error):
send_to_client(["NOTICE", "malformed message: " + error])
// Keep connection alive
Rule 8.3: Rate Limiting
Relays may rate-limit clients at three levels.
Connection level:
on_connection_attempt(ip_address):
if exceeds_connection_rate_limit(ip_address):
return HTTP 429 "Too Many Requests"
accept_connection()
Subscription level:
on_req_message(client, sub_id, filters):
if exceeds_subscription_rate_limit(client):
send(["CLOSED", sub_id, "rate limited: too many subscriptions"])
return
create_subscription(sub_id, filters)
Event level:
on_event_message(client, event):
if exceeds_event_rate_limit(client):
send(["OK", event.id, false, "rate limited: too many events"])
return
store_event(event)
Example rate limits:
- Connections: 1 per IP per 5 minutes
- Subscriptions: 20 per client per minute
- Events: 2 per client per 3 minutes
Rule 8.4: CLOSED Message Handling
When a client receives CLOSED, it must stop expecting events for that subscription.
Client:
on_closed_message(sub_id, reason):
subscription = subscriptions.get(sub_id)
if subscription:
subscription.closed = true
clear_eose_timeout(sub_id)
invoke_close_callback(reason)
subscriptions.delete(sub_id)
Common CLOSED reasons:
"rate limited: too many subscriptions"
"invalid: filter too broad"
"auth-required: this relay requires authentication"
"error: internal relay error"
Rule 8.5: Unmatched Message Handling
Clients should ignore messages for unknown subscription IDs.
Pattern:
on_event_message(sub_id, event):
if not subscriptions.has(sub_id):
// Silently discard - subscription may have been closed
return
process_event(sub_id, event)
on_eose_message(sub_id):
if not subscriptions.has(sub_id):
return
mark_eosed(sub_id)
Do not log warnings or errors for unmatched messages. They may arrive after CLOSE due to network timing.
9. Authentication (NIP-42)
Rule 9.1: AUTH Challenge
Relays may require authentication for certain operations.
Relay sends challenge:
on_connect(client):
challenge = random_hex(16)
client.auth_challenge = challenge
send_to_client(["AUTH", challenge])
Example:
["AUTH", "a1b2c3d4e5f6g7h8"]
Rule 9.2: AUTH Response
Clients respond with a signed kind 22242 event.
Event structure:
{
"kind": 22242,
"tags": [
["relay", <relay-url>],
["challenge", <challenge-string>]
],
"content": "",
"created_at": <current-timestamp>,
"pubkey": <client-pubkey>,
"id": <computed-id>,
"sig": <signature>
}
Example:
["AUTH", {
"kind": 22242,
"tags": [
["relay", "wss://relay.example.com"],
["challenge", "a1b2c3d4e5f6g7h8"]
],
"content": "",
"created_at": 1673347337,
"pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
"id": "f8c...",
"sig": "a3d..."
}]
Relay validation:
on_auth_message(client, signed_event):
if signed_event.kind != 22242:
send(["OK", signed_event.id, false, "error: wrong event kind"])
return
if not verify_signature(signed_event):
send(["OK", signed_event.id, false, "error: invalid signature"])
return
challenge_tag = find_tag(signed_event.tags, "challenge")
if challenge_tag[1] != client.auth_challenge:
send(["OK", signed_event.id, false, "error: challenge mismatch"])
return
client.authenticated_pubkey = signed_event.pubkey
send(["OK", signed_event.id, true, ""])
Rule 9.3: Auth-Required Error Pattern
Relays indicate authentication requirements via OK or CLOSED messages prefixed with "auth-required:".
For events:
["OK", <event-id>, false, "auth-required: this relay requires authentication for writes"]
For subscriptions:
["CLOSED", <sub-id>, "auth-required: authentication required for this subscription"]
Client retry pattern:
on_ok_rejected(event_id, message):
if message.starts_with("auth-required:"):
auth_event = create_auth_event(relay.challenge)
send(["AUTH", auth_event])
wait_for_auth_ok()
// Retry original event
send(["EVENT", original_event])
on_closed_message(sub_id, message):
if message.starts_with("auth-required:"):
auth_event = create_auth_event(relay.challenge)
send(["AUTH", auth_event])
wait_for_auth_ok()
// Retry original subscription
send(["REQ", sub_id, ...original_filters])
Rule 9.4: Authentication State
Authentication persists for the connection lifetime.
Client tracking:
relay_state = {
challenge: "a1b2c3d4e5f6g7h8",
authenticated: false,
authenticated_pubkey: null
}
on_auth_ok_received():
relay_state.authenticated = true
relay_state.authenticated_pubkey = my_pubkey
Relay tracking:
client_state = {
challenge: "a1b2c3d4e5f6g7h8",
authenticated: false,
pubkey: null
}
on_valid_auth(pubkey):
client_state.authenticated = true
client_state.pubkey = pubkey
10. Connection Termination
Rule 10.1: Graceful Shutdown
Both sides should attempt graceful shutdown when possible.
Client:
disconnect():
for subscription in active_subscriptions:
send(["CLOSE", subscription.id])
send_websocket_close_frame()
wait_for_close_confirmation(timeout=1000ms)
close_websocket()
Relay:
shutdown():
for client in active_clients:
for subscription in client.subscriptions:
send_to_client(["CLOSED", subscription.id, "relay shutting down"])
send_websocket_close_frame(client)
wait_for_close_confirmation(timeout=1000ms)
close_connection(client)
Rule 10.2: Resource Cleanup
All resources must be freed on disconnection.
Client cleanup:
on_disconnect():
// Clear all subscriptions
for subscription in subscriptions.values():
subscription.closed = true
invoke_close_callback("connection closed")
subscriptions.clear()
// Reject pending publishes
for publish in pending_publishes.values():
publish.promise.reject("connection closed")
pending_publishes.clear()
// Clear timers
clear_all_timeouts()
// Reset state
state = DISCONNECTED
authenticated = false
Relay cleanup:
on_client_disconnect(client):
// Remove all subscriptions
for subscription in client.subscriptions:
remove_from_subscription_index(subscription)
client.subscriptions.clear()
// Remove from client registry
clients.remove(client.id)
// Cancel context
client.context.cancel()
// Close socket
client.websocket.close()
Rule 10.3: Close Frames
Use standard WebSocket close codes.
Common close codes:
- 1000: Normal closure
- 1001: Going away (client navigating away, server shutting down)
- 1002: Protocol error
- 1011: Unexpected condition (internal error)
Example:
close_connection(code, reason):
send_close_frame(code, reason)
wait_for_close_ack()
close_socket()
Optional Features
Optional: Reconnection Strategy
Clients may implement automatic reconnection after disconnection.
Optional Rule 11.1: Exponential Backoff
Use exponential backoff with a maximum delay.
Algorithm:
INITIAL_DELAY = 250 // milliseconds
MAX_DELAY = 16000 // milliseconds
attempt_count = 0
reconnect():
delay = min(INITIAL_DELAY * (2 ** attempt_count), MAX_DELAY)
sleep(delay)
try_connect()
if success:
attempt_count = 0
else:
attempt_count += 1
Example progression:
Attempt 0: 250ms
Attempt 1: 500ms
Attempt 2: 1000ms
Attempt 3: 2000ms
Attempt 4: 4000ms
Attempt 5: 8000ms
Attempt 6+: 16000ms (capped)
Optional Rule 11.2: Subscription Restoration
Reestablish subscriptions after reconnection.
Pattern:
on_reconnect_success():
for subscription in saved_subscriptions:
send(["REQ", subscription.id, ...subscription.filters])
Clients should track active subscriptions separately from connection state to enable restoration.
Optional Rule 11.3: Event Retry
Retry failed event publishes after reconnection.
Pattern:
outbox = [] // Failed events pending retry
publish(event):
try:
send(["EVENT", event])
wait_for_ok()
catch error:
outbox.push(event)
on_reconnect_success():
for event in outbox:
try:
send(["EVENT", event])
wait_for_ok()
remove_from_outbox(event)
catch error:
// Keep in outbox for next attempt
Optional Rule 11.4: Extended Backoff for Specific Errors
Certain errors warrant longer backoff periods.
Extended backoff triggers:
- HTTP 403 Forbidden
- HTTP 410 Gone
- HTTP 502 Bad Gateway
- HTTP 503 Service Unavailable
Pattern:
on_connection_error(error):
if error.code in [403, 410, 502, 503]:
delay = EXTENDED_BACKOFF // e.g., 5 minutes
else:
delay = calculate_exponential_backoff()
sleep(delay)
retry_connection()
Optional: Multi-Relay Coordination
Clients may connect to multiple relays simultaneously.
Optional Rule 12.1: Connection Pooling
Reuse relay connections across operations.
Pattern:
relay_pool = Map<url, relay_connection>
get_relay(url):
if relay_pool.has(url):
return relay_pool.get(url)
connection = create_connection(url)
relay_pool.set(url, connection)
return connection
on_relay_disconnect(url):
relay_pool.delete(url)
Optional Rule 12.2: Event Deduplication
Track which events have been seen across relays.
Pattern:
seen_events = Set<event_id>
on_event(relay, event):
if seen_events.has(event.id):
return // Skip duplicate
seen_events.add(event.id)
process_event(event)
With relay tracking:
event_sources = Map<event_id, Set<relay_url>>
on_event(relay_url, event):
if not event_sources.has(event.id):
event_sources.set(event.id, new Set())
process_event(event)
event_sources.get(event.id).add(relay_url)
Optional Rule 12.3: Subscription Fan-Out
Subscribe to the same filters on multiple relays.
Pattern:
subscribe_multi(relay_urls, filters):
subscription_id = generate_id()
for url in relay_urls:
relay = get_relay(url)
relay.send(["REQ", subscription_id, ...filters])
return subscription_id
All relays use the same subscription ID for simplified management.
Optional Rule 12.4: EOSE Batching
Wait for EOSE from all relays before marking complete.
Pattern:
multi_subscription = {
id: "sub-1",
relay_urls: ["wss://relay1.com", "wss://relay2.com", "wss://relay3.com"],
eose_received: Set()
}
on_eose(relay_url, sub_id):
multi_subscription.eose_received.add(relay_url)
if multi_subscription.eose_received.size == multi_subscription.relay_urls.length:
invoke_complete_callback()
Optional Rule 12.5: Publish Broadcasting
Publish events to multiple relays and collect results.
Pattern:
publish_multi(relay_urls, event):
promises = []
for url in relay_urls:
relay = get_relay(url)
promise = relay.publish(event)
promises.push(promise)
return Promise.all(promises)
With partial success handling:
publish_multi(relay_urls, event):
results = []
for url in relay_urls:
relay = get_relay(url)
try:
await relay.publish(event)
results.push({url: url, success: true})
catch error:
results.push({url: url, success: false, error: error})
return results
Optional: Dynamic Filter Updates
Clients may update subscription filters without closing and reopening.
Optional Rule 13.1: Filter Replacement
Send REQ with same subscription ID but new filters.
Pattern:
subscription = {
id: "sub-1",
filters: [{"kinds": [1], "limit": 10}]
}
update_filters(new_filters):
subscription.filters = new_filters
send(["REQ", subscription.id, ...new_filters])
// Relay treats as new subscription with same ID
Optional Rule 13.2: Filter Change Detection
Optimize by avoiding resend when filters haven't meaningfully changed.
Changes that warrant resend:
- Different event kinds
- Different authors
- Different tag filters
sincetimestamp moved backwarduntiltimestamp changedlimitchanged
Changes that may skip resend:
sincetimestamp moved forward (natural progression)
Example:
should_resend_req(old_filters, new_filters):
if old_filters.kinds != new_filters.kinds:
return true
if old_filters.authors != new_filters.authors:
return true
if old_filters["#e"] != new_filters["#e"]:
return true
if new_filters.since < old_filters.since:
return true // Looking backward in time
if new_filters.until != old_filters.until:
return true
return false // Only `since` moved forward
Optional Rule 13.3: Observable Filters
Use reactive primitives to automatically update subscriptions.
Pattern:
filters_observable = BehaviorSubject([{"kinds": [1]}])
subscribe_reactive(relay, sub_id, filters_observable):
filters_observable.subscribe(filters => {
relay.send(["REQ", sub_id, ...filters])
})
// Later, update filters:
filters_observable.next([{"kinds": [1, 6, 7]}])
// Subscription automatically updates
Optional: Message Queuing
Clients may queue incoming messages to prevent blocking.
Optional Rule 14.1: Asynchronous Processing
Process messages in a non-blocking queue.
Pattern:
message_queue = []
processing = false
on_websocket_message(raw_text):
message_queue.push(raw_text)
if not processing:
start_queue_processor()
async start_queue_processor():
processing = true
while message_queue.length > 0:
message = message_queue.shift()
process_message(message)
await yield_thread() // Allow other tasks to run
processing = false
Optional Rule 14.2: Priority Queuing
Process certain message types before others.
Priority order:
- EOSE (completes queries)
- OK (completes publishes)
- CLOSED (frees resources)
- EVENT (bulk of traffic)
- NOTICE (informational only)
Pattern:
priority_queues = {
high: [], // EOSE, OK, CLOSED
normal: [], // EVENT
low: [] // NOTICE
}
on_message(raw_text):
message_type = extract_type(raw_text)
priority = get_priority(message_type)
priority_queues[priority].push(raw_text)
process_queues():
while has_messages():
if priority_queues.high.length > 0:
process(priority_queues.high.shift())
else if priority_queues.normal.length > 0:
process(priority_queues.normal.shift())
else if priority_queues.low.length > 0:
process(priority_queues.low.shift())
Optional: Performance Optimizations
Optional Rule 15.1: Fast Event ID Extraction
Extract event ID before full JSON parsing for early deduplication.
Pattern:
on_event_message(raw_text):
// Fast path: extract ID via regex before parsing
if raw_text.starts_with('["EVENT"'):
event_id = extract_hex64(raw_text, '"id"') // regex scan
if already_have_event(event_id):
return // Skip expensive JSON.parse()
// Slow path: parse full JSON
[type, sub_id, event] = JSON.parse(raw_text)
process_event(sub_id, event)
Regex pattern:
/"id"\s*:\s*"([0-9a-f]{64})"/
Optional Rule 15.2: Subscription Ref-Counting
Share underlying subscriptions for identical requests.
Pattern:
active_reqs = Map<sub_id, {count: number, filters: Filter[]}>
subscribe(sub_id, filters):
if active_reqs.has(sub_id):
active_reqs.get(sub_id).count += 1
return // Don't send duplicate REQ
active_reqs.set(sub_id, {count: 1, filters: filters})
send(["REQ", sub_id, ...filters])
unsubscribe(sub_id):
req = active_reqs.get(sub_id)
req.count -= 1
if req.count == 0:
send(["CLOSE", sub_id])
active_reqs.delete(sub_id)
Optional Rule 15.3: Event Validation Caching
Cache signature verification results.
Pattern:
verified_events = Map<event_id, boolean>
verify_event(event):
if verified_events.has(event.id):
return verified_events.get(event.id)
is_valid = verify_signature(event)
verified_events.set(event.id, is_valid)
return is_valid
Cache eviction:
MAX_CACHE_SIZE = 10000
if verified_events.size > MAX_CACHE_SIZE:
// Evict oldest entries
remove_oldest_entries(1000)
Optional Rule 15.4: Trusted Relays
Skip signature verification for known-good relays.
Pattern:
trusted_relay_urls = Set([
"wss://relay1.example.com",
"wss://relay2.example.com"
])
on_event(relay_url, event):
if trusted_relay_urls.has(relay_url):
process_event(event) // Skip verification
else:
if verify_event(event):
process_event(event)
Optional: Connection Lifecycle Hooks
Implementations may expose hooks for monitoring and extension.
Optional Rule 16.1: Connection State Callbacks
Client hooks:
on_connecting():
// Connection attempt started
show_loading_indicator()
on_connected():
// WebSocket open, ready to communicate
hide_loading_indicator()
restore_subscriptions()
on_disconnecting():
// Graceful shutdown initiated
show_reconnecting_message()
on_disconnected():
// Connection closed
clear_ui_state()
schedule_reconnection()
Optional Rule 16.2: Message Interception
Relay hooks:
before_event_store(event):
// Validate custom rules
if not meets_relay_policy(event):
return reject("policy violation")
return accept()
after_event_store(event):
// Trigger side effects
update_search_index(event)
notify_external_systems(event)
before_subscription_create(filters):
// Validate or modify filters
if too_broad(filters):
return reject("filter too broad")
return accept()
Optional Rule 16.3: Error Callbacks
Client hooks:
on_error(error):
log_to_monitoring(error)
show_user_message(error)
on_notice(message):
log_relay_notice(message)
on_closed(sub_id, reason):
log_subscription_closure(sub_id, reason)
show_user_notification(reason)
Implementation Notes
Concurrency Models
This specification is compatible with multiple concurrency approaches:
Callback-based:
relay.on('event', (sub_id, event) => { ... })
relay.on('eose', (sub_id) => { ... })
Promise-based:
const ok = await relay.publish(event)
const events = await relay.query(filters)
Observable-based:
relay.subscribe(filters).subscribe(event => { ... })
Async iterator:
for await (const event of relay.subscribe(filters)) { ... }
Choose based on language and platform conventions.
Memory Management
Implementations must prevent memory leaks:
- Remove closed subscriptions from registries
- Clear timeouts when operations complete or connections close
- Limit cache sizes for deduplication and validation
- Clean up on disconnect - subscriptions, pending publishes, timers
Thread Safety
If using threads or processes:
- Protect WebSocket writes with mutex
- Guard shared state (subscription maps, event caches)
- Use message passing between connection handler and application logic
Platform Considerations
Browser:
- No native WebSocket ping-pong access
- Use application-level heartbeat (dummy REQ)
- Be aware of connection limits (typically 6-30 per domain)
Node.js:
- Native ping-pong available via
wslibrary - Can handle thousands of concurrent connections
- Use clustering for horizontal scaling
Mobile:
- Connections may break when app backgrounds
- Implement reconnection on app foreground
- Consider battery impact of keep-alive frequency
Server (Relay):
- Must handle high concurrent connection counts
- Implement connection limits and rate limits
- Use efficient data structures (swap-delete for O(1) removal)