NIP-65

Relay List Metadata

draft relay features

NIP-65 enables users to publish relay preferences (read/write) using kind 10002 events, helping clients optimize relay connections.

Author
mikedilger, vitorpamplona
Last Updated
10 February 2024
Official Spec
View on GitHub →

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

  1. Client Optimization: Connect only to relays that matter
  2. Content Discovery: Find user’s posts efficiently
  3. Bandwidth Savings: Reduce unnecessary connections
  4. Relay Support: Direct users to specific relays
  5. Network Health: Better relay distribution
  6. 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

ClientRead ListWrite ListOutbox ModelUI
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.


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


Last updated: February 2024 Official specification: GitHub

Client Support

This NIP is supported by the following clients:

gossip amethyst coracle nostrudel snort
View all clients →

Related NIPs

NIP-01 NIP-02 NIP-11 NIP-50
← Browse All NIPs