Websocket Transport Specification
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 errorRule 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.comRule 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) → DISCONNECTEDExample client code:
state = DISCONNECTED
connect():
state = CONNECTING
ws.connect()
on_open():
state = CONNECTED
on_close():
state = DISCONNECTEDRule 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: 864002. 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 alive3. 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 eventsRule 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# Nostr Client-Relay 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 = createwebsocket(relayurl) waitforopen_event()
**Relay behavior:**
receive HTTP GET with "Upgrade: websocket" header validateconnectionrequest() if accepted: upgradetowebsocket() 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 → (openevent) → CONNECTED CONNECTING → (error) → DISCONNECTED CONNECTED → (disconnect) → CLOSING CLOSING → (closecomplete) → 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:**
connectionpromise = createconnection() await connectionpromise sendmessage(["REQ", ...])
**Relay:**
onwebsocketupgrade(): createconnectioncontext() markconnectionready() executeconnecthooks()
### 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:**
onmessage(text): if length(text) > MAXMESSAGESIZE: closeconnection("message too large") return process_message(text)
### Rule 2.3: Write Safety
Implementations must prevent concurrent writes to the WebSocket.
**Pattern:**
mutex = create_mutex()
sendmessage(jsonarray): 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:**
ontextframe(rawtext): try: jsonarray = JSON.parse(rawtext) messagetype = jsonarray[0] dispatchbytype(messagetype, jsonarray) catch parseerror: 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:**
receiveevent(event): validateeventstructure(event) validateeventid(event) validateeventsignature(event) if valid: storeevent(event) forwardtomatchingsubscriptions(event) sendokaccepted(event.id) else: sendok_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:**
oneventmessage(subid, event): subscription = findsubscription(sub_id) if subscription is null: return // discard unknown subscription
if alreadyhaveevent(event.id): return // skip duplicate
if not verifyeventsignature(event): return // skip invalid
if matchesfilters(event, subscription.filters): deliverto_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:**
pendingpublishes = Map<eventid, promise>
sendevent(event): promise = createpromise() pending_publishes.set(event.id, promise) send(["EVENT", event]) return promise
onokmessage(eventid, accepted, message): promise = pendingpublishes.get(eventid) if promise: if accepted: promise.resolve() else: promise.reject(message) pendingpublishes.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:**
onclosedmessage(subid, message): subscription = findsubscription(subid) if subscription: subscription.markclosed() invokeclosecallback(message) removesubscription(subid)
### 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:**
onconnect(client): challenge = generaterandom_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:**
subscriptionid = generateuniqueid() subscriptions.set(subscriptionid, { id: subscriptionid, filters: filters, eosed: false, closed: false }) send(["REQ", subscriptionid, ...filters]) starteosetimeout(subscription_id)
**Relay lifecycle:**
onreqmessage(client, subid, filters): subscription = { id: subid, filters: filters, client: client } active_subscriptions.add(subscription)
storedevents = queryevents(filters) for event in storedevents: sendtoclient(["EVENT", subid, event])
sendtoclient(["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:**
onreqmessage(client, subid, newfilters): if subscriptionexists(subid): removeoldsubscription(subid) createnewsubscription(subid, newfilters) queryandsendevents(subid, newfilters)
**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:**
closesubscription(subid): send(["CLOSE", subid]) removesubscription(sub_id)
**Relay-initiated:**
closesubscription(client, subid, reason): sendtoclient(["CLOSED", subid, reason]) removesubscription(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 = createpromise() timeout = settimeout(PUBLISH_TIMEOUT, () => { promise.reject("timeout") })
pending_publishes.set(event.id, {promise, timeout}) send(["EVENT", event])
return promise
**Relay:**
oneventmessage(client, event): validationresult = validateand_store(event)
if validationresult.accepted: forwardtosubscriptions(event) sendtoclient(["OK", event.id, true, ""]) else: sendtoclient(["OK", event.id, false, validationresult.reason])
### Rule 6.2: Publish Timeout
Clients should implement timeouts for event publishing.
**Recommended timeout:** 4000-5000 milliseconds
**Behavior:**
PUBLISH_TIMEOUT = 4400 // milliseconds
publishwithtimeout(event): promise = publish(event) timeoutpromise = sleep(PUBLISHTIMEOUT).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: eventa = {id: "aaa...", ...} eventb = {id: "bbb...", ...}
Send: ["EVENT", eventa] ["EVENT", eventb]
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:**
onconnect(client): startping_timer(client)
pingtimer(): every PINGINTERVAL: sendwebsocketping() waitforpong(PONGTIMEOUT) if nopongreceived: closeconnection("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:**
startheartbeat(): every HEARTBEATINTERVAL: // Send dummy REQ for impossible event nonce = randomhex(64) send(["REQ", "ping-" + nonce, {"ids": [nonce]}]) waitforeose(HEARTBEATTIMEOUT) if noeosereceived: 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:**
onconnect(client): setreaddeadline(now() + READDEADLINE)
onmessage(client, message): resetreaddeadline(now() + READDEADLINE)
onreaddeadlineexceeded(client): closeconnection(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
onconnect(client): setidle_timer(client)
onanymessage(client): resetidletimer(client)
onidletimeout(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:**
onwebsocketerror(error): logerror(error) closeallsubscriptions("connection error") rejectallpendingpublishes("connection error") set_state(DISCONNECTED)
**Relay:**
onwebsocketerror(client, error): logerror(error) closeallclientsubscriptions(client) removeclientfrom_registry(client)
### Rule 8.2: Protocol Errors
Invalid messages should trigger NOTICE but not close the connection.
**Relay behavior:**
oninvalidjson(client, rawtext): sendto_client(["NOTICE", "invalid JSON"]) // Keep connection alive
onunknownmessagetype(client, messagetype): sendtoclient(["NOTICE", "unknown message type: " + message_type]) // Keep connection alive
onmalformedmessage(client, error): sendtoclient(["NOTICE", "malformed message: " + error]) // Keep connection alive
### Rule 8.3: Rate Limiting
Relays may rate-limit clients at three levels.
**Connection level:**
onconnectionattempt(ipaddress): if exceedsconnectionratelimit(ipaddress): return HTTP 429 "Too Many Requests" acceptconnection()
**Subscription level:**
onreqmessage(client, subid, filters): if exceedssubscriptionratelimit(client): send(["CLOSED", subid, "rate limited: too many subscriptions"]) return createsubscription(sub_id, filters)
**Event level:**
oneventmessage(client, event): if exceedseventratelimit(client): send(["OK", event.id, false, "rate limited: too many events"]) return storeevent(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:**
onclosedmessage(subid, reason): subscription = subscriptions.get(subid) if subscription: subscription.closed = true cleareosetimeout(subid) invokeclosecallback(reason) subscriptions.delete(subid)
**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:**
oneventmessage(subid, event): if not subscriptions.has(subid): // Silently discard - subscription may have been closed return processevent(subid, event)
oneosemessage(subid): if not subscriptions.has(subid): return markeosed(subid)
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:**
onconnect(client): challenge = randomhex(16) client.authchallenge = challenge sendto_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:**
onauthmessage(client, signedevent): if signedevent.kind != 22242: send(["OK", signed_event.id, false, "error: wrong event kind"]) return
if not verifysignature(signedevent): send(["OK", signed_event.id, false, "error: invalid signature"]) return
challengetag = findtag(signedevent.tags, "challenge") if challengetag[1] != client.authchallenge: send(["OK", signedevent.id, false, "error: challenge mismatch"]) return
client.authenticatedpubkey = signedevent.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:**
onokrejected(eventid, message): if message.startswith("auth-required:"): authevent = createauthevent(relay.challenge) send(["AUTH", authevent]) waitforauthok() // Retry original event send(["EVENT", originalevent])
onclosedmessage(subid, message): if message.startswith("auth-required:"): authevent = createauthevent(relay.challenge) send(["AUTH", authevent]) waitforauthok() // Retry original subscription send(["REQ", subid, ...original_filters])
### Rule 9.4: Authentication State
Authentication persists for the connection lifetime.
**Client tracking:**
relaystate = { challenge: "a1b2c3d4e5f6g7h8", authenticated: false, authenticatedpubkey: null }
onauthokreceived(): relaystate.authenticated = true relaystate.authenticatedpubkey = my_pubkey
**Relay tracking:**
client_state = { challenge: "a1b2c3d4e5f6g7h8", authenticated: false, pubkey: null }
onvalidauth(pubkey): clientstate.authenticated = true clientstate.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])
sendwebsocketcloseframe() waitforcloseconfirmation(timeout=1000ms) close_websocket()
**Relay:**
shutdown(): for client in activeclients: for subscription in client.subscriptions: sendto_client(["CLOSED", subscription.id, "relay shutting down"])
sendwebsocketcloseframe(client) waitforcloseconfirmation(timeout=1000ms) close_connection(client)
### Rule 10.2: Resource Cleanup
All resources must be freed on disconnection.
**Client cleanup:**
ondisconnect(): // Clear all subscriptions for subscription in subscriptions.values(): subscription.closed = true invokeclose_callback("connection closed") subscriptions.clear()
// Reject pending publishes for publish in pendingpublishes.values(): publish.promise.reject("connection closed") pendingpublishes.clear()
// Clear timers clearalltimeouts()
// Reset state state = DISCONNECTED authenticated = false
**Relay cleanup:**
onclientdisconnect(client): // Remove all subscriptions for subscription in client.subscriptions: removefromsubscription_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:**
closeconnection(code, reason): sendcloseframe(code, reason) waitforcloseack() 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:**
INITIALDELAY = 250 // milliseconds MAXDELAY = 16000 // milliseconds attempt_count = 0
reconnect(): delay = min(INITIALDELAY * (2 ** attemptcount), MAXDELAY) sleep(delay) tryconnect() if success: attemptcount = 0 else: attemptcount += 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:**
onreconnectsuccess(): 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]) waitforok() catch error: outbox.push(event)
onreconnectsuccess(): for event in outbox: try: send(["EVENT", event]) waitforok() removefromoutbox(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:**
onconnectionerror(error): if error.code in [403, 410, 502, 503]: delay = EXTENDEDBACKOFF // e.g., 5 minutes else: delay = calculateexponential_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:**
relaypool = Map<url, relayconnection>
getrelay(url): if relaypool.has(url): return relay_pool.get(url)
connection = createconnection(url) relaypool.set(url, connection) return connection
onrelaydisconnect(url): relay_pool.delete(url)
### Optional Rule 12.2: Event Deduplication
Track which events have been seen across relays.
**Pattern:**
seenevents = Set<eventid>
onevent(relay, event): if seenevents.has(event.id): return // Skip duplicate
seenevents.add(event.id) processevent(event)
**With relay tracking:**
eventsources = Map<eventid, Set<relay_url>>
onevent(relayurl, event): if not eventsources.has(event.id): eventsources.set(event.id, new Set()) process_event(event)
eventsources.get(event.id).add(relayurl)
### Optional Rule 12.3: Subscription Fan-Out
Subscribe to the same filters on multiple relays.
**Pattern:**
subscribemulti(relayurls, filters): subscriptionid = generateid()
for url in relayurls: relay = getrelay(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:**
multisubscription = { id: "sub-1", relayurls: ["wss://relay1.com", "wss://relay2.com", "wss://relay3.com"], eose_received: Set() }
oneose(relayurl, subid): multisubscription.eosereceived.add(relayurl)
if multisubscription.eosereceived.size == multisubscription.relayurls.length: invokecompletecallback()
### Optional Rule 12.5: Publish Broadcasting
Publish events to multiple relays and collect results.
**Pattern:**
publishmulti(relayurls, event): promises = []
for url in relayurls: relay = getrelay(url) promise = relay.publish(event) promises.push(promise)
return Promise.all(promises)
**With partial success handling:**
publishmulti(relayurls, event): results = []
for url in relayurls: relay = getrelay(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}] }
updatefilters(newfilters): subscription.filters = newfilters send(["REQ", subscription.id, ...newfilters]) // 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:**
shouldresendreq(oldfilters, newfilters): if oldfilters.kinds != newfilters.kinds: return true if oldfilters.authors != newfilters.authors: return true if oldfilters["#e"] != newfilters["#e"]: return true if newfilters.since < oldfilters.since: return true // Looking backward in time if newfilters.until != oldfilters.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]}])
subscribereactive(relay, subid, filtersobservable): filtersobservable.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
onwebsocketmessage(rawtext): messagequeue.push(rawtext) if not processing: startqueue_processor()
async startqueueprocessor(): processing = true while messagequeue.length > 0: message = messagequeue.shift() processmessage(message) await yieldthread() // 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 }
onmessage(rawtext): messagetype = extracttype(rawtext) priority = getpriority(messagetype) priorityqueues[priority].push(raw_text)
processqueues(): while hasmessages(): if priorityqueues.high.length > 0: process(priorityqueues.high.shift()) else if priorityqueues.normal.length > 0: process(priorityqueues.normal.shift()) else if priorityqueues.low.length > 0: process(priorityqueues.low.shift())
## Optional: Performance Optimizations
### Optional Rule 15.1: Fast Event ID Extraction
Extract event ID before full JSON parsing for early deduplication.
**Pattern:**
oneventmessage(rawtext): // Fast path: extract ID via regex before parsing if rawtext.startswith('["EVENT"'): eventid = extracthex64(rawtext, '"id"') // regex scan
if alreadyhaveevent(event_id): return // Skip expensive JSON.parse()
// Slow path: parse full JSON [type, subid, event] = JSON.parse(rawtext) processevent(subid, event)
**Regex pattern:**
/"id"\s:\s"([0-9a-f]{64})"/
### Optional Rule 15.2: Subscription Ref-Counting
Share underlying subscriptions for identical requests.
**Pattern:**
activereqs = Map<subid, {count: number, filters: Filter[]}>
subscribe(subid, filters): if activereqs.has(subid): activereqs.get(sub_id).count += 1 return // Don't send duplicate REQ
activereqs.set(subid, {count: 1, filters: filters}) send(["REQ", sub_id, ...filters])
unsubscribe(subid): req = activereqs.get(sub_id) req.count -= 1
if req.count == 0: send(["CLOSE", subid]) activereqs.delete(sub_id)
### Optional Rule 15.3: Event Validation Caching
Cache signature verification results.
**Pattern:**
verifiedevents = Map<eventid, boolean>
verifyevent(event): if verifiedevents.has(event.id): return verified_events.get(event.id)
isvalid = verifysignature(event) verifiedevents.set(event.id, isvalid) return is_valid
**Cache eviction:**
MAXCACHESIZE = 10000
if verifiedevents.size > MAXCACHESIZE: // Evict oldest entries removeoldest_entries(1000)
### Optional Rule 15.4: Trusted Relays
Skip signature verification for known-good relays.
**Pattern:**
trustedrelayurls = Set([ "wss://relay1.example.com", "wss://relay2.example.com" ])
onevent(relayurl, event): if trustedrelayurls.has(relayurl): processevent(event) // Skip verification else: if verifyevent(event): processevent(event)
## Optional: Connection Lifecycle Hooks
Implementations may expose hooks for monitoring and extension.
### Optional Rule 16.1: Connection State Callbacks
**Client hooks:**
onconnecting(): // Connection attempt started showloading_indicator()
onconnected(): // WebSocket open, ready to communicate hideloadingindicator() restoresubscriptions()
ondisconnecting(): // Graceful shutdown initiated showreconnecting_message()
ondisconnected(): // Connection closed clearuistate() schedulereconnection()
### Optional Rule 16.2: Message Interception
**Relay hooks:**
beforeeventstore(event): // Validate custom rules if not meetsrelaypolicy(event): return reject("policy violation") return accept()
aftereventstore(event): // Trigger side effects updatesearchindex(event) notifyexternalsystems(event)
beforesubscriptioncreate(filters): // Validate or modify filters if too_broad(filters): return reject("filter too broad") return accept()
### Optional Rule 16.3: Error Callbacks
**Client hooks:**
onerror(error): logtomonitoring(error) showuser_message(error)
onnotice(message): logrelay_notice(message)
onclosed(subid, reason): logsubscriptionclosure(subid, reason) showuser_notification(reason)
---
## Implementation Notes
### Concurrency Models
This specification is compatible with multiple concurrency approaches:
**Callback-based:**
relay.on('event', (subid, event) => { ... }) relay.on('eose', (subid) => { ... })
**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)", 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 promiseRelay:
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 ID7. 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 aliveRule 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_pubkeyRelay tracking:
client_state = {
challenge: "a1b2c3d4e5f6g7h8",
authenticated: false,
pubkey: null
}
on_valid_auth(pubkey):
client_state.authenticated = true
client_state.pubkey = pubkey10. 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 = falseRelay 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 += 1Example 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 attemptOptional 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_idAll 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 resultsOptional: 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 IDOptional 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 forwardOptional 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 updatesOptional: 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 = falseOptional 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_validCache 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)