1
[Spec] Nostr Transport Specification
jay edited this page 2025-12-30 09:59:12 -05:00

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:// or wss://)
  • 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
  • since timestamp moved backward
  • until timestamp changed
  • limit changed

Changes that may skip resend:

  • since timestamp 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:

  1. EOSE (completes queries)
  2. OK (completes publishes)
  3. CLOSED (frees resources)
  4. EVENT (bulk of traffic)
  5. 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:

  1. Remove closed subscriptions from registries
  2. Clear timeouts when operations complete or connections close
  3. Limit cache sizes for deduplication and validation
  4. Clean up on disconnect - subscriptions, pending publishes, timers

Thread Safety

If using threads or processes:

  1. Protect WebSocket writes with mutex
  2. Guard shared state (subscription maps, event caches)
  3. 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 ws library
  • 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)