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:
sendMessage() adds message to store with giftWrapId
- Subscription receives self-copy and adds with same ID
- 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
- Relay Service - Add
onComplete callback, implement race pattern
- Business Service - Pass callback for analytics instead of using early return result
- Message Hook - Remove duplicate message addition (if using subscriptions)
Testing Checklist
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
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:
User Impact:
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
2. The
onCompleteCallback PatternThe key insight for analytics/logging: the early return only has partial data. Use a callback for final results.
Why not just await all?
3. Fixing Duplicate Messages (Side Effect)
When using subscriptions that receive self-copies (NIP-17 pattern), you may get duplicates:
Problem:
sendMessage()adds message to store withgiftWrapIdSolution: Single source of truth - only the subscription adds messages:
Performance Comparison
Key Implementation Details
1. Thread Safety for Arrays
The
publishedRelaysandfailedRelaysarrays are mutated by concurrent promises. This is safe in JavaScript because:push()is atomicallSettledPromise.then()callback, after all writes complete2. Closure Reference for Callbacks
When passing
onComplete, the callback captures variables by reference:3. Error Handling
If ALL relays fail, the race still resolves (with empty string) because
allSettledPromisetriggers the fallback resolution.Files to Modify
onCompletecallback, implement race patternTesting Checklist
Commits Reference
fix: Remove duplicate message from sendMessage, let subscription handle itfix: Analytics now logs FULL relay results after background completionperf: Return after first relay success, complete rest in background