Request Deduplication and API Caching with TanStack React Query
When I first heard about request deduplication, I thought: "We already have caching — isn't that enough?" It wasn't. In our React Native regulated app, I discovered that without deduplication, the same API call could fire 4-6 times within a single screen render cycle. TanStack React Query v5 solved this elegantly, but configuring it correctly for a sensitive application required careful thought. Here's what I learned.
The Problem: Death by a Thousand Requests
Consider a typical data-heavy dashboard. The header shows the user's name (needs user profile). The balance card shows account balance (needs account data). The transaction list shows recent transactions (needs transaction data). The notification bell shows unread count (needs notification data). And each of these components mounts independently.
But here's the catch — the transaction list component also needs account data to display account names. The notification bell needs user profile data to filter notifications. Before React Query, our useEffect-based fetching resulted in duplicate API calls:
// Network tab without deduplication:
GET /api/user/profile (Header component)
GET /api/user/profile (NotificationBell component)
GET /api/accounts (BalanceCard component)
GET /api/accounts (TransactionList component)
GET /api/transactions (TransactionList component)
GET /api/notifications (NotificationBell component)
GET /api/user/profile (Greeting component) // 3rd call!
Seven API calls, but only four unique endpoints. In a mobile app on a cellular connection, those three redundant calls aren't just wasteful — they're user-visible latency.
TanStack Query Configuration for Financial Data
Our global configuration reflects the nature of financial data — it needs to be fresh, but not at the cost of hammering the API:
typescript// queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
retry: 2,
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 10000),
refetchOnWindowFocus: false, // Mobile doesn't have "window focus"
refetchOnReconnect: true, // But it does lose connectivity
networkMode: 'offlineFirst',
},
mutations: {
retry: 0, // Never retry mutations in banking
},
},
});
The 5-minute stale time is the sweet spot for banking data. Account balances don't change every second for most users, and showing a balance that's up to 5 minutes old is acceptable (we show a "last updated" timestamp). But we never retry mutations — if a transfer fails, we don't want to accidentally double-send it.
How Deduplication Actually Works
TanStack Query deduplicates based on the query key. When multiple components use the same key, only one network request fires:
typescript// hooks/useAccountData.ts
export function useAccountData(accountId: string) {
return useQuery({
queryKey: ['account', accountId],
queryFn: () => accountApi.getAccount(accountId),
staleTime: 5 * 60 * 1000,
});
}
// Used in BalanceCard.tsx
const { data: account } = useAccountData('ACC-001');
// Used in TransactionList.tsx — SAME query key, NO duplicate request
const { data: account } = useAccountData('ACC-001');
Under the hood, when BalanceCard mounts and triggers the query, TanStack Query stores the in-flight promise. When TransactionList mounts milliseconds later with the same key, it receives the same promise. One request, two subscribers.
Query Key Architecture
Getting query keys right is critical for deduplication. We use a factory pattern:
typescript// queryKeys.ts
export const queryKeys = {
user: {
all: ['user'] as const,
profile: () => [...queryKeys.user.all, 'profile'] as const,
preferences: () => [...queryKeys.user.all, 'preferences'] as const,
},
accounts: {
all: ['accounts'] as const,
list: () => [...queryKeys.accounts.all, 'list'] as const,
detail: (id: string) => [...queryKeys.accounts.all, 'detail', id] as const,
balance: (id: string) => [...queryKeys.accounts.all, 'balance', id] as const,
},
transactions: {
all: ['transactions'] as const,
list: (accountId: string, filters?: TransactionFilters) =>
[...queryKeys.transactions.all, 'list', accountId, filters] as const,
detail: (id: string) =>
[...queryKeys.transactions.all, 'detail', id] as const,
},
notifications: {
all: ['notifications'] as const,
unread: () => [...queryKeys.notifications.all, 'unread'] as const,
},
};
This structure enables surgical invalidation. After a transfer, we invalidate the specific account's balance without refetching every account:
typescript// After a successful transfer
await queryClient.invalidateQueries({
queryKey: queryKeys.accounts.balance(fromAccountId),
});
await queryClient.invalidateQueries({
queryKey: queryKeys.accounts.balance(toAccountId),
});
await queryClient.invalidateQueries({
queryKey: queryKeys.transactions.list(fromAccountId),
});
Optimistic Updates for Better UX
For non-critical updates (marking notifications as read, updating preferences), we use optimistic updates to eliminate perceived latency:
typescriptexport function useMarkNotificationRead() {
return useMutation({
mutationFn: (notificationId: string) =>
notificationApi.markRead(notificationId),
onMutate: async (notificationId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: queryKeys.notifications.all,
});
// Snapshot previous value
const previousNotifications = queryClient.getQueryData(
queryKeys.notifications.unread()
);
// Optimistically update
queryClient.setQueryData(
queryKeys.notifications.unread(),
(old: Notification[]) =>
old?.filter((n) => n.id !== notificationId) ?? []
);
return { previousNotifications };
},
onError: (_err, _id, context) => {
// Rollback on error
queryClient.setQueryData(
queryKeys.notifications.unread(),
context?.previousNotifications
);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.notifications.all,
});
},
});
}
We never use optimistic updates for financial transactions. A failed optimistic transfer update that shows a wrong balance for even a few seconds could cause a customer to make decisions based on incorrect information.
Prefetching for Perceived Performance
We prefetch data that the user is likely to need next:
typescript// In the account list screen
function AccountListItem({ account }: { account: Account }) {
const queryClient = useQueryClient();
const handlePressIn = () => {
// Prefetch when the user starts pressing, not when they navigate
queryClient.prefetchQuery({
queryKey: queryKeys.transactions.list(account.id),
queryFn: () => transactionApi.getRecent(account.id),
staleTime: 5 * 60 * 1000,
});
};
return (
<Pressable onPressIn={handlePressIn} onPress={() => navigate(account.id)}>
<AccountCard account={account} />
</Pressable>
);
}
By prefetching on onPressIn (touch start) rather than onPress (touch end), we gain ~200ms of network time. On a good connection, the data is ready before the navigation animation completes.
Measuring the Impact
After implementing TanStack Query with proper deduplication:
| Metric | Before | After | |--------|--------|-------| | API calls per dashboard load | 7 | 4 | | Average dashboard load time | 2.1s | 0.9s | | Monthly API calls (total) | 12.4M | 5.8M | | Server infrastructure cost | $X | ~0.47X |
The API call reduction alone justified the migration. But the real win was the user experience — screens feel instant when data is cached and deduplicated. The 5-minute stale time means returning to a screen shows cached data immediately while a background refetch ensures freshness.
TanStack React Query is one of those libraries that makes you wonder how you ever built apps without it. The deduplication and caching alone would be worth it, but combined with optimistic updates, prefetching, and the query key factory pattern, it becomes the backbone of a responsive sensitive application.