If QueryClient is the brain, then QueryCache is the database layer that stores everything.
This is where:
- ✅ All queries are stored
- ✅ State is maintained
- ✅ Observers are attached
- ✅ Garbage collection is tracked
- ✅ Fetch deduplication happens
Let's understand QueryCache at the internal engine level 🔥
From @tanstack/react-query
📋 Table of Contents
- What is QueryCache?
- QueryCache Structure
- Query Object Internals
- Complete Query Lifecycle
- QueryCache Responsibilities
- Advanced Concepts
- QueryCache vs QueryClient
- Internal Event System
- Real-World Insights
- Performance Characteristics
⚡ Quick Summary
TL;DR: QueryCache is the internal database that:
- ✅ Stores all Query instances as a hash map
- ✅ Manages query state machines
- ✅ Coordinates observers and notifications
- ✅ Handles fetch deduplication
- ✅ Tracks garbage collection
- ✅ Enables structural sharing
- ✅ Powers DevTools integration
Length: ~25 min read | Level: Advanced
📦 Installation
npm install @tanstack/react-query
🗄️ What is QueryCache? {#what-is-querycache}
Simple Definition:
QueryCache = Internal Database + State Store + Observer Registry
Simplified Internal Structure:
class QueryCache {
queries: Query[] // All Query instances
queriesMap: Map // Fast lookup by hash
config: QueryCacheConfig // Configuration
listeners: Set<Listener> // Global subscribers
}
Real Implementation:
QueryCache maintains an indexed collection:
Map<queryHash, Query>
-
Key: Stable hash of
queryKey - Value: Complete Query state machine
Key Point:
QueryCache is framework-agnostic.
It doesn't know about React. React components are just observers.
🏗 QueryCache Structure {#querycache-structure}
How Queries are Stored:
// User writes:
useQuery({ queryKey: ['users', 123] })
// Internally becomes:
{
queryHash: '["users",123]',
query: Query {
queryKey: ['users', 123],
state: { data, error, status, ... },
observers: Set<QueryObserver>,
retryer: Retryer,
// ... more
}
}
Visual Representation:
QueryCache
├── Map<queryHash, Query>
│ ├── '["users"]' → Query { state, observers, retryer }
│ ├── '["user",1]' → Query { state, observers, retryer }
│ ├── '["posts"]' → Query { state, observers, retryer }
│ └── ...
│
└── listeners: Set<Listener>
├── DevTools
├── Custom Logger
└── ...
🔍 Query Object Internals {#query-object-internals}
What is a Query?
Each entry in QueryCache is a full Query state machine.
Internal Structure:
class Query {
// Identification
queryKey: QueryKey // Original key: ['users', 123]
queryHash: string // Stable hash: '["users",123]'
// State
state: QueryState // Current state
// Observers
observers: Set<QueryObserver> // Connected components
// Fetch Control
retryer: Retryer // Retry logic
promise: Promise // Current fetch promise
// Lifecycle
gcTimeout: NodeJS.Timeout // Garbage collection timer
// Options
options: QueryOptions // Configuration
}
Let's explore each property:
1. queryKey
The original array you pass:
queryKey: ['users', userId, { status: 'active' }]
Why stored:
- Needed for matching filters
- Used in callbacks
- Reference for developers
2. queryHash
A stable string hash of queryKey.
// Input
queryKey: ['users', 123]
// Output
queryHash: '["users",123]'
Why needed?
Arrays can't be used directly as Map keys reliably:
// These are different references
['users', 1] !== ['users', 1]
// But same hash
hash(['users', 1]) === hash(['users', 1])
Hash function:
function hashQueryKey(queryKey: QueryKey): string {
return JSON.stringify(queryKey, (_, val) =>
isPlainObject(val)
? Object.keys(val)
.sort()
.reduce((result, key) => {
result[key] = val[key]
return result
}, {} as any)
: val
)
}
This ensures:
- ✅ Stable hashing
- ✅ Order-independent for objects
- ✅ Fast lookup
3. state (Most Important)
The actual server state:
state: {
data: any, // Query result
dataUpdatedAt: number, // Last successful fetch timestamp
error: Error | null, // Error object
errorUpdatedAt: number, // Last error timestamp
fetchStatus: 'idle' | 'fetching' | 'paused',
status: 'pending' | 'error' | 'success',
fetchFailureCount: number, // Failed attempts
fetchFailureReason: Error, // Last failure reason
isInvalidated: boolean // Marked stale by invalidation
}
This is pure server state - no React-specific data.
State machine:
pending → fetching → success
↘ error
4. observers
Set of QueryObserver instances.
observers: Set<QueryObserver>
What is a QueryObserver?
Each component that calls useQuery creates a QueryObserver:
function UserList() {
const { data } = useQuery({ queryKey: ['users'] })
// Creates QueryObserver that subscribes to Query
}
Why a Set?
- Multiple components can use same query
- Each gets its own observer
- Query notifies all observers when state changes
Example:
// Component A
function ComponentA() {
useQuery({ queryKey: ['users'] }) // Observer 1
}
// Component B
function ComponentB() {
useQuery({ queryKey: ['users'] }) // Observer 2
}
// Same Query, two observers
Query {
queryKey: ['users'],
observers: Set([Observer1, Observer2])
}
5. retryer
Active fetch controller.
retryer: Retryer {
continue: () => void,
cancel: () => void,
cancelRetry: () => void,
continueRetry: () => void
}
What it handles:
- ✅ Retry logic
- ✅ Exponential backoff
- ✅ Cancellation
- ✅ Pause/Resume
Exponential backoff example:
// Attempt 0: 1s
// Attempt 1: 2s
// Attempt 2: 4s
// Attempt 3: 8s
// Capped at 30s
6. promise
Current fetch promise.
promise: Promise<TData> | null
Why stored?
Fetch deduplication!
If same query is requested multiple times:
- First request → creates promise
- Subsequent requests → return same promise
// ComponentA and ComponentB mount simultaneously
function ComponentA() {
useQuery({ queryKey: ['users'] }) // Creates fetch
}
function ComponentB() {
useQuery({ queryKey: ['users'] }) // Reuses same fetch!
}
// Only ONE network request
Behind the scenes:
class Query {
fetch() {
// Already fetching?
if (this.promise) {
return this.promise // Reuse!
}
// Start new fetch
this.promise = this.executeFetch()
return this.promise
}
}
7. gcTimeout
Garbage collection timer reference.
gcTimeout: NodeJS.Timeout | null
How it works:
// When query becomes inactive (no observers)
onObserverRemoved() {
if (this.observers.size === 0) {
// Start GC timer
this.gcTimeout = setTimeout(() => {
queryCache.remove(this)
}, this.options.gcTime)
}
}
Default gcTime: 5 minutes
🔄 Complete Query Lifecycle {#complete-query-lifecycle}
Let's trace what happens when you call useQuery:
useQuery({ queryKey: ['users'], queryFn: fetchUsers })
Step 1: Build or Get Query
QueryClient calls:
queryCache.build(queryKey, options)
Logic:
build(queryKey, options) {
// 1. Generate hash
const queryHash = hashQueryKey(queryKey)
// 2. Existing query?
let query = this.queriesMap.get(queryHash)
// 3. Create if missing
if (!query) {
query = new Query({
queryCache: this,
queryKey,
queryHash,
options
})
this.queriesMap.set(queryHash, query)
}
return query
}
Result: Query instance (new or existing)
Step 2: Observer Attach
Component mount:
const observer = new QueryObserver(queryClient, options)
query.addObserver(observer)
Inside Query:
addObserver(observer) {
this.observers.add(observer)
// Query is now active
this.clearGcTimeout()
// Notify observer of current state
observer.onQueryUpdate()
}
Query status changes:
-
inactive→active
Step 3: Fetch Trigger
Decision logic:
// Should we fetch?
const shouldFetch =
!this.state.data || // No data
this.isStale() || // Data is stale
options.refetchOnMount // Force refetch
if (shouldFetch) {
query.fetch()
}
If yes, fetch:
fetch() {
// Create retryer
this.retryer = new Retryer({
fn: this.options.queryFn,
retry: this.options.retry,
retryDelay: this.options.retryDelay
})
// Store promise (for deduplication)
this.promise = this.retryer.start()
// Update state
this.setState({
fetchStatus: 'fetching'
})
return this.promise
}
Step 4: State Update
Fetch resolves:
this.promise
.then((data) => {
// Success!
this.setState({
data,
dataUpdatedAt: Date.now(),
status: 'success',
fetchStatus: 'idle',
error: null
})
})
.catch((error) => {
// Error!
this.setState({
error,
errorUpdatedAt: Date.now(),
status: 'error',
fetchStatus: 'idle'
})
})
State update triggers observer notification.
Step 5: Observer Notify
QueryCache doesn't trigger renders directly.
Instead:
setState(newState) {
// Update state
this.state = { ...this.state, ...newState }
// Notify all observers
this.observers.forEach(observer => {
observer.onQueryUpdate()
})
}
Inside QueryObserver:
onQueryUpdate() {
// Diff state
const hasChanged = this.hasPropsChanged()
if (hasChanged) {
// Trigger React re-render
this.notify()
}
}
Only re-renders if tracked properties changed!
Step 6: Component Unmount
Cleanup:
// Component unmounts
observer.destroy()
// Inside observer
destroy() {
query.removeObserver(this)
}
Inside Query:
removeObserver(observer) {
this.observers.delete(observer)
// No more observers?
if (this.observers.size === 0) {
// Query becomes inactive
this.scheduleGc()
}
}
Step 7: Garbage Collection
After gcTime:
scheduleGc() {
this.clearGcTimeout()
this.gcTimeout = setTimeout(() => {
// Remove from cache
queryCache.remove(this)
}, this.options.gcTime || 5 * 60 * 1000)
}
Memory freed.
🎯 QueryCache Responsibilities {#querycache-responsibilities}
1. Query Registration
Creating and storing queries.
queryCache.build(queryKey, options)
2. Fast Lookup
O(1) retrieval via hash:
const query = queryCache.find(queryKey)
3. Matching & Filtering
Powers these QueryClient methods:
queryClient.invalidateQueries({ queryKey: ['users'] })
queryClient.refetchQueries({ type: 'active' })
queryClient.removeQueries({ stale: true })
Behind the scenes:
findAll(filters) {
const queries = Array.from(this.queriesMap.values())
return queries.filter(query => {
// Match queryKey
if (filters.queryKey) {
if (!partialMatchKey(query.queryKey, filters.queryKey)) {
return false
}
}
// Match type
if (filters.type === 'active' && query.observers.size === 0) {
return false
}
// Match stale
if (filters.stale && !query.isStale()) {
return false
}
return true
})
}
4. Global Event Listeners
QueryCache maintains subscribers:
listeners: Set<Listener>
DevTools subscribes here:
queryCache.subscribe((event) => {
if (event.type === 'added') {
devtools.onQueryAdded(event.query)
}
})
Events:
-
added- New query created -
removed- Query garbage collected -
updated- Query state changed
5. Batch Notifications
Doesn't notify directly:
notify(query) {
// Use notification manager for batching
notifyManager.batch(() => {
// Notify listeners
this.listeners.forEach(listener => {
listener({ query })
})
// Notify query observers
query.observers.forEach(observer => {
observer.onQueryUpdate()
})
})
}
Result:
- Multiple updates → single React render
- Integrates with React batching
🧠 Advanced Concepts {#advanced-concepts}
1. Stale System ⏰
QueryCache doesn't run timers.
Instead:
isStale() {
// Calculate on-demand
const now = Date.now()
const age = now - this.state.dataUpdatedAt
return age > this.options.staleTime
}
Why this approach?
- No active timers to manage
- Lower memory overhead
- Lazy evaluation
2. Invalidation Logic 🔄
When you call:
queryClient.invalidateQueries(['users'])
Behind the scenes:
// 1. Mark query as invalidated
query.invalidate()
// Inside Query
invalidate() {
this.state.isInvalidated = true
// Has active observers?
if (this.observers.size > 0) {
// Schedule refetch
this.fetch()
}
// Otherwise, just stays marked stale
}
Important:
Invalidation ≠ immediate fetch
It marks stale first, refetches only if active.
3. Structural Sharing 🧬
Optimizes re-renders by reusing unchanged references.
When data updates:
setData(newData) {
// Deep comparison
const sharedData = replaceEqualDeep(
this.state.data,
newData
)
this.state.data = sharedData
}
How it works:
function replaceEqualDeep(prev, next) {
if (prev === next) return prev
const prevType = typeof prev
const nextType = typeof next
if (prevType !== nextType) return next
if (Array.isArray(prev) && Array.isArray(next)) {
// Check each item
return prev.map((item, index) =>
replaceEqualDeep(item, next[index])
)
}
if (isPlainObject(prev) && isPlainObject(next)) {
// Check each property
const result = { ...prev }
Object.keys(next).forEach(key => {
result[key] = replaceEqualDeep(prev[key], next[key])
})
return result
}
return next
}
Result:
- Unchanged branches keep same reference
- React skips re-rendering unchanged parts
Example:
// Old data
const oldUsers = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
// New data (same content)
const newUsers = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
// After structural sharing
oldUsers === newUsers // false (different references)
// But: oldUsers[0] === newUsers[0] // true! (reused)
4. Fetch Deduplication 🔁
Same query, multiple components:
function ComponentA() {
useQuery({ queryKey: ['users'] })
}
function ComponentB() {
useQuery({ queryKey: ['users'] })
}
function ComponentC() {
useQuery({ queryKey: ['users'] })
}
Without deduplication: 3 network requests
With deduplication: 1 network request
How it works:
fetch() {
// Already fetching?
if (this.promise) {
return this.promise // Share promise!
}
// Start new fetch
this.promise = this.executeFetch()
.finally(() => {
// Clear after completion
this.promise = null
})
return this.promise
}
All observers await the same promise!
5. Cancellation System ❌
When you call:
queryClient.cancelQueries(['users'])
Behind the scenes:
cancel() {
// Cancel retry logic
this.retryer?.cancel()
// Abort network request
this.abortController?.abort()
// Clear promise
this.promise = null
}
If queryFn supports AbortSignal:
queryFn: ({ signal }) =>
fetch('/api/users', { signal })
Then fetch is truly aborted!
🆚 QueryCache vs QueryClient {#querycache-vs-queryclient}
Clear Separation:
| Layer | Responsibility | Example |
|---|---|---|
| QueryClient | Orchestrator | queryClient.invalidateQueries() |
| QueryCache | Data store | Stores Query instances |
| Query | State machine | Manages individual query lifecycle |
| Observer | React bridge | Connects components to queries |
Architecture Diagram:
User Component (React)
↓
useQuery (Hook)
↓
QueryObserver (Bridge)
↓
Query (State Machine)
↓
QueryCache (Database)
↓
QueryClient (Orchestrator)
Why this separation?
Framework agnostic:
- QueryCache has no React dependencies
- Can be used with Vue, Angular, Svelte
- React is just one observer implementation
📡 Internal Event System {#internal-event-system}
QueryCache emits events:
class QueryCache extends Subscribable {
notify(event) {
// Notify all listeners
this.listeners.forEach(listener => {
listener(event)
})
}
}
Event Types:
type QueryCacheNotifyEvent =
| { type: 'added'; query: Query }
| { type: 'removed'; query: Query }
| { type: 'updated'; query: Query; action: UpdateAction }
Who subscribes?
1. DevTools:
queryCache.subscribe((event) => {
devtoolsPanel.updateQueryList()
})
2. Custom Loggers:
queryCache.subscribe((event) => {
console.log('Query event:', event.type, event.query.queryKey)
})
3. Analytics:
queryCache.subscribe((event) => {
if (event.type === 'added') {
analytics.track('Query Created', {
queryKey: event.query.queryKey
})
}
})
💡 Real-World Insights {#real-world-insights}
1. QueryCache is NOT React State
Important concept:
QueryCache ≠ React State
QueryCache = Independent Async Store
React = Observer
Why this matters:
✅ No prop drilling needed
✅ No context cascade re-renders
✅ Fine-grained subscriptions
✅ Works with React Concurrent Mode
✅ No tearing issues
2. Memory Management
Without QueryCache:
- Manual cache management
- Memory leaks
- No automatic cleanup
With QueryCache:
- Automatic GC
- Configurable gcTime
- Memory-efficient
Example:
// Query becomes inactive
query.observers.size === 0
// After 5 minutes (default gcTime)
queryCache.remove(query)
// Memory freed
3. Why Hash-Based Storage?
Problem:
const map = new Map()
map.set(['users', 1], data1)
map.set(['users', 1], data2) // Different key! (new array reference)
map.size // 2 (should be 1!)
Solution:
const queryCache = new Map()
const hash1 = hash(['users', 1]) // '["users",1]'
const hash2 = hash(['users', 1]) // '["users",1]'
hash1 === hash2 // true!
queryCache.set(hash1, query1)
queryCache.set(hash2, query2) // Overwrites query1 ✅
queryCache.size // 1 (correct!)
4. Observer Pattern Benefits
Traditional approach (Redux):
// Component connects to entire state
const state = useSelector(state => state)
// Re-renders on ANY state change
QueryCache approach:
// Component subscribes to specific query
const { data } = useQuery(['users'])
// Re-renders only when THIS query changes
Result:
- Fewer re-renders
- Better performance
- Isolated updates
⚡ Performance Characteristics {#performance-characteristics}
1. Lookup Performance
O(1) hash-based lookup:
// Super fast
const query = queryCache.get(queryHash)
2. Memory Efficiency
Automatic garbage collection:
// Inactive query after 5 min → removed
// No memory leaks
3. Render Optimization
Structural sharing:
// Unchanged data → same reference → no re-render
Observer diffing:
// Only notify if tracked props changed
4. Network Efficiency
Fetch deduplication:
// 10 components, same query → 1 network request
🎯 Key Takeaways
QueryCache is:
- ✅ Database Layer - Stores all Query instances
- ✅ Hash Map - Fast O(1) lookups
- ✅ Event Emitter - Powers DevTools and monitoring
- ✅ GC Manager - Automatic memory cleanup
- ✅ Dedup Engine - Prevents duplicate fetches
- ✅ Framework Agnostic - No React dependencies
How It Powers React Query:
- Centralized storage - Single source of truth
- Smart caching - Automatic lifecycle management
- Observer pattern - Fine-grained subscriptions
- Structural sharing - Optimized re-renders
- Fetch deduplication - Network efficiency
Mental Model:
Think of QueryCache as:
- PostgreSQL (if React Query was a backend)
- IndexedDB (for browser persistence)
- Redis (for caching layer)
It's the foundational data store that everything builds on.
📌 Final Thought
QueryCache is the hidden engine that makes React Query magical.
Understanding it helps you:
- ✅ Debug effectively
- ✅ Optimize performance
- ✅ Build advanced patterns
- ✅ Understand internals
- ✅ Interview confidently
Most developers use React Query without understanding QueryCache.
Now you're in the top 1% who truly understand how it works. 🚀
🚀 Next Steps
- Read TanStack Query source code
- Build custom QueryCache visualizer
- Experiment with QueryCache events
- Create custom persistence layer
- Contribute to React Query
💡 Pro Tips
Explore these advanced topics:
- Query dehydration/hydration for SSR
- Custom QueryCache implementations
- Building plugins with QueryCache events
- Memory profiling with Chrome DevTools
🤝 Let's Connect
Found this helpful?
- ❤️ Save for future reference
- 🔄 Share with your team
- 💬 Ask questions in comments
- 🐦 Follow for more deep dives
What QueryCache concept blew your mind? Comment below! 👇
📚 Official Docs:
https://tanstack.com/query/latest/docs/react/overview
Happy caching! 🎯
Tags: #react #tanstack #reactquery #javascript #webdev #frontend #querycache #internals
Top comments (0)