Pattern 4: Performance & Perceived Speed
Problem Statement
Current State
Performance issues killing retention:
- [Data:19] Apps described as “slow and clunky” compared to Twitter, with “clients blasting entire message history”
- [Data:20] Multiple crash reports: Primal Android crashes (Sept & Oct 2025), Amethyst community crashes, Nostur iOS crashes
- [Data:21] Database performance identified as “core bottleneck” causing slow feed loading
- [User:11] Users report needing “5-6 different clients to work around bugs” with all apps in “alpha state”
- [Data:22] Amethyst battery drain from background video playback loops and excessive relay connections (up to 100 relays)
The perception gap: Nostr apps may not be objectively slower than centralized alternatives, but they feel slower due to:
- No loading state indicators
- Multi-relay coordination exposed to users as delays
- Missing skeleton screens and progressive loading
- Lack of optimistic UI patterns
The retention impact:
- Users compare to Twitter/Instagram’s polished performance
- “Slow” apps perceived as low-quality or alpha-state
- Performance issues compound trust problems from unreliable core interactions
- First impression matters: slow initial load = immediate bounce
Root Causes
- Multi-relay queries without optimization: Waiting for slowest relay instead of progressive loading
- No caching strategy: Repeated queries for same data
- Synchronous event validation: Blocking UI thread with cryptographic checks
- Missing perceived performance patterns: No skeleton screens, optimistic UI, or progressive enhancement
- Inefficient rendering: Re-rendering entire feed instead of incremental updates
- Large bundle sizes: Slow initial page load on web clients
Why This Matters
“Users don’t distinguish between ‘objectively fast’ and ‘feels fast.’ Perception is reality.” [Research:31]
Performance Impact on Bounce Rates: [Research:32] [Research:33]
- 32% bounce rate increase from 1 to 3 seconds load time
- 90% increase at 5 seconds
- 53% of mobile visitors leave if page takes >3 seconds to load
Speed directly impacts retention. Every second of delay costs users.
Additional research: [Research:34] [Research:35] [Research:31]
- Perceived performance more important than actual performance
- Visual feedback during delays dramatically improves satisfaction
- 100ms response threshold for “instant” feeling
Universal Principles
These principles apply to any application prioritizing performance and user experience.
1. Perceived Performance
Research backing: [Research:34] [Research:35]
Core insight: How fast an app feels matters more than benchmark speeds. A 500ms operation with great feedback feels faster than a 200ms operation with no indication. [Research:34] Users perceive skeleton screens as 30% faster than spinners for identical wait times.
Key strategies:
- Skeleton screens for 2-10 second loads [Research:35]
- Progressive loading (show something immediately, refine later)
- Optimistic UI (assume success, update immediately) [Research:36]
- Instant feedback (respond within 100ms, even if processing continues) [Research:31]
Examples from mainstream apps:
- Twitter/X: [Example:11] Algorithm runs 5 billion times/day, completes each execution in <1.5 seconds on average
- Instagram: [Example:12] AI ranks 500 posts per user by engagement predictions, emphasizes “engage in first 3 seconds”
- TikTok: [Example:13] Watch time prioritized—content that “stops scrolls cold” with grabber upfront
- Starbucks PWA: [Example:14] Offline functionality led to 2x increase in daily active users and 53% rise in order completions
2. The 100ms / 1s / 10s Rule
Response Time Thresholds (Updated January 2024) [Research:31]
- <100ms: Feels instantaneous - user perceives direct manipulation
- <1 second: Maintains flow of thought - no interruption to mental process
- <5 seconds: Keeps attention - Nielsen updated threshold from 10s to 5s in 2024
For social apps:
- Button clicks must respond <100ms (disable, show spinner, change color)
- Feed refresh should show first content <1s
- Full feed load acceptable up to 5s if progressive
What to do for each:
| Response time | What to do |
|---|---|
| 0–100ms | Show immediate UI change (button press, loading indicator) |
| 100ms–1s | Use optimistic UI, show skeleton screens |
| 1–5s | Progressive loading, show percentage/status, allow cancellation |
| >5s | Must be background operation with notification on completion |
3. Skeleton Screens
Research backing: [Research:34] [Research:35]
Why skeleton screens:
- Users perceive skeleton screens as 30% faster than identical sites with spinners [Research:34]
- Skeleton screens feel 20% faster than spinners for identical wait times [Research:34]
- Set expectations for content structure [Research:35]
- Maintain visual stability (no layout shifts)
- Signal that app is working, not frozen
Best practices: [Research:34] [Research:35] [Research:37]
- Match final content structure exactly
- Subtle pulsing/shimmer animation (Material Design 3 Expressive indicators for <5s waits)
- Use for 2-10 second loads (spinners for <2s single modules, progress bars >10s)
- Avoid “header + footer only” skeletons - show content areas
- Best for container-based components: tiles, lists, grids, data tables, cards
Progressive loading pattern:
1. Show skeleton immediately (0ms)
2. Load critical above-the-fold content first
3. Display as soon as available (500ms-1s)
4. Load remaining content in background
5. Lazy load images as user scrolls4. Optimistic UI Patterns
Research backing: [Research:36] [Research:38] (See also: Pattern 3)
Quick summary: Update UI immediately assuming success, rollback on failure. React 19’s useOptimistic Hook provides elegant solution [Research:36].
Performance benefit: Eliminates perceived latency for high-success-rate operations. Essential for social media: “seeing a comment appear the moment it’s clicked” [Research:36].
Apply to:
- Likes/reactions (instant visual change) - Facebook’s like button immediately turns blue [Research:38]
- Follow/unfollow (immediate button state)
- Post drafts (save locally, sync in background)
- Profile updates (show changes immediately)
5. Caching & Offline-First
Research backing: [Research:39] [Research:40]
Core principle: Never fetch data twice if you can avoid it.
Caching layers:
- Memory cache: Active session data (current feed)
- Storage cache: IndexedDB for data, Cache API for assets [Research:40]
- Service Worker cache: Static assets, offline functionality [Research:39]
- Relay-level cache: Smart relay selection based on past performance
Cache strategies: [Research:40]
- Cache First or Network First based on application needs
- Stale-while-revalidate for dynamic content: serve cached immediately while async network fetch updates cache
- 2024 HTTP Archive data: Nearly 45% of high-rated installable PWAs use hybrid approach [Research:40]
Cache invalidation strategies:
- Time-based expiry (profiles: 1 hour, posts: until user refreshes)
- Event-based (new post = invalidate feed cache)
- User-triggered (pull-to-refresh)
Offline-first benefits:
- App works instantly on return visits
- Read cached content while new content loads
- Progressive enhancement as network becomes available
6. Bundle Size & Initial Load
Research backing: [Research:41] [Research:42] [Research:43]
The problem: Large JavaScript bundles delay time-to-interactive.
Optimization strategies:
- Code splitting: Load only what’s needed for current route. Dynamic
import()expressions [Research:41] - Tree shaking: Remove unused code - can reduce bundle sizes by 20-50% [Research:41]
- Lazy loading: Defer non-critical components
- Compression: Gzip/Brotli on all assets
- CDN distribution: Serve assets from edge locations
- React Server Components: [Research:43] Real-world example: 40% improvement in load times, 15% increase in conversion rates
Performance budgets (2024): [Research:42]
- Target: <250KB gzipped initial bundle for fast 3G
- Total bundle: <1MB gzipped for good UX
- Critical path: <50KB for above-the-fold content
- Images: WebP/AVIF formats, responsive images [Research:44]
7. Incremental Rendering
Research backing: [Research:45] [Research:46] [Research:47]
The problem: Rendering 1000+ items in DOM kills performance.
Solutions:
- Virtual scrolling: Only render visible items + small buffer [Research:45] [Research:46]
- Significantly reduces: DOM updates, memory usage, render time [Research:46]
- TanStack Virtual most popular library as of Nov 2024 [Research:47]
- Best practices: Wrap row components in
React.memo(), render 1-2 additional items above/below visible area (overscan) [Research:47] - Pagination: Load 20-50 items at a time
- Infinite scroll with windowing: Recycle DOM nodes as user scrolls
- React.memo / useMemo: Prevent unnecessary re-renders
Example: Feed rendering
// Bad: Render all 1000 posts
{posts.map(post => <Post key={post.id} post={post} />)}
// Good: Virtual scrolling
<VirtualList
items={posts}
height={window.innerHeight}
itemHeight={200}
renderItem={post => <Post post={post} />}
/>Nostr-Specific Considerations
Challenge 1: Multi-Relay Query Latency
The problem: Querying 10 relays in parallel means waiting for the slowest one. [Data:23] Only 639 relays online globally, with varying performance.
Current failures:
- App waits for all relays before showing any content
- One slow/dead relay blocks entire feed load
- No progressive display as results arrive
- Users see blank screen or spinner for 5-10 seconds
- [Data:24] Clients can open “hundreds of WebSocket connections simultaneously” causing performance overhead
- [Research:48] Academic research: “Replication of posts across relays enhances censorship-resistance but introduces significant overhead”
Solutions:
Pattern A: Race-based loading
// Show content as soon as ANY relay responds
const posts = []
const relayPromises = relays.map(relay =>
relay.query(filter).then(events => {
posts.push(...events)
renderPosts(posts) // Update UI immediately
})
)
// Don't wait for all - update progressively
Pattern B: Timeout with fallback
const FAST_RELAY_TIMEOUT = 1000 // 1s
const SLOW_RELAY_TIMEOUT = 5000 // 5s
// Query fast relays first
const fastResults = await Promise.race([
queryFastRelays(filter),
delay(FAST_RELAY_TIMEOUT)
])
renderPosts(fastResults) // Show immediately
// Continue querying slow relays in background
querySlowRelays(filter).then(slowResults => {
mergePosts(fastResults, slowResults)
renderPosts(allPosts)
})Pattern C: Relay performance tracking
// Track relay latency over time
interface RelayStats {
avgLatency: number
successRate: number
lastSuccess: timestamp
}
// Query fastest relays first, slowest last
const sortedRelays = relays.sort((a, b) =>
stats[a.url].avgLatency - stats[b.url].avgLatency
)Challenge 2: Event Signature Verification Overhead
The problem: Cryptographic signature verification is CPU-intensive.
Current approach: Verify every event signature synchronously on arrival.
Performance impact:
- Blocks UI thread
- ~1-5ms per event verification
- 100 events = 100-500ms of blocking
Critical security vs performance trade-off: [Data:25] Black Hat USA 2025 research found several clients (Damus, Iris, FreeFrom, Plebstr past versions) omit signature verification entirely to improve speed. Recommendation: Enforce mandatory verification in NIP-01, but use Web Workers to avoid blocking.
Solutions:
Pattern A: Web Workers for verification
// Offload verification to background thread
const verificationWorker = new Worker('verify-worker.js')
verificationWorker.postMessage({ events })
verificationWorker.onmessage = ({ data: { verified, invalid } }) => {
renderPosts(verified)
logInvalid(invalid)
}Pattern B: Lazy verification
// Verify on-demand, not upfront
function renderPost(event) {
// Show immediately with "Verifying..." badge
const verified = verifyLazily(event)
if (!verified) {
hidePost(event) // Remove if invalid
showWarning("Invalid signature detected")
}
}Pattern C: Trust-based verification
// Skip verification for trusted sources
const trustedPubkeys = getUserFollows() // WoT
if (trustedPubkeys.includes(event.pubkey)) {
return event // Skip verification
} else {
return verifyEvent(event) // Verify unknown sources
}Challenge 3: Caching Strategy for Nostr Data
The problem: No built-in caching in Nostr protocol.
Current solutions: [Data:26] [Data:27]
- NDK Dexie-based cache (browser only)
- Primal caching service (relay-level, open sourced Dec 2024)
- nostrdb: “Unfairly fast” embedded database with zero-copy, O(1) access, memory-mapped LMDB
- Strfry performance tuning: queryTimesliceBudgetMicroseconds = 5000
What to cache:
- Profiles (kind 0): High reuse, change infrequently
- Contact lists (kind 3): Moderate reuse
- Recent posts (kind 1): High volume, user expects fresh content
- Relay metadata: Connection status, latency stats [Data:23]
Cache implementation:
interface NostrCache {
profiles: Map<pubkey, Profile> // 1 hour TTL
contacts: Map<pubkey, ContactList> // 1 hour TTL
recentPosts: LRU<eventId, Event> // Keep 500 most recent
relayStats: Map<url, RelayStats> // Persistent
}
async function getProfile(pubkey: string): Promise<Profile> {
// Check cache first
const cached = cache.profiles.get(pubkey)
if (cached && !isExpired(cached)) {
return cached.data
}
// Fetch from relays
const profile = await fetchProfile(pubkey)
cache.profiles.set(pubkey, { data: profile, expires: Date.now() + 3600000 })
return profile
}Challenge 4: Feed Rendering Performance
The problem: Nostr feeds mix content from multiple relays with duplicate events.
Performance challenges:
- Deduplication overhead (comparing 1000s of event IDs)
- Sorting by timestamp (O(n log n))
- Re-rendering entire list on new events
Solutions:
Pattern A: Efficient deduplication
// Use Set for O(1) lookup
const seenEventIds = new Set<string>()
function deduplicateEvents(events: Event[]): Event[] {
return events.filter(event => {
if (seenEventIds.has(event.id)) return false
seenEventIds.add(event.id)
return true
})
}Pattern B: Incremental sort
// Don't re-sort entire feed, insert new events in correct position
function insertEvent(feed: Event[], newEvent: Event): Event[] {
const index = binarySearch(feed, newEvent, (a, b) => b.created_at - a.created_at)
feed.splice(index, 0, newEvent)
return feed
}Pattern C: Virtual scrolling with React
import { FixedSizeList } from 'react-window'
<FixedSizeList
height={window.innerHeight}
itemCount={posts.length}
itemSize={200}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<Post post={posts[index]} />
</div>
)}
</FixedSizeList>Challenge 5: Image Loading Performance
The problem: Large images slow down feed scrolling.
Nostr-specific considerations:
- Images hosted on external servers (varying speeds)
- No CDN control
- Users may post massive unoptimized images
Native lazy loading performance: [Research:44]
- On 4G: 97.5% of lazy-loaded images fully loaded within 10ms of becoming visible
- On 2G: 92.6% loaded within 10ms
- Modern formats: WebP, AVIF more efficient than JPEG/PNG [Research:44]
Solutions:
Pattern A: Lazy loading
<img
src={imageUrl}
loading="lazy"
decoding="async"
alt={altText}
/>Pattern B: Responsive images with proxy
// Use image proxy for optimization
const optimizedUrl = imageUrl.startsWith('http')
? `https://images.weserv.nl/?url=${encodeURIComponent(imageUrl)}&w=600&output=webp`
: imageUrl
<img src={optimizedUrl} alt={altText} />Pattern C: Progressive image loading
// Show blurred placeholder, load full image
const [imageLoaded, setImageLoaded] = useState(false)
<div className="image-container">
<img
src={thumbnailUrl}
className={imageLoaded ? 'hidden' : 'blur'}
/>
<img
src={fullImageUrl}
onLoad={() => setImageLoaded(true)}
className={imageLoaded ? 'visible' : 'hidden'}
/>
</div>Pattern Library: Concrete Solutions
Pattern A: Fast Initial Feed Load
Problem: Users see blank screen for 5+ seconds while feed loads.
Solution: Progressive loading with skeleton screens.
Implementation:
function Feed() {
const [posts, setPosts] = useState<Event[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadFeedProgressive()
}, [])
async function loadFeedProgressive() {
// 1. Show skeleton immediately
setLoading(true)
// 2. Load from cache first (instant)
const cachedPosts = await getCachedPosts()
if (cachedPosts.length > 0) {
setPosts(cachedPosts)
setLoading(false) // Remove skeleton, show cached
}
// 3. Query fast relays (1s timeout)
const fastRelays = getFastRelays() // Based on historical performance
const fastPosts = await Promise.race([
queryRelays(fastRelays, { kinds: [1], limit: 50 }),
delay(1000)
])
if (fastPosts.length > 0) {
setPosts(mergePosts(cachedPosts, fastPosts))
setLoading(false)
}
// 4. Query remaining relays in background (5s timeout)
const slowRelays = getSlowRelays()
const slowPosts = await Promise.race([
queryRelays(slowRelays, { kinds: [1], limit: 50 }),
delay(5000)
])
setPosts(mergePosts(posts, slowPosts))
}
if (loading && posts.length === 0) {
return <FeedSkeleton count={10} />
}
return (
<VirtualList
items={posts}
renderItem={post => <Post post={post} />}
/>
)
}Validation:
- Time to first content: <1 second
- Time to full content: <3 seconds
- User sees progress, not blank screen
Pattern B: Optimized Profile Loading
Problem: Loading same profile data repeatedly across feed.
Solution: Aggressive caching with background refresh.
Implementation:
class ProfileCache {
private cache = new Map<string, CachedProfile>()
private pending = new Map<string, Promise<Profile>>()
async get(pubkey: string): Promise<Profile> {
// 1. Return cached if fresh
const cached = this.cache.get(pubkey)
if (cached && Date.now() - cached.timestamp < 3600000) {
return cached.profile
}
// 2. Deduplicate concurrent requests
const existing = this.pending.get(pubkey)
if (existing) return existing
// 3. Fetch from relays
const promise = this.fetchProfile(pubkey)
this.pending.set(pubkey, promise)
const profile = await promise
this.cache.set(pubkey, { profile, timestamp: Date.now() })
this.pending.delete(pubkey)
return profile
}
private async fetchProfile(pubkey: string): Promise<Profile> {
const filter = { kinds: [0], authors: [pubkey], limit: 1 }
const events = await queryRelays(getFastRelays(), filter)
return events[0] ? JSON.parse(events[0].content) : null
}
}
const profileCache = new ProfileCache()
// Usage in component
function Post({ post }) {
const [author, setAuthor] = useState<Profile | null>(null)
useEffect(() => {
profileCache.get(post.pubkey).then(setAuthor)
}, [post.pubkey])
return (
<div>
<Avatar src={author?.picture} name={author?.name} />
<Content>{post.content}</Content>
</div>
)
}Pattern C: Smooth Infinite Scroll
Problem: Feed stutters when loading more posts.
Solution: Intersection Observer with preloading buffer.
Implementation:
function InfiniteFeed() {
const [posts, setPosts] = useState<Event[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const loadMoreRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore()
}
},
{ threshold: 0.5, rootMargin: '200px' } // Trigger before bottom
)
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current)
}
return () => observer.disconnect()
}, [hasMore, loading])
async function loadMore() {
setLoading(true)
const oldestTimestamp = posts[posts.length - 1]?.created_at || Date.now()
const filter = {
kinds: [1],
until: oldestTimestamp,
limit: 20
}
const newPosts = await queryRelays(getFastRelays(), filter)
if (newPosts.length === 0) {
setHasMore(false)
} else {
setPosts([...posts, ...newPosts])
}
setLoading(false)
}
return (
<div>
<VirtualList items={posts} renderItem={post => <Post post={post} />} />
<div ref={loadMoreRef}>
{loading && <Spinner />}
{!hasMore && <p>No more posts</p>}
</div>
</div>
)
}Pattern D: Background Sync with Service Worker
Problem: Users must manually refresh to see new posts.
Solution: Service Worker syncs in background, shows notification.
Implementation:
// service-worker.js
self.addEventListener('periodicsync', event => {
if (event.tag === 'sync-feed') {
event.waitUntil(syncFeed())
}
})
async function syncFeed() {
const lastSync = await getLastSyncTime()
const filter = { kinds: [1], since: lastSync, limit: 50 }
const newPosts = await queryRelays(getFastRelays(), filter)
if (newPosts.length > 0) {
await cacheNewPosts(newPosts)
// Show notification
self.registration.showNotification('New posts available', {
body: `${newPosts.length} new posts from your feed`,
icon: '/icon.png',
tag: 'feed-update'
})
}
await setLastSyncTime(Date.now())
}
// Register in app
if ('serviceWorker' in navigator && 'periodicSync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.register('/sw.js')
await registration.periodicSync.register('sync-feed', {
minInterval: 60 * 60 * 1000 // 1 hour
})
}Anti-Patterns: What Not To Do
Anti-Pattern 1: Synchronous Blocking Operations
What it looks like:
function loadFeed() {
showSpinner()
// Blocks UI for 10+ seconds
for (const relay of relays) {
const posts = relay.query(filter) // Synchronous
allPosts.push(...posts)
}
renderFeed(allPosts)
hideSpinner()
}Why it fails:
- UI completely frozen during load
- No way to cancel or show progress
- User can’t interact with app
What to do instead:
- Async/await with Promise.all()
- Progressive rendering as results arrive
- Show skeleton screen, not spinner
Anti-Pattern 2: No Loading States
What it looks like:
- User taps “Refresh”
- Nothing happens for 3 seconds
- Feed suddenly updates
- User confused, taps multiple times
Why it fails:
- User doesn’t know if action registered
- No perceived progress
- Leads to duplicate requests
What to do instead:
- Immediate visual feedback (<100ms)
- Loading indicator (spinner, skeleton, progress)
- Disable button during load
Anti-Pattern 3: Waiting for All Relays
What it looks like:
// Wait for ALL relays before showing anything
const results = await Promise.all(
relays.map(r => r.query(filter))
)
renderFeed(results.flat())Why it fails:
- Slowest relay determines perceived speed
- One dead relay = 30s timeout
- User sees blank screen entire time
What to do instead:
- Show results as they arrive (Promise.race)
- Timeout slow relays (1-5s)
- Cache previous content while refreshing
Anti-Pattern 4: No Caching Strategy
What it looks like:
- User navigates away and back
- Feed reloads from scratch
- Same profile images re-downloaded 100 times
- Wastes bandwidth and time
Why it fails:
- Unnecessary network requests
- Slow perceived performance
- Poor offline experience
What to do instead:
- Cache profiles, posts, static assets
- Use stale-while-revalidate pattern
- Persist cache across sessions
Anti-Pattern 5: Rendering Entire Feed on Update
What it looks like:
function onNewPost(newPost) {
const updatedFeed = [newPost, ...posts]
rerenderEntireFeed(updatedFeed) // Re-renders 1000+ items
}Why it fails:
- Massive performance hit
- Scroll position jumps
- Janky user experience
What to do instead:
- Virtual scrolling (only render visible)
- Incremental updates (insert single item)
- React.memo / key optimization
Validation Checklist
Performance Metrics
Core Web Vitals (Updated March 2024): [Research:49] [Research:50]
- Largest Contentful Paint (LCP): <2.5s
- Interaction to Next Paint (INP): <200ms (NEW: Replaced FID in March 2024) [Research:49]
- INP measures ALL interactions (not just first like FID)
- Includes input delay + event handler processing + browser paint time
- Good: <200ms, Poor: >500ms [Research:50]
- Cumulative Layout Shift (CLS): <0.1
Note: FID (First Input Delay) was officially deprecated and removed from Google Search Console in March 2024. [Research:49]
Custom Metrics:
- Time to First Content: <1 second (cached) or <3 seconds (cold start)
- Feed Load Time: <3 seconds for 50 posts
- Profile Load Time: <500ms (cached) or <2 seconds (network)
- Infinite Scroll: <1 second to load next page
- Button Response Time: <100ms visual feedback
User Experience Metrics
Perceived Performance:
- Users rate app as “fast” or “very fast” (survey)
- <5% of users complain about speed
- Skeleton screens shown for all >1s operations
- No “frozen UI” moments (>500ms blocking)
Comparison to Mainstream:
- Feed loads as fast or faster than Twitter/Instagram
- Scrolling smoothness comparable to native apps (60fps target [Research:51])
- Image loading doesn’t block interaction
Business Impact Metrics (Based on Industry Research): [Research:52] [Research:53]
- Meeting Core Web Vitals targets correlates with 24% reduction in page abandonment [Research:53]
- Good LCP can lead to up to 61% increase in conversion rate (Rakuten case study) [Research:52]
- Performance improvements typically show 8-10% conversion increase per 0.1s load time improvement [Research:53]
Technical Metrics
Relay Performance:
- Average query time: <2 seconds per relay
- Relay timeout enforced: 5 seconds max
- Fast relay identified and prioritized
- Dead relays removed from rotation
Caching Effectiveness:
- Profile cache hit rate: >80%
- Post cache hit rate: >50% (returning users)
- Cache size: <100MB storage used
Rendering Performance:
- Feed renders: <16ms per frame (60fps)
- Virtual scrolling implemented for >100 items
- No unnecessary re-renders (React DevTools)
User Research Questions
Performance Perception:
- “Does the app feel fast?”
- “Do you notice delays or lag?”
- “How does speed compare to Twitter/Instagram?”
Loading Feedback:
- “Is it clear when content is loading?”
- “Can you tell if the app is working or frozen?”
- “Do you feel informed about progress?”
Problem Areas:
- “What parts of the app feel slow?”
- “When do you experience lag or stuttering?”
- “Have you abandoned actions due to slowness?”
A/B Testing Opportunities
Test different approaches:
- Skeleton screens vs spinners vs blank screen
- Progressive loading vs wait-for-all
- Cache duration (1 hour vs 1 day)
- Relay timeout values (1s, 3s, 5s)
- Virtual scrolling threshold (100 items vs 500 items)
Measure impact on:
- Perceived speed ratings
- Bounce rate (leave before first content)
- Session duration
- Posts viewed per session
- Return rate
Citations & Sources
Note: All sources from 2024-2025 to ensure currency for this fast-moving technology.
Data & Analytics (Nostr-Specific)
- [Data:19] User complaints: Nostr apps “slow and clunky”, “clients blasting entire message history” (2024)
- [Data:20] Crash reports across Primal, Amethyst, Nostur, Damus clients (2024)
- [Data:21] Database identified as “core bottleneck” for slow feed loading; gap vs Twitter’s Redis architecture (2024)
- [Data:22] Amethyst battery drain: Background video loops, up to 100 relay connections, improper WebSocket management (2024)
- [Data:23] Only 639 relays online; default SQLite backend “worst performance”; free relays slower than paid (2024)
- [Data:24] Clients can open hundreds of WebSocket connections simultaneously; one per relay (2024)
- [Data:25] Black Hat USA 2025: Some clients omit signature verification for performance; security tradeoff (2025)
- [Data:26] nostrdb: “Unfairly fast” embedded database with zero-copy, O(1) access, LMDB design (2024)
- [Data:27] Primal Caching Service: Server-side caching for Nostr events, open sourced (December 2024)
UX Research & Industry Studies
[Research:31] Response Time Limits: 100ms instant, 1s flow uninterrupted, 5s (updated from 10s) keeps attention (NN/g, January 2024)
[Research:32] Speed & Bounce: 32% bounce at 3s, 90% at 5s, 123% at 10s; 53% mobile visitors leave >3s (2025)
[Research:33] Conversion Impact: 39% at 1s load, 1.9% at 2.4s, 0.6% at 5.7s; 7% loss per second delay (2025)
[Research:34] Skeleton screens perceived 30% faster than spinners; best for <10s waits, container components (LogRocket, 2024)
[Research:35] Skeleton vs Spinners: More engaging, dramatic UX improvement despite identical speed (UI-Deploy, 2024)
[Research:36] React 19 useOptimistic Hook: Elegant optimistic UI solution for social media (LogRocket, 2024)
[Research:37] Material Design 3 Expressive loading indicator for <5s waits; 7 unique shape morphs (May 2025)
[Research:38] React Optimistic UI vital for social media: Facebook like button turns blue instantly (Telerik, 2025)
[Research:39] Service Workers + IndexedDB significantly improves performance, enables offline-first apps (September 2024)
[Research:40] Offline-First PWAs: 45% use hybrid approach; Cache API for files, IndexedDB for data (August 2025)
[Research:41] JS Bundle Size: Tree shaking, code splitting, dynamic import() reduce bundles 20-50% (2025)
[Research:42] Webpack Bundles: <250KB gzipped initial, <1MB total for good UX (2025)
[Research:43] React Server Components: 40% faster loads, 15% conversion increase; no JS shipped to client (2024)
[Research:44] Native Lazy Loading: 97.5% images load <10ms on 4G, 92.6% on 2G; WebP/AVIF more efficient (2024)
[Research:45] React Performance: Virtual scrolling/windowing essential for large lists, reduces DOM ops (2025)
[Research:46] React Virtualization: Only render visible elements; reduces DOM updates, memory, render time (2024)
[Research:47] TanStack Virtual: Most popular library; use React.memo(), combine with pagination, overscan 1-2 items (November 2024)
[Research:48] Nostr Replication Overhead (arXiv): Control replication count, reduce retrieval overhead (Wei & Tyson, September 2025)
[Research:49] INP Replaces FID as Core Web Vital: March 12, 2024; measures all interactions vs first only (2024)
[Research:50] INP vs FID: INP measures input+processing+paint; Good <200ms, Poor >500ms (Vercel, 2024)
[Research:51] 60fps Animation: 16.7ms budget; GPU-accelerated transform/opacity smoother (MDN, November 2025)
[Research:52] Rakuten CWV: Good LCP = 61% conversion increase, 26% revenue/visitor increase (web.dev, 2024)
[Research:53] Core Web Vitals Impact: 24% less abandonment, 8-10% conversion per 0.1s improvement (2024)
Case Studies & Examples (Mainstream Apps)
- [Example:11] Twitter/X: Algorithm runs 5 billion times/day, <1.5s average execution for personalized For You feed (2025)
- [Example:12] Instagram: AI picks 500 posts/user; “Engage in first 3 seconds”; Reels 2.46% engagement (2025)
- [Example:13] TikTok: Watch time priority; content must “stop scrolls cold”; brands post 6x/week (2025)
- [Example:14] Starbucks PWA: 2x daily users, 53% more completions; <1MB, <1s load (2025)
- [Example:15] Discord: 84% crash reduction iOS, 30% faster switching, 80% faster GIFs, 25% less latency (Dec 2024)
See References & Bibliography for full citation details.
Frequently Asked Questions
Why do Nostr apps feel slow even when they aren’t objectively slower?
Because how fast an app feels matters more than benchmark speed. Nostr apps often lack loading state indicators, skeleton screens, and optimistic UI, and they expose multi-relay coordination to users as visible delays.
How fast should a button respond to a tap?
Within 100ms — that is the threshold where an interaction feels instantaneous. Even if processing continues, give immediate feedback: disable the button, show a spinner, or change its color.
Should I wait for all relays before showing the feed?
No. Waiting for all relays means the slowest one determines perceived speed, and a single dead relay can block the feed for up to 30 seconds. Show results as soon as any relay responds, and time out slow relays after 1 to 5 seconds.
Are skeleton screens better than spinners?
For most loads, yes. Users perceive skeleton screens as 30% faster than spinners for identical wait times. Use them for 2 to 10 second loads, reserve spinners for waits under 2 seconds, and use progress bars for waits over 10 seconds.
How should I cache Nostr data to improve performance?
Cache aggressively across layers — memory, storage via IndexedDB, service worker, and relay-level. Profiles (kind 0) and contact lists (kind 3) change infrequently and reuse well, so cache them with roughly a one-hour TTL, and use stale-while-revalidate for dynamic content.
Related Patterns
Next Steps
- Measure Core Web Vitals: Track LCP (<2.5s), INP (<200ms), and CLS (<0.1) to establish performance baseline and monitor improvements
- Implement perceived performance patterns: Deploy skeleton screens for >1s loads, optimistic UI for interactions, and progressive feed loading
- Optimize relay coordination: Implement parallel queries with race conditions, enforce 5s timeouts, and prioritize fast relays for initial content
- Build comprehensive caching layer: Cache profiles (>80% hit rate), posts (>50% hit rate), and implement offline-first patterns with service workers