Relay List Metadata
NIP-65 enables users to publish relay preferences (read/write) using kind 10002 events, helping clients optimize relay connections.
NIP-65: Relay List Metadata
Status: Draft Authors: mikedilger, vitorpamplona Category: Relay Features
Overview
NIP-65 defines how users specify their preferred relays for reading and writing events, enabling better content discovery and efficient relay connections.
Core Concept:
- Users publish relay preferences (kind 10002)
- Relays categorized as read, write, or both
- Clients use relay lists to optimize connections
- Better content delivery and discovery
Relay Types:
- ✅ Read relays: Where to fetch user’s content
- ✅ Write relays: Where user publishes events
- ✅ Both: Combined read/write functionality
- ✅ Hints: Suggest where to find specific events
Status: Draft (widely adopted, considered essential)
Why Relay Lists Matter
Without NIP-65
Problems before relay lists:
- Clients don’t know which relays to query
- Users connect to 10+ relays (wasteful)
- Content missed if wrong relays queried
- Bandwidth wasted on irrelevant connections
- No way to communicate relay preferences
With NIP-65
Benefits:
- Efficient discovery: Query only relevant relays
- Reduced bandwidth: Connect to fewer relays
- Better content delivery: Events found where they exist
- User control: Specify preferred relay infrastructure
- Decentralization: Support your favorite relays
Use Cases
- Client Optimization: Connect only to relays that matter
- Content Discovery: Find user’s posts efficiently
- Bandwidth Savings: Reduce unnecessary connections
- Relay Support: Direct users to specific relays
- Network Health: Better relay distribution
- Privacy: Control where your data lives
How It Works
Relay List Event Structure
NIP-65 uses kind 10002 (replaceable event):
{
"kind": 10002,
"content": "",
"tags": [
["r", "wss://relay.damus.io"],
["r", "wss://relay.primal.net", "write"],
["r", "wss://nos.lol", "read"],
["r", "wss://relay.snort.social"]
],
"created_at": 1673347337,
"pubkey": "<user-pubkey>"
}
Tag Format:
["r", "<relay-url>"]- Read and write (default)["r", "<relay-url>", "read"]- Read only["r", "<relay-url>", "write"]- Write only
Creating Relay Lists
Basic Relay List
import { getPublicKey, signEvent } from 'nostr-tools';
function createRelayList(relays, privateKey) {
const relayList = {
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: relays.map(relay => {
if (relay.access) {
return ["r", relay.url, relay.access];
}
return ["r", relay.url];
}),
pubkey: getPublicKey(privateKey)
};
return signEvent(relayList, privateKey);
}
// Usage
const myRelays = [
{ url: "wss://relay.damus.io" }, // Read & write
{ url: "wss://relay.primal.net", access: "write" }, // Write only
{ url: "wss://nos.lol", access: "read" }, // Read only
{ url: "wss://relay.snort.social" } // Read & write
];
const relayList = createRelayList(myRelays, myPrivateKey);
await pool.publish(relayList, myRelays);
Categorized Relay Configuration
function createCategorizedRelayList({
writeRelays,
readRelays,
bothRelays,
privateKey
}) {
const tags = [];
// Add write-only relays
writeRelays.forEach(url => {
tags.push(["r", url, "write"]);
});
// Add read-only relays
readRelays.forEach(url => {
tags.push(["r", url, "read"]);
});
// Add read+write relays (no access marker)
bothRelays.forEach(url => {
tags.push(["r", url]);
});
const relayList = {
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: tags,
pubkey: getPublicKey(privateKey)
};
return signEvent(relayList, privateKey);
}
// Usage
const categorizedList = createCategorizedRelayList({
writeRelays: [
"wss://relay.primal.net",
"wss://relay.damus.io"
],
readRelays: [
"wss://nos.lol",
"wss://relay.nostr.band"
],
bothRelays: [
"wss://relay.snort.social"
],
privateKey: myPrivateKey
});
await pool.publish(categorizedList, myRelays);
Fetching Relay Lists
Get User’s Relay List
async function getUserRelayList(userPubkey, pool, relays) {
const relayList = await pool.get(relays, {
kinds: [10002],
authors: [userPubkey]
});
if (!relayList) return null;
return parseRelayList(relayList);
}
function parseRelayList(event) {
const relays = {
read: [],
write: [],
both: []
};
event.tags.forEach(tag => {
if (tag[0] !== "r") return;
const url = tag[1];
const access = tag[2];
if (!access) {
relays.both.push(url);
} else if (access === "read") {
relays.read.push(url);
} else if (access === "write") {
relays.write.push(url);
}
});
return relays;
}
// Usage
const relayList = await getUserRelayList(userPubkey, pool, myRelays);
console.log("Read relays:", relayList.read);
console.log("Write relays:", relayList.write);
console.log("Both:", relayList.both);
Get All Relays for User
function getAllUserRelays(relayList) {
const allRelays = [
...relayList.both,
...relayList.read,
...relayList.write
];
// Deduplicate
return [...new Set(allRelays)];
}
function getReadRelays(relayList) {
return [...relayList.both, ...relayList.read];
}
function getWriteRelays(relayList) {
return [...relayList.both, ...relayList.write];
}
// Usage
const userRelays = await getUserRelayList(userPubkey, pool, myRelays);
const readFrom = getReadRelays(userRelays);
const writeTo = getWriteRelays(userRelays);
console.log(`Query ${readFrom.length} relays for content`);
console.log(`Publish to ${writeTo.length} relays`);
Using Relay Lists
Optimized Content Fetching
async function fetchUserEventsOptimized(userPubkey, pool, bootstrapRelays) {
// Step 1: Get user's relay list
const relayList = await getUserRelayList(userPubkey, pool, bootstrapRelays);
if (!relayList) {
console.log("No relay list found, using bootstrap relays");
return fetchUserEvents(userPubkey, pool, bootstrapRelays);
}
// Step 2: Get read relays
const readRelays = getReadRelays(relayList);
console.log(`Fetching from ${readRelays.length} preferred relays`);
// Step 3: Query only preferred relays
return fetchUserEvents(userPubkey, pool, readRelays);
}
async function fetchUserEvents(userPubkey, pool, relays) {
return await pool.list(relays, [
{
kinds: [1],
authors: [userPubkey],
limit: 50
}
]);
}
Smart Relay Connection
class SmartRelayManager {
constructor(pool, bootstrapRelays) {
this.pool = pool;
this.bootstrapRelays = bootstrapRelays;
this.userRelays = new Map();
}
async getRelaysForUser(pubkey) {
// Check cache
if (this.userRelays.has(pubkey)) {
return this.userRelays.get(pubkey);
}
// Fetch relay list
const relayList = await getUserRelayList(
pubkey,
this.pool,
this.bootstrapRelays
);
if (relayList) {
const relays = getAllUserRelays(relayList);
this.userRelays.set(pubkey, relays);
return relays;
}
// Fallback to bootstrap
return this.bootstrapRelays;
}
async fetchFromUserRelays(pubkey, filters) {
const relays = await this.getRelaysForUser(pubkey);
return await this.pool.list(relays, [filters]);
}
async publishToUserRelays(event, authorPubkey) {
const relayList = await getUserRelayList(
authorPubkey,
this.pool,
this.bootstrapRelays
);
if (!relayList) {
return await this.pool.publish(event, this.bootstrapRelays);
}
const writeRelays = getWriteRelays(relayList);
return await this.pool.publish(event, writeRelays);
}
}
// Usage
const relayManager = new SmartRelayManager(pool, [
"wss://relay.damus.io",
"wss://relay.primal.net"
]);
// Fetch user's posts from their preferred relays
const posts = await relayManager.fetchFromUserRelays(userPubkey, {
kinds: [1],
limit: 20
});
Updating Relay Lists
Add Relay to List
async function addRelay(relayUrl, access, privateKey, pool, bootstrapRelays) {
const pubkey = getPublicKey(privateKey);
// Fetch existing list
const existingList = await getUserRelayList(pubkey, pool, bootstrapRelays);
// Build relay set
const relays = existingList
? getAllUserRelays(existingList)
: [];
// Add new relay (prevent duplicates)
if (!relays.includes(relayUrl)) {
relays.push(relayUrl);
}
// Rebuild tags
const tags = relays.map(url => {
if (url === relayUrl && access) {
return ["r", url, access];
}
// Preserve existing access markers
return ["r", url];
});
const updatedList = {
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: tags,
pubkey: pubkey
};
const signedList = signEvent(updatedList, privateKey);
await pool.publish(signedList, [...relays, ...bootstrapRelays]);
return signedList;
}
// Usage
await addRelay("wss://relay.snort.social", "read", myPrivateKey, pool, bootstrapRelays);
Remove Relay from List
async function removeRelay(relayUrl, privateKey, pool, bootstrapRelays) {
const pubkey = getPublicKey(privateKey);
const existingList = await getUserRelayList(pubkey, pool, bootstrapRelays);
if (!existingList) return null;
// Filter out the relay
const tags = existingList.tags.filter(tag =>
tag[0] === "r" && tag[1] !== relayUrl
);
const updatedList = {
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: tags,
pubkey: pubkey
};
const signedList = signEvent(updatedList, privateKey);
const remainingRelays = tags.map(t => t[1]);
await pool.publish(signedList, [...remainingRelays, ...bootstrapRelays]);
return signedList;
}
Advanced Patterns
Relay Hints in Events
Combine NIP-65 with event relay hints:
function createEventWithRelayHint(content, referencedEvent, privateKey) {
const event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
content: content,
tags: [
["e", referencedEvent.id, referencedEvent.relayHint || "", "reply"]
],
pubkey: getPublicKey(privateKey)
};
return signEvent(event, privateKey);
}
// Usage: Reply with relay hint
const reply = createEventWithRelayHint(
"Great post!",
{
id: originalPost.id,
relayHint: "wss://relay.damus.io" // Where original was found
},
myPrivateKey
);
Outbox Model
Implement the “outbox model” for reliable content delivery:
class OutboxModel {
constructor(pool) {
this.pool = pool;
this.relayCache = new Map();
}
async fetchUserContent(userPubkey, filters) {
// 1. Get user's relay list (where they write)
const relayList = await this.getUserRelays(userPubkey);
const writeRelays = getWriteRelays(relayList);
// 2. Query write relays (user's "outbox")
const events = await this.pool.list(writeRelays, [
{ ...filters, authors: [userPubkey] }
]);
return events;
}
async fetchFollowingContent(followedPubkeys, filters) {
// Fetch relay lists for all followed users
const relaysByUser = await Promise.all(
followedPubkeys.map(async pubkey => ({
pubkey,
relays: await this.getUserRelays(pubkey)
}))
);
// Build relay pool (all write relays)
const allRelays = new Set();
relaysByUser.forEach(({ relays }) => {
getWriteRelays(relays).forEach(r => allRelays.add(r));
});
// Query all relevant relays
return await this.pool.list([...allRelays], [
{ ...filters, authors: followedPubkeys }
]);
}
async getUserRelays(pubkey) {
if (this.relayCache.has(pubkey)) {
return this.relayCache.get(pubkey);
}
const relays = await getUserRelayList(pubkey, this.pool, bootstrapRelays);
this.relayCache.set(pubkey, relays);
return relays;
}
}
Relay Selection Strategy
class RelaySelector {
constructor() {
this.relayStats = new Map();
}
recordLatency(relayUrl, latency) {
if (!this.relayStats.has(relayUrl)) {
this.relayStats.set(relayUrl, {
latencies: [],
failures: 0,
successes: 0
});
}
const stats = this.relayStats.get(relayUrl);
stats.latencies.push(latency);
stats.successes++;
// Keep only last 100 measurements
if (stats.latencies.length > 100) {
stats.latencies.shift();
}
}
recordFailure(relayUrl) {
if (!this.relayStats.has(relayUrl)) {
this.relayStats.set(relayUrl, {
latencies: [],
failures: 0,
successes: 0
});
}
this.relayStats.get(relayUrl).failures++;
}
selectBestRelays(candidateRelays, count = 5) {
// Score each relay
const scored = candidateRelays.map(url => ({
url,
score: this.scoreRelay(url)
}));
// Sort by score (higher is better)
scored.sort((a, b) => b.score - a.score);
// Return top N
return scored.slice(0, count).map(r => r.url);
}
scoreRelay(relayUrl) {
const stats = this.relayStats.get(relayUrl);
if (!stats) return 0; // Unknown relay
// Calculate average latency
const avgLatency = stats.latencies.reduce((a, b) => a + b, 0) / stats.latencies.length;
// Calculate success rate
const total = stats.successes + stats.failures;
const successRate = stats.successes / total;
// Score: inverse of latency * success rate
return (1000 / avgLatency) * successRate;
}
}
// Usage
const selector = new RelaySelector();
// Record performance
selector.recordLatency("wss://relay.damus.io", 45);
selector.recordLatency("wss://relay.primal.net", 120);
selector.recordFailure("wss://slow-relay.com");
// Select best relays
const bestRelays = selector.selectBestRelays(allAvailableRelays, 3);
UI Patterns
Relay List Manager Component
function RelayListManager({ privateKey, pool, bootstrapRelays }) {
const [relays, setRelays] = useState({ read: [], write: [], both: [] });
const [newRelay, setNewRelay] = useState("");
const [accessType, setAccessType] = useState("both");
useEffect(() => {
loadRelayList();
}, []);
async function loadRelayList() {
const pubkey = getPublicKey(privateKey);
const list = await getUserRelayList(pubkey, pool, bootstrapRelays);
if (list) setRelays(list);
}
async function handleAddRelay() {
if (!newRelay) return;
await addRelay(newRelay, accessType !== "both" ? accessType : null, privateKey, pool, bootstrapRelays);
await loadRelayList();
setNewRelay("");
}
async function handleRemoveRelay(url) {
await removeRelay(url, privateKey, pool, bootstrapRelays);
await loadRelayList();
}
return (
<div className="relay-manager">
<h2>Relay Configuration</h2>
<div className="add-relay">
<input
type="text"
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
placeholder="wss://relay.example.com"
/>
<select value={accessType} onChange={(e) => setAccessType(e.target.value)}>
<option value="both">Read & Write</option>
<option value="read">Read Only</option>
<option value="write">Write Only</option>
</select>
<button onClick={handleAddRelay}>Add Relay</button>
</div>
<div className="relay-list">
<h3>Read & Write ({relays.both.length})</h3>
{relays.both.map(url => (
<RelayItem key={url} url={url} onRemove={handleRemoveRelay} />
))}
<h3>Read Only ({relays.read.length})</h3>
{relays.read.map(url => (
<RelayItem key={url} url={url} onRemove={handleRemoveRelay} />
))}
<h3>Write Only ({relays.write.length})</h3>
{relays.write.map(url => (
<RelayItem key={url} url={url} onRemove={handleRemoveRelay} />
))}
</div>
</div>
);
}
function RelayItem({ url, onRemove }) {
return (
<div className="relay-item">
<span>{url}</span>
<button onClick={() => onRemove(url)}>Remove</button>
</div>
);
}
Security Considerations
Relay Trust
function validateRelayUrl(url) {
// Must be WebSocket URL
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
return { valid: false, error: "Must be WebSocket URL (wss:// or ws://)" };
}
// Prefer encrypted (wss://)
if (url.startsWith("ws://")) {
console.warn("Unencrypted relay detected. Use wss:// for security.");
}
// Check for valid hostname
try {
new URL(url);
} catch {
return { valid: false, error: "Invalid URL format" };
}
return { valid: true };
}
Privacy Protection
Be careful with relay choices:
function getPrivacyAwareRelays(sensitiveEvent) {
// For sensitive events, use trusted relays only
return [
"wss://my-personal-relay.com",
"wss://trusted-relay.org"
];
}
function getPublicRelays() {
// For public content, use popular relays
return [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol"
];
}
Performance Optimization
Relay List Caching
class RelayListCache {
constructor(ttl = 5 * 60 * 1000) { // 5 minutes
this.cache = new Map();
this.ttl = ttl;
}
get(pubkey) {
const cached = this.cache.get(pubkey);
if (!cached) return null;
if (Date.now() - cached.timestamp > this.ttl) {
this.cache.delete(pubkey);
return null;
}
return cached.relayList;
}
set(pubkey, relayList) {
this.cache.set(pubkey, {
relayList,
timestamp: Date.now()
});
}
}
const relayListCache = new RelayListCache();
async function getCachedRelayList(pubkey, pool, bootstrapRelays) {
// Check cache first
const cached = relayListCache.get(pubkey);
if (cached) return cached;
// Fetch from relays
const relayList = await getUserRelayList(pubkey, pool, bootstrapRelays);
if (relayList) {
relayListCache.set(pubkey, relayList);
}
return relayList;
}
Client Support
Clients with NIP-65 Support
- Gossip - Built around outbox model (full support)
- Amethyst - Relay list management UI
- Coracle - Automatic relay optimization
- Nostrudel - Advanced relay configuration
- Snort - Basic relay list support
Implementation Status
| Client | Read List | Write List | Outbox Model | UI |
|---|---|---|---|---|
| Gossip | ✅ | ✅ | ✅ | ✅ |
| Amethyst | ✅ | ✅ | ✅ | ✅ |
| Coracle | ✅ | ✅ | ✅ | ⚠️ |
| Nostrudel | ✅ | ✅ | ⚠️ | ✅ |
| Snort | ✅ | ⚠️ | ❌ | ⚠️ |
Check our Client Directory for details.
Common Questions
Why kind 10002 instead of kind 3 (contacts)?
Kind 10002 is specifically for relay metadata, separate from contact lists. This separation allows relay changes without republishing contacts.
How many relays should I use?
Recommendation: 3-5 relays. More relays = more bandwidth, more complexity. Choose quality over quantity.
Should I use read/write separation?
Optional. Useful if you want to:
- Publish to paid relays (write only)
- Read from large aggregators (read only)
- Support specific relay infrastructure
What if a relay goes offline?
Clients should handle failures gracefully and fall back to other relays. Use multiple relays for redundancy.
Can I have different relay lists for different clients?
No. Kind 10002 is replaceable, so only one relay list exists per user. All clients see the same list.
Related NIPs
- NIP-01 - Basic protocol (event structure)
- NIP-02 - Contact lists (predecessor with relay info)
- NIP-11 - Relay information document
- NIP-50 - Search (relay discovery)
Technical Specification
For the complete technical specification, see NIP-65 on GitHub.
Summary
NIP-65 enables relay preference management on Nostr:
✅ Kind 10002 for relay lists ✅ Read/write separation optional ✅ Optimized queries using user preferences ✅ Outbox model for reliable delivery ✅ Better performance fewer connections
Relay list structure:
{
"kind": 10002,
"tags": [
["r", "wss://relay.damus.io"],
["r", "wss://relay.primal.net", "write"],
["r", "wss://nos.lol", "read"]
]
}
Status: Draft - essential for modern Nostr clients.
Best practice: Use 3-5 quality relays, prefer read/write separation for advanced setups.
Next Steps:
- Learn about authentication in NIP-42
- Explore relay info in NIP-11
- Understand search in NIP-50
- Browse all NIPs in our reference
Last updated: February 2024 Official specification: GitHub
Client Support
This NIP is supported by the following clients: