Skip to content

Messaging improvements #2

@psam21

Description

@psam21

First Relay Success Pattern - Detailed Implementation Guide

Problem Statement

When publishing events to multiple Nostr relays, the traditional approach waits for all relays to complete before returning to the caller:

// Traditional approach - SLOW
const results = await Promise.allSettled(relayPromises);
// User waits 15+ seconds if one relay times out

User Impact:

  • Message appears frozen for 10-15+ seconds waiting for slow/dead relays
  • Nostr.band, for example, often times out at 15 seconds
  • UX feels broken even though 6-7 relays succeed in <1 second

Solution: First Success Pattern

Return to the caller immediately when the first relay succeeds, then let remaining relays complete in the background.

Key Insight: For message delivery, you only need one relay to succeed for the message to be delivered. Additional relays are redundancy.


Implementation Architecture

1. The Race Pattern

async publishEvent(
  event: NostrEvent,
  onProgress?: (progress: RelayPublishingProgress) => void,
  onComplete?: (finalResult: RelayPublishingResult) => void  // NEW: callback for final results
): Promise<RelayPublishingResult> {
  
  const publishedRelays: string[] = [];
  const failedRelays: string[] = [];
  let firstSuccessResolved = false;
  let resolveFirstSuccess: ((relay: string) => void) | null = null;

  // Promise that resolves when the FIRST relay succeeds
  const firstSuccessPromise = new Promise<string>((resolve) => {
    resolveFirstSuccess = resolve;
  });

  // Start all relay publishes in parallel
  const publishPromises = RELAYS.map(async (relay, index) => {
    try {
      const result = await this.publishToRelay(relay, event);
      
      if (result.success) {
        publishedRelays.push(relay.url);
        
        // Trigger first success resolution (only once)
        if (!firstSuccessResolved && resolveFirstSuccess) {
          firstSuccessResolved = true;
          resolveFirstSuccess(relay.url);
        }
      } else {
        failedRelays.push(relay.url);
      }
    } catch (error) {
      failedRelays.push(relay.url);
    }
  });

  // Wait for all to settle (for background completion tracking)
  const allSettledPromise = Promise.allSettled(publishPromises).then(() => {
    // If no relay succeeded, resolve with empty string
    if (!firstSuccessResolved && resolveFirstSuccess) {
      resolveFirstSuccess('');
    }
  });

  // RACE: Wait for first success OR all failures
  const firstSuccessfulRelay = await Promise.race([
    firstSuccessPromise,
    allSettledPromise.then(() => ''),  // Returns '' if all failed
  ]);

  const success = firstSuccessfulRelay !== '';

  // Let remaining relays complete in background (fire-and-forget)
  void allSettledPromise.then(() => {
    // Log final results
    console.log(`Final: ${publishedRelays.length}/${RELAYS.length} relays succeeded`);
    
    // Call onComplete with FULL results for analytics
    if (onComplete) {
      onComplete({
        success: publishedRelays.length > 0,
        publishedRelays: [...publishedRelays],
        failedRelays: [...failedRelays],
        // ... other fields
      });
    }
  });

  // Return IMMEDIATELY after first success
  return {
    success,
    publishedRelays: success ? [firstSuccessfulRelay] : [],
    failedRelays: [],  // Not fully populated yet - use onComplete for full list
    // ... other fields
  };
}

2. The onComplete Callback Pattern

The key insight for analytics/logging: the early return only has partial data. Use a callback for final results.

// CALLER CODE
const result = await relayService.publishEvent(
  event,
  (progress) => updateUI(progress),          // Progress callback (optional)
  (finalResult) => logAnalytics(finalResult)  // Final results callback (NEW)
);

// `result` returns in ~300-500ms with first relay
// `finalResult` callback fires after all relays complete (background)

Why not just await all?

  • UX needs the early return for responsiveness
  • Analytics needs the full data for accuracy
  • The callback pattern gives you both

3. Fixing Duplicate Messages (Side Effect)

When using subscriptions that receive self-copies (NIP-17 pattern), you may get duplicates:

Problem:

  1. sendMessage() adds message to store with giftWrapId
  2. Subscription receives self-copy and adds with same ID
  3. Even with deduplication, race conditions can cause duplicates

Solution: Single source of truth - only the subscription adds messages:

// BEFORE (causes duplicates)
async sendMessage(content) {
  const result = await publishToRelays(event);
  addMessageToStore(message);  // ❌ First add
  // Subscription also adds it later ❌ Second add
}

// AFTER (single source of truth)
async sendMessage(content) {
  const result = await publishToRelays(event);
  // Don't add here - subscription handles it
  // Subscription receives self-copy and adds ✅ Only add
}

Performance Comparison

Metric Before After
Message appears 10-15 seconds 300-500ms
User perception "App is frozen" "Instant"
Actual delivery Same Same
Analytics accuracy Full data Full data (via callback)

Key Implementation Details

1. Thread Safety for Arrays

The publishedRelays and failedRelays arrays are mutated by concurrent promises. This is safe in JavaScript because:

  • Array push() is atomic
  • We only read the arrays in the allSettledPromise.then() callback, after all writes complete

2. Closure Reference for Callbacks

When passing onComplete, the callback captures variables by reference:

let eventForAnalytics: Event | undefined;

const result = await publish(event, undefined, (finalResult) => {
  // This runs AFTER the await returns
  // eventForAnalytics will be set by then
  logAnalytics(eventForAnalytics, finalResult);
});

eventForAnalytics = result.event;  // Set before callback runs

3. Error Handling

If ALL relays fail, the race still resolves (with empty string) because allSettledPromise triggers the fallback resolution.


Files to Modify

  1. Relay Service - Add onComplete callback, implement race pattern
  2. Business Service - Pass callback for analytics instead of using early return result
  3. Message Hook - Remove duplicate message addition (if using subscriptions)

Testing Checklist

  • Send message - appears in <1 second
  • Check analytics - shows 6/8 or 7/8 (not 1/8)
  • Send multiple messages quickly - no duplicates
  • Slow relay (Nostr.band) - doesn't block UX
  • All relays fail - error shown correctly

Commits Reference

  • fix: Remove duplicate message from sendMessage, let subscription handle it
  • fix: Analytics now logs FULL relay results after background completion
  • perf: Return after first relay success, complete rest in background

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions