DEV Community

Munna Thakur
Munna Thakur

Posted on

Mastering QueryCache — The Database Layer of React Query

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


⚡ 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
Enter fullscreen mode Exit fullscreen mode

🗄️ 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
}
Enter fullscreen mode Exit fullscreen mode

Real Implementation:

QueryCache maintains an indexed collection:

Map<queryHash, Query>
Enter fullscreen mode Exit fullscreen mode
  • 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    └── ...
Enter fullscreen mode Exit fullscreen mode

🔍 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
}
Enter fullscreen mode Exit fullscreen mode

Let's explore each property:


1. queryKey

The original array you pass:

queryKey: ['users', userId, { status: 'active' }]
Enter fullscreen mode Exit fullscreen mode

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]'
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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
  )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

This is pure server state - no React-specific data.

State machine:

pending → fetching → success
                  ↘ error
Enter fullscreen mode Exit fullscreen mode

4. observers

Set of QueryObserver instances.

observers: Set<QueryObserver>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

5. retryer

Active fetch controller.

retryer: Retryer {
  continue: () => void,
  cancel: () => void,
  cancelRetry: () => void,
  continueRetry: () => void
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

6. promise

Current fetch promise.

promise: Promise<TData> | null
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

7. gcTimeout

Garbage collection timer reference.

gcTimeout: NodeJS.Timeout | null
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

Default gcTime: 5 minutes


🔄 Complete Query Lifecycle {#complete-query-lifecycle}

Let's trace what happens when you call useQuery:

useQuery({ queryKey: ['users'], queryFn: fetchUsers })
Enter fullscreen mode Exit fullscreen mode

Step 1: Build or Get Query

QueryClient calls:

queryCache.build(queryKey, options)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Result: Query instance (new or existing)


Step 2: Observer Attach

Component mount:

const observer = new QueryObserver(queryClient, options)
query.addObserver(observer)
Enter fullscreen mode Exit fullscreen mode

Inside Query:

addObserver(observer) {
  this.observers.add(observer)

  // Query is now active
  this.clearGcTimeout()

  // Notify observer of current state
  observer.onQueryUpdate()
}
Enter fullscreen mode Exit fullscreen mode

Query status changes:

  • inactiveactive

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()
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'
    })
  })
Enter fullscreen mode Exit fullscreen mode

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()
  })
}
Enter fullscreen mode Exit fullscreen mode

Inside QueryObserver:

onQueryUpdate() {
  // Diff state
  const hasChanged = this.hasPropsChanged()

  if (hasChanged) {
    // Trigger React re-render
    this.notify()
  }
}
Enter fullscreen mode Exit fullscreen mode

Only re-renders if tracked properties changed!


Step 6: Component Unmount

Cleanup:

// Component unmounts
observer.destroy()

// Inside observer
destroy() {
  query.removeObserver(this)
}
Enter fullscreen mode Exit fullscreen mode

Inside Query:

removeObserver(observer) {
  this.observers.delete(observer)

  // No more observers?
  if (this.observers.size === 0) {
    // Query becomes inactive
    this.scheduleGc()
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Garbage Collection

After gcTime:

scheduleGc() {
  this.clearGcTimeout()

  this.gcTimeout = setTimeout(() => {
    // Remove from cache
    queryCache.remove(this)
  }, this.options.gcTime || 5 * 60 * 1000)
}
Enter fullscreen mode Exit fullscreen mode

Memory freed.


🎯 QueryCache Responsibilities {#querycache-responsibilities}

1. Query Registration

Creating and storing queries.

queryCache.build(queryKey, options)
Enter fullscreen mode Exit fullscreen mode

2. Fast Lookup

O(1) retrieval via hash:

const query = queryCache.find(queryKey)
Enter fullscreen mode Exit fullscreen mode

3. Matching & Filtering

Powers these QueryClient methods:

queryClient.invalidateQueries({ queryKey: ['users'] })
queryClient.refetchQueries({ type: 'active' })
queryClient.removeQueries({ stale: true })
Enter fullscreen mode Exit fullscreen mode

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
  })
}
Enter fullscreen mode Exit fullscreen mode

4. Global Event Listeners

QueryCache maintains subscribers:

listeners: Set<Listener>
Enter fullscreen mode Exit fullscreen mode

DevTools subscribes here:

queryCache.subscribe((event) => {
  if (event.type === 'added') {
    devtools.onQueryAdded(event.query)
  }
})
Enter fullscreen mode Exit fullscreen mode

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()
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Why this approach?

  • No active timers to manage
  • Lower memory overhead
  • Lazy evaluation

2. Invalidation Logic 🔄

When you call:

queryClient.invalidateQueries(['users'])
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

4. Fetch Deduplication 🔁

Same query, multiple components:

function ComponentA() {
  useQuery({ queryKey: ['users'] })
}

function ComponentB() {
  useQuery({ queryKey: ['users'] })
}

function ComponentC() {
  useQuery({ queryKey: ['users'] })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

All observers await the same promise!


5. Cancellation System ❌

When you call:

queryClient.cancelQueries(['users'])
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

cancel() {
  // Cancel retry logic
  this.retryer?.cancel()

  // Abort network request
  this.abortController?.abort()

  // Clear promise
  this.promise = null
}
Enter fullscreen mode Exit fullscreen mode

If queryFn supports AbortSignal:

queryFn: ({ signal }) => 
  fetch('/api/users', { signal })
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Event Types:

type QueryCacheNotifyEvent = 
  | { type: 'added'; query: Query }
  | { type: 'removed'; query: Query }
  | { type: 'updated'; query: Query; action: UpdateAction }
Enter fullscreen mode Exit fullscreen mode

Who subscribes?

1. DevTools:

queryCache.subscribe((event) => {
  devtoolsPanel.updateQueryList()
})
Enter fullscreen mode Exit fullscreen mode

2. Custom Loggers:

queryCache.subscribe((event) => {
  console.log('Query event:', event.type, event.query.queryKey)
})
Enter fullscreen mode Exit fullscreen mode

3. Analytics:

queryCache.subscribe((event) => {
  if (event.type === 'added') {
    analytics.track('Query Created', {
      queryKey: event.query.queryKey
    })
  }
})
Enter fullscreen mode Exit fullscreen mode

💡 Real-World Insights {#real-world-insights}

1. QueryCache is NOT React State

Important concept:

QueryCache ≠ React State
QueryCache = Independent Async Store
React = Observer
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

4. Observer Pattern Benefits

Traditional approach (Redux):

// Component connects to entire state
const state = useSelector(state => state)
// Re-renders on ANY state change
Enter fullscreen mode Exit fullscreen mode

QueryCache approach:

// Component subscribes to specific query
const { data } = useQuery(['users'])
// Re-renders only when THIS query changes
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

2. Memory Efficiency

Automatic garbage collection:

// Inactive query after 5 min → removed
// No memory leaks
Enter fullscreen mode Exit fullscreen mode

3. Render Optimization

Structural sharing:

// Unchanged data → same reference → no re-render
Enter fullscreen mode Exit fullscreen mode

Observer diffing:

// Only notify if tracked props changed
Enter fullscreen mode Exit fullscreen mode

4. Network Efficiency

Fetch deduplication:

// 10 components, same query → 1 network request
Enter fullscreen mode Exit fullscreen mode

🎯 Key Takeaways

QueryCache is:

  1. Database Layer - Stores all Query instances
  2. Hash Map - Fast O(1) lookups
  3. Event Emitter - Powers DevTools and monitoring
  4. GC Manager - Automatic memory cleanup
  5. Dedup Engine - Prevents duplicate fetches
  6. 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.
Enter fullscreen mode Exit fullscreen mode

📌 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

  1. Read TanStack Query source code
  2. Build custom QueryCache visualizer
  3. Experiment with QueryCache events
  4. Create custom persistence layer
  5. 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)