Drastically reduce concurrent relay queries to prevent rate limiting
The app was firing an excessive number of concurrent REQs, causing relays to return "too many concurrent REQs" errors. Root causes and fixes: 1. **Batch event stats** (biggest win): Created `useBatchEventStats` hook that fetches interaction stats for ALL visible feed items in a single relay query instead of 2 queries per NoteCard. For a 15-item feed this reduces ~30 concurrent REQs to just 1. Results are seeded into the individual `['event-stats', id]` cache for instant resolution. 2. **Merged dual queries**: Both `useEventStats` and `useEventInteractions` were firing 2 parallel queries (e-tag + q-tag). Merged each into a single query using multiple filter objects (relay handles as OR). 3. **Reduced limits across the board**: - useEventStats: limit 200+50 → 50+20 (single query) - useEventInteractions: limit 500+100 → 50+20 (single query) - useTrendingTags: limit 200 → 50 - useReplies: limit 100 → 50 - useProfileFeed PAGE_SIZE: 30 → 20 - useStreamPosts/useStreamKind subscription limit: 100 → 0 Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
@@ -10,6 +10,7 @@ import SignupDialog from '@/components/auth/SignupDialog';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useBatchEventStats } from '@/hooks/useTrending';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FeedItem } from '@/hooks/useFeed';
|
||||
|
||||
@@ -73,6 +74,13 @@ export function Feed() {
|
||||
}, [feedItems]);
|
||||
useAuthors(feedPubkeys);
|
||||
|
||||
// Batch-prefetch interaction stats for all visible events in a single
|
||||
// relay query instead of firing 2 queries per NoteCard.
|
||||
const feedEventIds = useMemo(() => {
|
||||
return feedItems.map((item) => item.event.id);
|
||||
}, [feedItems]);
|
||||
useBatchEventStats(feedEventIds);
|
||||
|
||||
const handleLogin = () => {
|
||||
setLoginDialogOpen(false);
|
||||
setSignupDialogOpen(false);
|
||||
|
||||
@@ -126,16 +126,17 @@ export function useEventInteractions(eventId: string | undefined) {
|
||||
const timeout = AbortSignal.timeout(5000);
|
||||
const combined = AbortSignal.any([signal, timeout]);
|
||||
|
||||
const [eTagEvents, qTagEvents] = await Promise.all([
|
||||
nostr.query(
|
||||
[{ kinds: [6, 7, 9735], '#e': [eventId], limit: 500 }],
|
||||
{ signal: combined },
|
||||
),
|
||||
nostr.query(
|
||||
[{ kinds: [1], '#q': [eventId], limit: 100 }],
|
||||
{ signal: combined },
|
||||
),
|
||||
]);
|
||||
// Single query with two filter objects — relay handles as OR
|
||||
const allEvents = await nostr.query(
|
||||
[
|
||||
{ kinds: [6, 7, 9735], '#e': [eventId], limit: 50 },
|
||||
{ kinds: [1], '#q': [eventId], limit: 20 },
|
||||
],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
const eTagEvents = allEvents.filter(e => e.kind !== 1 || e.tags.some(([n, v]) => n === 'e' && v === eventId));
|
||||
const qTagEvents = allEvents.filter(e => e.kind === 1 && e.tags.some(([n, v]) => n === 'q' && v === eventId));
|
||||
|
||||
const reposts: RepostEntry[] = [];
|
||||
const quotes: QuoteEntry[] = [];
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useFeedSettings } from './useFeedSettings';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export type ProfileTab = 'posts' | 'replies' | 'media' | 'likes';
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useReplies(eventId: string | undefined) {
|
||||
if (!eventId) return [];
|
||||
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [1], '#e': [eventId], limit: 100 }],
|
||||
[{ kinds: [1], '#e': [eventId], limit: 50 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export function useStreamKind(kind: number | number[]) {
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for await (const msg of nostr.req(
|
||||
[{ ...filter, since: now, limit: 100 }],
|
||||
[{ ...filter, since: now, limit: 0 }],
|
||||
{ signal: ac.signal },
|
||||
)) {
|
||||
if (!alive) break;
|
||||
|
||||
@@ -127,7 +127,7 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for await (const msg of nostr.req(
|
||||
[{ ...baseFilter, since: now, limit: 100 }],
|
||||
[{ ...baseFilter, since: now, limit: 0 }],
|
||||
{ signal: ac.signal },
|
||||
)) {
|
||||
if (!alive) break;
|
||||
|
||||
+123
-51
@@ -1,5 +1,5 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
export interface TrendingTag {
|
||||
@@ -15,7 +15,7 @@ export function useTrendingTags(enabled = true) {
|
||||
queryKey: ['trending-tags'],
|
||||
queryFn: async ({ signal }) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [1], limit: 200 }],
|
||||
[{ kinds: [1], limit: 50 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
|
||||
@@ -117,6 +117,66 @@ function parseBolt11Amount(bolt11: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
/** The stats shape returned by useEventStats and useBatchEventStats. */
|
||||
export interface EventStats {
|
||||
replies: number;
|
||||
reposts: number;
|
||||
quotes: number;
|
||||
reactions: number;
|
||||
zapAmount: number;
|
||||
reactionEmojis: string[];
|
||||
}
|
||||
|
||||
const EMPTY_STATS: EventStats = { replies: 0, reposts: 0, quotes: 0, reactions: 0, zapAmount: 0, reactionEmojis: [] };
|
||||
|
||||
/** Computes stats for a single event ID from a flat array of interaction events. */
|
||||
function computeStats(eventId: string, events: NostrEvent[]): EventStats {
|
||||
let replies = 0;
|
||||
let reposts = 0;
|
||||
let quotes = 0;
|
||||
let reactions = 0;
|
||||
let zapAmount = 0;
|
||||
const reactionEmojiSet = new Set<string>();
|
||||
|
||||
for (const e of events) {
|
||||
// Check if this event references our target via e-tag or q-tag
|
||||
const refersViaE = e.tags.some(([name, val]) => name === 'e' && val === eventId);
|
||||
const refersViaQ = e.tags.some(([name, val]) => name === 'q' && val === eventId);
|
||||
|
||||
if (!refersViaE && !refersViaQ) continue;
|
||||
|
||||
switch (e.kind) {
|
||||
case 1:
|
||||
if (refersViaQ) {
|
||||
quotes++;
|
||||
} else {
|
||||
replies++;
|
||||
}
|
||||
break;
|
||||
case 6: reposts++; break;
|
||||
case 7: {
|
||||
reactions++;
|
||||
const emoji = e.content.trim();
|
||||
if (emoji === '+' || emoji === '') {
|
||||
reactionEmojiSet.add('👍');
|
||||
} else if (emoji !== '-') {
|
||||
reactionEmojiSet.add(emoji);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 9735: {
|
||||
const msats = extractZapAmount(e);
|
||||
if (msats > 0) {
|
||||
zapAmount += Math.floor(msats / 1000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { replies, reposts, quotes, reactions, zapAmount, reactionEmojis: Array.from(reactionEmojiSet) };
|
||||
}
|
||||
|
||||
/** Counts engagement (replies, reposts, quotes, reactions, zaps) for a given event. */
|
||||
export function useEventStats(eventId: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
@@ -124,60 +184,72 @@ export function useEventStats(eventId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['event-stats', eventId ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!eventId) return { replies: 0, reposts: 0, quotes: 0, reactions: 0, zapAmount: 0, reactionEmojis: [] as string[] };
|
||||
if (!eventId) return EMPTY_STATS;
|
||||
|
||||
const timeout = AbortSignal.timeout(5000);
|
||||
const combined = AbortSignal.any([signal, timeout]);
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
|
||||
|
||||
// Two queries: one for e-tag interactions, one for q-tag quotes
|
||||
const [eTagEvents, qTagEvents] = await Promise.all([
|
||||
nostr.query(
|
||||
[{ kinds: [1, 6, 7, 9735], '#e': [eventId], limit: 200 }],
|
||||
{ signal: combined },
|
||||
),
|
||||
nostr.query(
|
||||
[{ kinds: [1], '#q': [eventId], limit: 50 }],
|
||||
{ signal: combined },
|
||||
),
|
||||
]);
|
||||
// Single query with two filter objects — relay handles as OR
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{ kinds: [1, 6, 7, 9735], '#e': [eventId], limit: 50 },
|
||||
{ kinds: [1], '#q': [eventId], limit: 20 },
|
||||
],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
let replies = 0;
|
||||
let reposts = 0;
|
||||
let reactions = 0;
|
||||
let zapAmount = 0;
|
||||
const reactionEmojiSet = new Set<string>();
|
||||
|
||||
for (const e of eTagEvents) {
|
||||
switch (e.kind) {
|
||||
case 1: replies++; break;
|
||||
case 6: reposts++; break;
|
||||
case 7: {
|
||||
reactions++;
|
||||
// Extract the emoji from the reaction content (kind 7 events use content for the emoji)
|
||||
const emoji = e.content.trim();
|
||||
if (emoji === '+' || emoji === '') {
|
||||
reactionEmojiSet.add('👍');
|
||||
} else if (emoji !== '-') {
|
||||
reactionEmojiSet.add(emoji);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 9735: {
|
||||
const msats = extractZapAmount(e);
|
||||
if (msats > 0) {
|
||||
zapAmount += Math.floor(msats / 1000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const quotes = qTagEvents.length;
|
||||
|
||||
return { replies, reposts, quotes, reactions, zapAmount, reactionEmojis: Array.from(reactionEmojiSet) };
|
||||
return computeStats(eventId, events);
|
||||
},
|
||||
enabled: !!eventId,
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: (prev) => prev, // Keep showing previous counts while refetching
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetch interaction stats for multiple event IDs in a single relay query.
|
||||
*
|
||||
* Much more efficient than calling `useEventStats` once per event — one
|
||||
* round-trip instead of N. Results are also seeded into the individual
|
||||
* `['event-stats', id]` cache entries so that `useEventStats()` calls
|
||||
* for the same IDs resolve instantly from cache.
|
||||
*/
|
||||
export function useBatchEventStats(eventIds: string[]) {
|
||||
const { nostr } = useNostr();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const uniqueIds = [...new Set(eventIds)].sort();
|
||||
|
||||
return useQuery<Map<string, EventStats>>({
|
||||
queryKey: ['batch-event-stats', uniqueIds.join(',')],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (uniqueIds.length === 0) return new Map();
|
||||
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(6000)]);
|
||||
|
||||
// Single query covering all event IDs — relay handles as OR
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{ kinds: [1, 6, 7, 9735], '#e': uniqueIds, limit: uniqueIds.length * 10 },
|
||||
{ kinds: [1], '#q': uniqueIds, limit: uniqueIds.length * 3 },
|
||||
],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
const statsMap = new Map<string, EventStats>();
|
||||
|
||||
for (const id of uniqueIds) {
|
||||
const stats = computeStats(id, events);
|
||||
statsMap.set(id, stats);
|
||||
|
||||
// Seed individual cache
|
||||
queryClient.setQueryData(['event-stats', id], stats);
|
||||
}
|
||||
|
||||
return statsMap;
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
enabled: uniqueIds.length > 0,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user