NIP-CF: Changes Feed
- NIP-CF: Changes Feed
NIP-CF: Changes Feed
draft optional
This NIP defines an optional relay extension for sequence-based event synchronization.
Abstract
Standard Nostr sync uses timestamp-based filters (since), which can miss events due to timestamp collisions or clock drift. This NIP defines a changes feed that uses monotonically increasing sequence numbers for precise, reliable synchronization.
Motivation
Timestamp-based sync has limitations:
- Collisions: Multiple events can share the same second-precision timestamp
- Clock drift: Client and relay clocks may differ
- Imprecise checkpointing: “Give me events since timestamp X” is fuzzy
Sequence numbers solve these issues by providing a strict total ordering of all events stored by a relay.
Relay Requirements
Relays implementing this NIP:
MUST:
- Assign a monotonically increasing sequence number to each stored event
- Support the
CHANGESmessage type - Support continuous/live changes feeds
Capability Advertisement
Relays MUST advertise support by including this NIP in their NIP-11 supported_nips array.
Clients SHOULD check this before using the changes feed and fall back to timestamp-based sync if not supported.
Protocol
CHANGES Request (client to relay)
["CHANGES", <subscription_id>, <filter>]
The filter object supports:
| Field | Type | Description |
|---|---|---|
since |
integer | Return events with seq > since (default: 0) |
limit |
integer | Maximum number of events to return (optional) |
kinds |
int[] | Filter by event kinds (optional) |
authors |
string[] | Filter by author pubkeys (optional) |
#<tag> |
string[] | Filter by tag values, same as NIP-01 (optional) |
live |
boolean | Keep subscription open for real-time updates (optional) |
Example:
["CHANGES", "sync-1", {
"since": 0,
"kinds": [1, 30023],
"authors": ["<pubkey>"]
}]
CHANGES EVENT (relay to client)
For each matching event, the relay sends:
["CHANGES", <subscription_id>, "EVENT", <seq>, <event>]
seq: The sequence number assigned to this event (integer)event: The full Nostr event object
Events MUST be sent in sequence order (ascending).
CHANGES EOSE (relay to client)
After sending all stored events matching the filter:
["CHANGES", <subscription_id>, "EOSE", <last_seq>]
last_seq: The relay’s current maximum sequence number
The last_seq value is always the global maximum, even if no events matched the filter. This allows clients to advance their checkpoint without re-querying the same range.
CHANGES ERR (relay to client)
If the request cannot be processed:
["CHANGES", <subscription_id>, "ERR", <message>]
After an error, the subscription is closed.
Closing a Subscription
Clients close a changes subscription with a standard CLOSE message:
["CLOSE", <subscription_id>]
Live/Continuous Mode
If the client includes "live": true in the filter, the relay keeps the subscription open after EOSE and sends new matching events in real-time:
Client: ["CHANGES", "s1", {"since": 42, "kinds": [1], "live": true}]
Relay: ["CHANGES", "s1", "EVENT", 43, {...}]
Relay: ["CHANGES", "s1", "EVENT", 50, {...}]
Relay: ["CHANGES", "s1", "EOSE", 50]
-- subscription stays open --
Relay: ["CHANGES", "s1", "EVENT", 51, {...}] (new event arrives)
Relay: ["CHANGES", "s1", "EVENT", 52, {...}] (another new event)
...
Client: ["CLOSE", "s1"]
Client Implementation
Sync Flow
Initial sync:
// Check if relay supports changes feed (NIP-CF)
const info = await fetch(relayUrl.replace('wss', 'https'))
const nip11 = await info.json()
if (nip11.supported_nips?.includes('CF')) {
// Use changes feed
send(["CHANGES", "sync", { since: 0, kinds: [1234], authors: [pubkey] }])
} else {
// Fall back to timestamp-based
send(["REQ", "sync", { kinds: [1234], authors: [pubkey] }])
}
Processing responses:
let checkpoint = loadCheckpoint() || 0
relay.on('message', (msg) => {
if (msg[0] === 'CHANGES' && msg[1] === 'sync') {
if (msg[2] === 'EVENT') {
const seq = msg[3]
const event = msg[4]
processEvent(event)
} else if (msg[2] === 'EOSE') {
const lastSeq = msg[3]
checkpoint = lastSeq
saveCheckpoint(checkpoint)
}
}
})
Incremental sync:
send(["CHANGES", "sync", { since: checkpoint, kinds: [1234], authors: [pubkey] }])
Checkpoint Storage
Clients SHOULD persist their checkpoint (last seen sequence number) to enable efficient incremental sync across sessions.
Note: Sequence numbers are relay-specific. Clients syncing from multiple relays need separate checkpoints for each.
Security Considerations
- Sequence numbers may reveal information about relay activity (event frequency)
- Relays SHOULD rate-limit changes feed requests like other subscriptions
- The
limitparameter helps prevent excessive resource usage