common-mistakes.md raw

Common Nostr Implementation Mistakes and How to Avoid Them

This document highlights frequent errors made when implementing Nostr clients and relays, along with solutions.

Event Creation and Signing

Mistake 1: Incorrect Event ID Calculation

Problem: Wrong serialization order or missing fields when calculating SHA256.

Correct Serialization:

[
  0,                    // Must be integer 0
  <pubkey>,            // Lowercase hex string
  <created_at>,        // Unix timestamp integer
  <kind>,              // Integer
  <tags>,              // Array of arrays
  <content>            // String
]

Common errors:

Fix: Serialize exactly as shown, compact JSON, SHA256 the UTF-8 bytes.

Mistake 2: Wrong Signature Algorithm

Problem: Using ECDSA instead of Schnorr signatures.

Correct:

Libraries:

Mistake 3: Invalid created_at Timestamps

Problem: Events with far-future timestamps or very old timestamps.

Best practices:

Fix: Always use current time when creating events.

Mistake 4: Malformed Tags

Problem: Tags that aren't arrays or have wrong structure.

Correct format:

{
  "tags": [
    ["e", "event-id", "relay-url", "marker"],
    ["p", "pubkey", "relay-url"],
    ["t", "hashtag"]
  ]
}

Common errors:

Mistake 5: Not Handling Replaceable Events

Problem: Showing multiple versions of replaceable events.

Event types:

Fix:

// For replaceable events
const key = `${event.pubkey}:${event.kind}`
if (latestEvents[key]?.created_at < event.created_at) {
  latestEvents[key] = event
}

// For parameterized replaceable events
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${event.kind}:${dTag}`
if (latestEvents[key]?.created_at < event.created_at) {
  latestEvents[key] = event
}

WebSocket Communication

Mistake 6: Not Handling EOSE

Problem: Loading indicators never finish or show wrong state.

Solution:

const receivedEvents = new Set()
let eoseReceived = false

ws.onmessage = (msg) => {
  const [type, ...rest] = JSON.parse(msg.data)
  
  if (type === 'EVENT') {
    const [subId, event] = rest
    receivedEvents.add(event.id)
    displayEvent(event)
  }
  
  if (type === 'EOSE') {
    eoseReceived = true
    hideLoadingSpinner()
  }
}

Mistake 7: Not Closing Subscriptions

Problem: Memory leaks and wasted bandwidth from unclosed subscriptions.

Fix: Always send CLOSE when done:

ws.send(JSON.stringify(['CLOSE', subId]))

Best practices:

Mistake 8: Ignoring OK Messages

Problem: Not knowing if events were accepted or rejected.

Solution:

ws.onmessage = (msg) => {
  const [type, eventId, accepted, message] = JSON.parse(msg.data)
  
  if (type === 'OK') {
    if (!accepted) {
      console.error(`Event ${eventId} rejected: ${message}`)
      handleRejection(eventId, message)
    }
  }
}

Common rejection reasons:

Mistake 9: Sending Events Before WebSocket Ready

Problem: Events lost because WebSocket not connected.

Fix:

const sendWhenReady = (ws, message) => {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(message)
  } else {
    ws.addEventListener('open', () => ws.send(message), { once: true })
  }
}

Mistake 10: Not Handling WebSocket Disconnections

Problem: App breaks when relay goes offline.

Solution: Implement reconnection with exponential backoff:

let reconnectDelay = 1000
const maxDelay = 30000

const connect = () => {
  const ws = new WebSocket(relayUrl)
  
  ws.onclose = () => {
    setTimeout(() => {
      reconnectDelay = Math.min(reconnectDelay * 2, maxDelay)
      connect()
    }, reconnectDelay)
  }
  
  ws.onopen = () => {
    reconnectDelay = 1000 // Reset on successful connection
    resubscribe() // Re-establish subscriptions
  }
}

Filter Queries

Mistake 11: Overly Broad Filters

Problem: Requesting too many events, overwhelming relay and client.

Bad:

{
  "kinds": [1],
  "limit": 10000
}

Good:

{
  "kinds": [1],
  "authors": ["<followed-users>"],
  "limit": 50,
  "since": 1234567890
}

Best practices:

Mistake 12: Not Using Prefix Matching

Problem: Full hex strings in filters unnecessarily.

Optimization:

{
  "ids": ["abc12345"],  // 8 chars enough for uniqueness
  "authors": ["def67890"]
}

Relays support prefix matching for ids and authors.

Mistake 13: Duplicate Filter Fields

Problem: Redundant filter conditions.

Bad:

{
  "authors": ["pubkey1", "pubkey1"],
  "kinds": [1, 1]
}

Good:

{
  "authors": ["pubkey1"],
  "kinds": [1]
}

Deduplicate filter arrays.

Threading and References

Mistake 14: Incorrect Thread Structure

Problem: Missing root/reply markers or wrong tag order.

Correct reply structure (NIP-10):

{
  "kind": 1,
  "tags": [
    ["e", "<root-event-id>", "<relay>", "root"],
    ["e", "<parent-event-id>", "<relay>", "reply"],
    ["p", "<author1-pubkey>"],
    ["p", "<author2-pubkey>"]
  ]
}

Key points:

Mistake 15: Missing p Tags in Replies

Problem: Authors not notified of replies.

Fix: Always add p tag for:

{
  "tags": [
    ["e", "event-id", "", "reply"],
    ["p", "original-author"],
    ["p", "mentioned-user1"],
    ["p", "mentioned-user2"]
  ]
}

Mistake 16: Not Using Markers

Problem: Ambiguous thread structure.

Solution: Always use markers in e tags:

Without markers, clients must guess thread structure.

Relay Management

Mistake 17: Relying on Single Relay

Problem: Single point of failure, censorship vulnerability.

Solution: Connect to multiple relays (5-15 common):

const relays = [
  'wss://relay1.com',
  'wss://relay2.com',
  'wss://relay3.com'
]

const connections = relays.map(url => connect(url))

Best practices:

Mistake 18: Not Implementing NIP-65

Problem: Querying wrong relays, missing user's events.

Correct flow:

  1. Fetch user's kind 10002 event (relay list)
  2. Connect to their read relays to fetch their content
  3. Connect to their write relays to send them messages
async function getUserRelays(pubkey) {
  // Fetch kind 10002
  const relayList = await fetchEvent({
    kinds: [10002],
    authors: [pubkey]
  })
  
  const readRelays = []
  const writeRelays = []
  
  relayList.tags.forEach(([tag, url, mode]) => {
    if (tag === 'r') {
      if (!mode || mode === 'read') readRelays.push(url)
      if (!mode || mode === 'write') writeRelays.push(url)
    }
  })
  
  return { readRelays, writeRelays }
}

Mistake 19: Not Respecting Relay Limitations

Problem: Violating relay policies, getting rate limited or banned.

Solution: Fetch and respect NIP-11 relay info:

const getRelayInfo = async (relayUrl) => {
  const url = relayUrl.replace('wss://', 'https://').replace('ws://', 'http://')
  const response = await fetch(url, {
    headers: { 'Accept': 'application/nostr+json' }
  })
  return response.json()
}

// Respect limitations
const info = await getRelayInfo(relayUrl)
const maxLimit = info.limitation?.max_limit || 500
const maxFilters = info.limitation?.max_filters || 10

Security

Mistake 20: Exposing Private Keys

Problem: Including nsec in client code, logs, or network requests.

Never:

Best practices:

Mistake 21: Not Verifying Signatures

Problem: Accepting invalid events, vulnerability to attacks.

Always verify:

const verifyEvent = (event) => {
  // 1. Verify ID
  const calculatedId = sha256(serializeEvent(event))
  if (calculatedId !== event.id) return false
  
  // 2. Verify signature
  const signatureValid = schnorr.verify(
    event.sig,
    event.id,
    event.pubkey
  )
  if (!signatureValid) return false
  
  // 3. Check timestamp
  const now = Math.floor(Date.now() / 1000)
  if (event.created_at > now + 900) return false // 15 min future
  
  return true
}

Verify before:

Mistake 22: Using NIP-04 Encryption

Problem: Weak encryption, vulnerable to attacks.

Solution: Use NIP-44 instead:

Migration: Update to NIP-44 for all new encrypted messages.

Mistake 23: Not Sanitizing Content

Problem: XSS vulnerabilities in displayed content.

Solution: Sanitize before rendering:

import DOMPurify from 'dompurify'

const safeContent = DOMPurify.sanitize(event.content, {
  ALLOWED_TAGS: ['b', 'i', 'u', 'a', 'code', 'pre'],
  ALLOWED_ATTR: ['href', 'target', 'rel']
})

Especially critical for:

User Experience

Mistake 24: Not Caching Events

Problem: Re-fetching same events repeatedly, poor performance.

Solution: Implement event cache:

const eventCache = new Map()

const cacheEvent = (event) => {
  eventCache.set(event.id, event)
}

const getCachedEvent = (eventId) => {
  return eventCache.get(eventId)
}

Cache strategies:

Mistake 25: Not Implementing Optimistic UI

Problem: Slow feeling app, waiting for relay confirmation.

Solution: Show user's events immediately:

const publishEvent = async (event) => {
  // Immediately show to user
  displayEvent(event, { pending: true })
  
  // Publish to relays
  const results = await Promise.all(
    relays.map(relay => relay.publish(event))
  )
  
  // Update status based on results
  const success = results.some(r => r.accepted)
  displayEvent(event, { pending: false, success })
}

Mistake 26: Poor Loading States

Problem: User doesn't know if app is working.

Solution: Clear loading indicators:

Mistake 27: Not Handling Large Threads

Problem: Loading entire thread at once, performance issues.

Solution: Implement pagination:

const loadThread = async (eventId, cursor = null) => {
  const filter = {
    "#e": [eventId],
    kinds: [1],
    limit: 20,
    until: cursor
  }
  
  const replies = await fetchEvents(filter)
  return { replies, nextCursor: replies[replies.length - 1]?.created_at }
}

Testing

Mistake 28: Not Testing with Multiple Relays

Problem: App works with one relay but fails with others.

Solution: Test with:

Mistake 29: Not Testing Edge Cases

Critical tests:

Mistake 30: Not Monitoring Performance

Metrics to track:

Best Practices Checklist

Event Creation:

WebSocket:

Filters:

Threading:

Relays:

Security:

UX:

Testing:

Resources