# NIP-65 Outbox Implementation: Future Improvements This document outlines performance and UX improvements identified during the deep review of the outbox implementation. The "quick wins" (single author special case, in-memory LRU cache, relay selection progress indicator) have been implemented. These are the remaining optimizations for future consideration. --- ## 1. Request Deduplication **Problem**: Multiple simultaneous queries for the same relay list create redundant network requests. **Current Behavior**: ```typescript // If 3 components request same relay list simultaneously: async function fetchRelayList(pubkey: string) { return await fetch(`wss://relay/kind:10002/${pubkey}`); } // Result: 3 identical network requests ``` **Proposed Solution**: ```typescript // Map of in-flight promises to prevent redundant fetches private inFlightRequests = new Map>(); async fetchRelayList(pubkey: string): Promise { // Check if request already in flight const existing = this.inFlightRequests.get(pubkey); if (existing) { console.debug(`[RelayListCache] Deduplicating request for ${pubkey.slice(0, 8)}`); return existing; } // Create new promise and store it const promise = this.fetchFromNetwork(pubkey); this.inFlightRequests.set(pubkey, promise); // Clean up when done promise.finally(() => { this.inFlightRequests.delete(pubkey); }); return promise; } ``` **Expected Impact**: - Reduce redundant network requests by ~60-80% - Lower bandwidth usage and relay load - Faster response times when multiple components need same data **Implementation Location**: `src/services/relay-list-cache.ts` --- ## 2. Performance Metrics Collection **Problem**: No telemetry to track cache hit rates, timing, or degradation patterns in production. **Proposed Solution**: ```typescript // In src/services/relay-list-cache.ts interface PerformanceMetrics { memoryHits: number; dexieHits: number; networkFetches: number; totalRequests: number; avgMemoryTime: number; avgDexieTime: number; avgNetworkTime: number; lastReset: number; } class RelayListCache { private metrics: PerformanceMetrics = { memoryHits: 0, dexieHits: 0, networkFetches: 0, totalRequests: 0, avgMemoryTime: 0, avgDexieTime: 0, avgNetworkTime: 0, lastReset: Date.now(), }; async getOutboxRelays(pubkey: string): Promise { const start = performance.now(); this.metrics.totalRequests++; // Check memory cache const memCached = this.memoryCache.get(pubkey); if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { this.metrics.memoryHits++; this.updateAvgTime('memory', performance.now() - start); return memCached.write; } // Check Dexie const cached = await this.get(pubkey); if (cached) { this.metrics.dexieHits++; this.updateAvgTime('dexie', performance.now() - start); return cached.write; } // Network fetch this.metrics.networkFetches++; this.updateAvgTime('network', performance.now() - start); return null; } getMetrics(): PerformanceMetrics & { memoryCacheHitRate: number; dexieCacheHitRate: number; overallCacheHitRate: number; } { const total = this.metrics.totalRequests; return { ...this.metrics, memoryCacheHitRate: total > 0 ? this.metrics.memoryHits / total : 0, dexieCacheHitRate: total > 0 ? this.metrics.dexieHits / total : 0, overallCacheHitRate: total > 0 ? (this.metrics.memoryHits + this.metrics.dexieHits) / total : 0, }; } } ``` **Expected Impact**: - Visibility into cache effectiveness - Data-driven optimization decisions - Production performance monitoring - Identify degradation patterns early **Implementation Location**: `src/services/relay-list-cache.ts` --- ## 3. Fallback Warning System **Problem**: Users don't know when their queries fall back to aggregator relays, causing confusion about incomplete results. **Current Behavior**: Silent fallback with only console.debug logs **Proposed Solution**: ```typescript // In src/services/relay-selection.ts interface RelaySelectionResult { relays: string[]; reasoning: RelaySelectionReasoning[]; isOptimized: boolean; fallbacksUsed?: { pubkey: string; reason: 'no-relay-list' | 'timeout' | 'invalid-list'; }[]; } // In selectRelaysForFilter: if (!cachedRelayList) { console.warn(`[RelaySelection] No relay list for ${pubkey.slice(0, 8)}, using fallback`); result.fallbacksUsed = result.fallbacksUsed || []; result.fallbacksUsed.push({ pubkey, reason: 'no-relay-list' }); } ``` **UI Component** (`src/components/ReqViewer.tsx`): ```tsx {reasoning && reasoning.some(r => r.isFallback) && (
Using fallback relays for {reasoning.filter(r => r.isFallback).length} users (relay lists unavailable)
)} ``` **Expected Impact**: - Users understand why results may be incomplete - Encourages fixing relay list issues - Better debugging experience - Transparency about query execution **Implementation Locations**: - `src/services/relay-selection.ts` - `src/components/ReqViewer.tsx` --- ## 4. Speculative Prefetching **Problem**: Cold start delays occur frequently because relay lists aren't cached until needed. **Proposed Solution**: ```typescript // In src/services/relay-list-cache.ts class RelayListCache { /** * Prefetch relay lists for a set of pubkeys in the background * Useful for warming cache with user's follows */ async prefetch(pubkeys: string[]): Promise { console.log(`[RelayListCache] Prefetching ${pubkeys.length} relay lists`); // Filter out already cached const uncached = await Promise.all( pubkeys.map(async (pubkey) => { const has = await this.has(pubkey); return has ? null : pubkey; }) ); const toPrefetch = uncached.filter((p): p is string => p !== null); if (toPrefetch.length === 0) { console.debug('[RelayListCache] All relay lists already cached'); return; } // Fetch in background (don't await - fire and forget) const eventStore = getEventStore(); eventStore.query({ kinds: [10002], authors: toPrefetch }); } } // Hook for automatic prefetching // In src/hooks/usePrefetchRelayLists.ts export function usePrefetchRelayLists() { const profile = useCurrentProfile(); useEffect(() => { if (!profile) return; // Get user's follows from contact list (kind 3) const contacts = profile.tags .filter(tag => tag[0] === 'p') .map(tag => tag[1]); if (contacts.length > 0) { console.log(`[Prefetch] Warming cache with ${contacts.length} follows`); relayListCache.prefetch(contacts.slice(0, 50)); // Limit to top 50 } }, [profile]); } ``` **Integration**: Call `usePrefetchRelayLists()` in App.tsx or after login **Expected Impact**: - Reduce cold start delays by ~80% for common queries - Better UX for new users - Proactive cache warming - Minimal bandwidth cost (background fetch) **Implementation Locations**: - `src/services/relay-list-cache.ts` - `src/hooks/usePrefetchRelayLists.ts` --- ## 5. Adaptive Timeout **Problem**: Fixed 1000ms timeout is too long for consistently slow relays but may be too short for slow networks. **Proposed Solution**: ```typescript // In src/services/relay-selection.ts interface RelayHealthMetrics { avgResponseTime: number; successRate: number; lastSuccess: number; failureCount: number; } class RelayHealthTracker { private metrics = new Map(); recordSuccess(pubkey: string, responseTime: number) { const existing = this.metrics.get(pubkey) || { avgResponseTime: 0, successRate: 1, lastSuccess: Date.now(), failureCount: 0, }; // Exponential moving average existing.avgResponseTime = 0.7 * existing.avgResponseTime + 0.3 * responseTime; existing.successRate = 0.9 * existing.successRate + 0.1 * 1; existing.lastSuccess = Date.now(); this.metrics.set(pubkey, existing); } recordFailure(pubkey: string) { const existing = this.metrics.get(pubkey) || { avgResponseTime: 1000, successRate: 0, lastSuccess: 0, failureCount: 0, }; existing.successRate = 0.9 * existing.successRate + 0.1 * 0; existing.failureCount++; this.metrics.set(pubkey, existing); } getTimeout(pubkey: string): number { const metrics = this.metrics.get(pubkey); if (!metrics) return 1000; // Default // Adaptive: 2x average response time, minimum 300ms, maximum 2000ms const adaptive = Math.max(300, Math.min(2000, metrics.avgResponseTime * 2)); // Reduce timeout for consistently slow relays if (metrics.avgResponseTime > 800 && metrics.successRate < 0.5) { return Math.min(500, adaptive); } return adaptive; } } ``` **Expected Impact**: - Faster queries for reliable relays (300-500ms vs 1000ms) - Reduce wasted time on slow relays - Better resource utilization - Adaptive to network conditions **Implementation Location**: `src/services/relay-selection.ts` --- ## 6. Incremental Relay Selection **Problem**: Users wait for all relay lists before seeing any results, even if some are cached. **Proposed Solution**: ```typescript // In src/services/relay-selection.ts export async function selectRelaysIncremental( eventStore: IEventStore, filter: NostrFilter, options?: RelaySelectionOptions, onUpdate?: (partial: RelaySelectionResult) => void ): Promise { const authors = filter.authors || []; const pTags = filter["#p"] || []; // Phase 1: Return cached relays immediately const cachedPointers = await Promise.all( authors.map(async (pubkey) => { const cached = await relayListCache.getOutboxRelays(pubkey); return cached ? { pubkey, relays: cached } : null; }) ); const initialRelays = cachedPointers .filter((p): p is NonNullable => p !== null) .flatMap(p => p.relays); if (initialRelays.length > 0 && onUpdate) { onUpdate({ relays: initialRelays, reasoning: [], isOptimized: true, }); } // Phase 2: Fetch missing relay lists const uncachedAuthors = authors.filter((_, i) => !cachedPointers[i]); if (uncachedAuthors.length > 0) { // Fetch and update as they arrive const subscription = eventStore .query({ kinds: [10002], authors: uncachedAuthors }) .subscribe((event) => { relayListCache.set(event); // Trigger incremental update if (onUpdate) { selectRelaysForFilter(eventStore, filter, options) .then(onUpdate); } }); // Wait for timeout, then complete await new Promise(resolve => setTimeout(resolve, options?.timeout || 1000) ); subscription.unsubscribe(); } // Phase 3: Final selection return selectRelaysForFilter(eventStore, filter, options); } ``` **Hook Integration**: ```typescript // In src/hooks/useOutboxRelays.ts export function useOutboxRelaysIncremental( filter: NostrFilter, options?: RelaySelectionOptions ) { const [result, setResult] = useState({ relays: options?.fallbackRelays || [], reasoning: [], isOptimized: false, }); useEffect(() => { selectRelaysIncremental( eventStore, filter, options, setResult // Update as relay lists arrive ); }, [filter, options]); return result; } ``` **Expected Impact**: - Show initial results within 10-50ms (cached relays) - Progressive enhancement as more relay lists arrive - Better perceived performance - Users can start seeing events immediately **Implementation Locations**: - `src/services/relay-selection.ts` - `src/hooks/useOutboxRelays.ts` --- ## 7. Cache Warming UI **Problem**: Users have no way to manually refresh stale relay lists or warm the cache proactively. **Proposed Solution**: ```tsx // In src/components/settings/RelayListSettings.tsx export function RelayListSettings() { const [stats, setStats] = useState(null); const [refreshing, setRefreshing] = useState(false); useEffect(() => { relayListCache.getStats().then(setStats); }, []); const handleRefreshAll = async () => { setRefreshing(true); // Clear cache await relayListCache.clear(); // Prefetch follows const profile = await getCurrentProfile(); if (profile) { const follows = getFollows(profile); await relayListCache.prefetch(follows.slice(0, 100)); } setRefreshing(false); // Update stats const newStats = await relayListCache.getStats(); setStats(newStats); }; const handleRefreshStale = async () => { // Only refresh entries older than 12 hours const allEntries = await db.relayLists.toArray(); const stale = allEntries .filter(entry => Date.now() - entry.updatedAt > 12 * 60 * 60 * 1000) .map(entry => entry.pubkey); if (stale.length > 0) { await relayListCache.prefetch(stale); } }; return (

Relay List Cache

{stats && (
Cached Users
{stats.count}
Memory Cache
{stats.memoryCacheSize} / {stats.memoryCacheLimit}
)}

Cache entries expire after 24 hours. Refresh to get latest relay lists.

); } ``` **Expected Impact**: - User control over cache freshness - Manual warming for important follows - Visibility into cache state - Proactive performance management **Implementation Location**: `src/components/settings/RelayListSettings.tsx` --- ## 8. Diagnostic Panel **Problem**: When queries fail or perform poorly, users and developers have no visibility into relay selection reasoning. **Proposed Solution**: ```tsx // In src/components/ReqViewer.tsx interface RelayDiagnosticsProps { reasoning: RelaySelectionReasoning[]; isOptimized: boolean; phase: RelaySelectionPhase; } function RelayDiagnostics({ reasoning, isOptimized, phase }: RelayDiagnosticsProps) { const [expanded, setExpanded] = useState(false); const metrics = relayListCache.getMetrics(); return (
{expanded && (
{/* Selection Status */}
Selection Status
Phase: {phase} • Optimized: {isOptimized ? 'Yes' : 'No (using fallbacks)'}
{/* Cache Performance */}
Cache Performance
Memory Hits
{(metrics.memoryCacheHitRate * 100).toFixed(1)}%
Dexie Hits
{(metrics.dexieCacheHitRate * 100).toFixed(1)}%
Network Fetches
{metrics.networkFetches}
{/* Selected Relays */}
Selected Relays
{reasoning.map((r, i) => (
{r.isFallback ? '⚠' : '✓'} {r.relay} ({r.writers.length}w {r.readers.length}r)
))}
{/* Coverage Analysis */}
Coverage Analysis
{reasoning.filter(r => !r.isFallback).length} optimized relays, {' '}{reasoning.filter(r => r.isFallback).length} fallback relays
)}
); } ``` **Expected Impact**: - Visibility into relay selection process - Easier debugging of query issues - Performance metrics at a glance - Educational for understanding NIP-65 **Implementation Location**: `src/components/ReqViewer.tsx` --- ## Priority Recommendations Based on impact vs. effort analysis: ### High Priority (Implement Next) 1. **Request Deduplication** - Low effort, high impact on redundant queries 2. **Fallback Warning System** - Low effort, significant UX improvement 3. **Performance Metrics Collection** - Medium effort, critical for production monitoring ### Medium Priority 4. **Speculative Prefetching** - Medium effort, large impact for cold start reduction 5. **Diagnostic Panel** - Medium effort, valuable for debugging and transparency ### Lower Priority (Nice to Have) 6. **Adaptive Timeout** - High effort, moderate impact 7. **Incremental Relay Selection** - High effort, moderate UX improvement 8. **Cache Warming UI** - Low effort, but user-initiated edge case --- ## Performance Impact Summary | Improvement | Expected Gain | Current | Target | |-------------|---------------|---------|--------| | Request Deduplication | -60% redundant requests | N/A | N/A | | Speculative Prefetching | -80% cold start delays | 1040ms | ~200ms | | Adaptive Timeout | -40% wasted time | 1000ms | 300-500ms | | Incremental Selection | Perceived perf | 1040ms | 10-50ms first response | | Performance Metrics | Monitoring | None | Full telemetry | --- ## Testing Recommendations For each improvement: 1. Add unit tests for core logic 2. Add integration tests for timing/caching behavior 3. Manual testing with slow networks (throttle to 3G) 4. Measure before/after metrics with realistic data 5. Test fallback scenarios (cache miss, timeout, error) --- *Document created: 2025-01-XX* *Quick wins implemented: Single author special case, in-memory LRU cache, relay selection progress indicator* *Future work: These improvements are prioritized but not yet scheduled for implementation*