Loading learning content...
No matter how well-optimized your queries are, no matter how well-designed your indexes, no matter how carefully sized your connection pools—the fastest query is the one you never execute.
Consider an e-commerce product page. Every visitor loads the same product details—title, description, price, images, reviews summary. If 10,000 visitors view that product in an hour, should the database execute the same query 10,000 times? The answer is obvious: cache the result.
Query caching is the practice of storing query results so that identical subsequent requests can be served without hitting the database. A well-implemented caching strategy can reduce database load by 90% or more, transform response times from hundreds of milliseconds to single-digit milliseconds, and enable applications to scale far beyond what the database alone could handle.
But caching introduces complexity: cache invalidation (updating caches when data changes), cache consistency (ensuring users see fresh data), and cache failure handling (what happens when the cache is unavailable). This page explores the full landscape of query caching strategies.
By the end of this page, you will understand: the layers of caching in a modern application stack; application-level caching with Redis and Memcached; database-native query caches; cache key design and TTL strategies; cache invalidation patterns; and real-world caching architectures used at scale.
Query caching can occur at multiple levels of the application stack, each with different characteristics and trade-offs.
The caching stack (from client to database):
| Layer | What's Cached | Latency | Scope | Invalidation |
|---|---|---|---|---|
| Browser cache | HTTP responses, assets | <1ms | Single user | Cache headers (max-age) |
| CDN edge cache | Full pages, API responses | ~10-50ms | Geographic region | Purge API, TTL |
| API response cache | Serialized JSON responses | ~1-5ms | All users of endpoint | TTL, event-based |
| Application cache | Query results, computed values | ~1-5ms | Application cluster | Event-based, TTL |
| ORM/Query cache | Hydrated objects, query results | ~1-10ms | Application instance | Automatic on write |
| Database query cache | Parsed queries, result sets | ~5-20ms | Database server | Table-level invalidation |
| Database buffer pool | Hot data pages | ~0.1ms vs disk | Database server | Automatic (LRU) |
The principle of layered caching:
Effective caching strategies layer multiple cache types:
Each layer closer to the user has lower latency but typically smaller capacity and shorter TTLs.
Cache hit ratio = (cache hits) / (cache hits + cache misses). A 90% hit ratio means 90% of requests are served from cache. Target 95%+ for frequently-accessed data. If hit ratio is low, investigate: wrong TTL, keys not matching, cache too small, or data too diverse to benefit from caching.
Application-level caching is the most flexible and commonly used approach. The application explicitly checks a cache (typically Redis or Memcached) before executing database queries, and populates the cache on misses.
The cache-aside (lazy loading) pattern:
This pattern is simple, widely applicable, and puts application in full control of what gets cached and for how long.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// Cache-aside pattern with Redisimport Redis from 'ioredis';import { Pool } from 'pg'; const redis = new Redis({ host: 'redis.internal', port: 6379, maxRetriesPerRequest: 3,}); const db = new Pool({ host: 'db.internal', max: 20,}); interface Product { id: string; name: string; price: number; description: string;} // Cache key namespacing - prevents collisionsfunction productCacheKey(productId: string): string { return `product:v1:${productId}`; // v1 allows cache busting on schema change} async function getProduct(productId: string): Promise<Product | null> { const cacheKey = productCacheKey(productId); // 1. Try cache first const cached = await redis.get(cacheKey); if (cached) { console.log(`Cache HIT for ${cacheKey}`); return JSON.parse(cached) as Product; } console.log(`Cache MISS for ${cacheKey}`); // 2. Cache miss - query database const result = await db.query<Product>( 'SELECT id, name, price, description FROM products WHERE id = $1', [productId] ); const product = result.rows[0] ?? null; // 3. Populate cache (even for null to prevent cache stampede) if (product) { await redis.setex( cacheKey, 3600, // TTL: 1 hour JSON.stringify(product) ); } else { // Cache negative result with shorter TTL await redis.setex(cacheKey, 60, JSON.stringify(null)); } return product;} // Invalidate cache on writeasync function updateProduct(productId: string, updates: Partial<Product>): Promise<void> { // Update database await db.query( 'UPDATE products SET name = COALESCE($1, name), price = COALESCE($2, price) WHERE id = $3', [updates.name, updates.price, productId] ); // Invalidate cache const cacheKey = productCacheKey(productId); await redis.del(cacheKey); // Optional: Pre-populate cache with new value // await getProduct(productId);}When a popular cache entry expires, hundreds of concurrent requests may all hit the database simultaneously to regenerate it (thundering herd). Solutions: lock during regeneration (only one request queries DB, others wait for cache); probabilistic early expiration (refresh before expiry); or stale-while-revalidate (serve stale data while refreshing in background).
Good cache key design is essential for cache correctness and efficient invalidation. A poorly designed key scheme leads to stale data, collisions, or inability to invalidate properly.
Cache key principles:
user:123, product:456, order:789. Enables type-specific operations.product:v2:123. When product schema changes, increment version to invalidate all old entries.products:category:electronics:page:2:limit:20. Different query = different cache entry.tags:[a,b] and tags:[b,a] should produce the same key.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// Cache key design patterns // Pattern 1: Simple entity cachingconst userCacheKey = (userId: string) => `user:v1:${userId}`;const productCacheKey = (productId: string) => `product:v1:${productId}`; // Pattern 2: Query result caching with normalized parametersinterface ProductQueryParams { category?: string; minPrice?: number; maxPrice?: number; sortBy?: 'price' | 'rating' | 'newest'; page?: number; limit?: number;} function productListCacheKey(params: ProductQueryParams): string { // Normalize: sort keys, filter undefined const normalized = Object.entries(params) .filter(([_, v]) => v !== undefined) .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${k}:${v}`) .join(':'); return `products:list:v1:${normalized}`;} // Examples:// productListCacheKey({ category: 'electronics', page: 1 })// => "products:list:v1:category:electronics:page:1" // productListCacheKey({ page: 1, category: 'electronics' }) // => "products:list:v1:category:electronics:page:1" (same key!) // Pattern 3: User-specific cached datafunction userFeedCacheKey(userId: string, page: number): string { return `feed:v1:user:${userId}:page:${page}`;} // Pattern 4: Hash for complex queriesimport crypto from 'crypto'; function complexQueryCacheKey(query: string, params: unknown[]): string { const hash = crypto .createHash('sha256') .update(query + JSON.stringify(params)) .digest('hex') .substring(0, 16); // Truncate for readability return `query:v1:${hash}`;} // Pattern 5: Tag-based cache keys for invalidationfunction orderCacheKey(orderId: string): string { return `order:v1:${orderId}`;} function userOrdersCacheKey(userId: string): string { return `user:${userId}:orders:v1`;} // Store both keys in Redis SET for efficient invalidationasync function cacheOrder(orderId: string, userId: string, data: unknown) { const orderKey = orderCacheKey(orderId); const userOrdersKey = userOrdersCacheKey(userId); // Cache the order await redis.setex(orderKey, 3600, JSON.stringify(data)); // Track which keys relate to this user (for invalidation) await redis.sadd(`user:${userId}:cache-keys`, orderKey); // Now when user data changes, we can invalidate all related caches: // const keys = await redis.smembers(`user:${userId}:cache-keys`); // await redis.del(...keys);}Use colons (:) as namespace separators—this is a Redis convention that tools like Redis Commander understand. Avoid spaces and special characters. Example: app:users:v1:12345:profile. The colon delimiter makes it easy to use SCAN with patterns like app:users:* to find or invalidate all user-related keys.
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Cache invalidation—ensuring cached data is updated when the underlying data changes—is genuinely difficult. Get it wrong and users see stale data. Be too aggressive and you lose the benefits of caching.
Invalidation strategies:
| Strategy | How It Works | Pros | Cons |
|---|---|---|---|
| TTL (Time-to-Live) | Cache expires after fixed time | Simple, self-healing | Stale data during TTL window |
| Write-through invalidation | Delete/update cache on every write | Strong consistency | Requires knowing all cache keys affected |
| Event-driven invalidation | Publish events on change, consumers invalidate | Decoupled, scalable | Eventual consistency, complexity |
| Tag-based invalidation | Associate cache entries with tags, purge by tag | Flexible grouping | Requires tag tracking infrastructure |
| Version-based | Increment version on data change, keys include version | Simple, atomic | Uses more cache memory |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// Pattern 1: Write-through invalidationasync function updateProduct(productId: string, data: Partial<Product>) { // Update database await db.query('UPDATE products SET name = $1 WHERE id = $2', [data.name, productId]); // Immediately invalidate cache await redis.del(`product:v1:${productId}`); // Also invalidate any lists that might contain this product const categories = await getProductCategories(productId); for (const cat of categories) { // Pattern-based deletion (Redis SCAN + DEL) const pattern = `products:list:v1:category:${cat}:*`; const keys = await scanKeys(pattern); if (keys.length > 0) { await redis.del(...keys); } }} // Pattern 2: Event-driven invalidation with Redis pub/subimport { EventEmitter } from 'events'; const cacheEvents = new EventEmitter(); // On product update, publish eventasync function updateProductWithEvent(productId: string, data: Partial<Product>) { await db.query('UPDATE products SET name = $1 WHERE id = $2', [data.name, productId]); // Publish cache invalidation event await redis.publish('cache:invalidate', JSON.stringify({ type: 'product', id: productId, action: 'update', }));} // Subscriber (runs in each app instance)async function setupCacheInvalidationSubscriber() { const subscriber = redis.duplicate(); await subscriber.subscribe('cache:invalidate'); subscriber.on('message', async (channel, message) => { const event = JSON.parse(message); if (event.type === 'product') { // Invalidate local caches (if any in-memory caching) // Invalidate Redis caches await redis.del(`product:v1:${event.id}`); } });} // Pattern 3: Version-based invalidation (cache-busting)class VersionedCache { private versionKey = (entity: string) => `version:${entity}`; async getVersion(entity: string): Promise<number> { const version = await redis.get(this.versionKey(entity)); return version ? parseInt(version, 10) : 1; } async incrementVersion(entity: string): Promise<number> { return redis.incr(this.versionKey(entity)); } async getCacheKey(entity: string, id: string): Promise<string> { const version = await this.getVersion(entity); return `${entity}:v${version}:${id}`; }} const versionedCache = new VersionedCache(); // On update, just increment version - old keys naturally expireasync function updateProductVersioned(productId: string, data: Partial<Product>) { await db.query('UPDATE products SET name = $1 WHERE id = $2', [data.name, productId]); // Increment version - all old cache keys become orphaned await versionedCache.incrementVersion('product'); // Old keys like "product:v3:123" are now ignored // New reads use "product:v4:123" // Old keys expire via TTL, no explicit deletion needed}Version-based invalidation is elegant: instead of finding and deleting all affected cache keys, increment a version number. All cache keys include the version, so old keys simply stop being used. They expire naturally via TTL. This trades memory (temporary duplicate entries) for simplicity (no complex invalidation logic).
Some databases provide built-in query caching. Understanding their capabilities and limitations helps you decide when to rely on them versus implementing application-level caching.
Important: MySQL's query cache was deprecated in MySQL 5.7.20 and removed in MySQL 8.0. Why? The query cache caused significant contention on multi-core systems due to the global mutex required to manage it.
For historical context and MySQL 5.7:
1234567891011121314151617181920212223242526272829303132
-- MySQL 5.7 query cache (deprecated, removed in 8.0)-- Enable query cache (my.cnf)-- query_cache_type = 1-- query_cache_size = 128M -- Check query cache statusSHOW STATUS LIKE 'Qcache%'; -- Qcache_hits: 1234567 <- Number of query cache hits-- Qcache_inserts: 789012 <- Queries added to cache-- Qcache_not_cached: 456 <- Queries not cacheable-- Qcache_lowmem_prunes: 12 <- Evictions due to memory pressure -- Query cache hit ratioSELECT (Qcache_hits / (Qcache_hits + Com_select)) * 100 as hit_ratioFROM ( SELECT (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Qcache_hits') as Qcache_hits, (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Com_select') as Com_select) t; -- Why query cache was removed:-- 1. Global mutex serialized all cache access-- 2. Any write to a table invalidated ALL cached queries for that table-- 3. Marginal benefit for write-heavy workloads-- 4. Memory management overhead -- Modern alternative: Use ProxySQL query caching-- Or application-level caching with RedisBeyond basic cache-aside, several advanced patterns address specific challenges in production systems.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// Request coalescing to prevent cache stampedesclass CoalescingCache { private inflight = new Map<string, Promise<unknown>>(); async get<T>( key: string, fetcher: () => Promise<T>, ttlSeconds: number ): Promise<T> { // Check cache first const cached = await redis.get(key); if (cached) { return JSON.parse(cached) as T; } // Check if another request is already fetching this key const existing = this.inflight.get(key) as Promise<T> | undefined; if (existing) { console.log(`Coalescing request for ${key}`); return existing; // Wait for the in-flight request } // This request will fetch const promise = (async () => { try { const data = await fetcher(); await redis.setex(key, ttlSeconds, JSON.stringify(data)); return data; } finally { this.inflight.delete(key); // Clean up } })(); this.inflight.set(key, promise); return promise; }} const cache = new CoalescingCache(); // 100 concurrent requests for same product// Only ONE database query executes; 99 wait for the resultawait Promise.all( Array(100).fill(null).map(() => cache.get( 'product:123', () => db.query('SELECT * FROM products WHERE id = 123'), 3600 ) )); // Stale-while-revalidate patternasync function getWithSWR<T>( key: string, fetcher: () => Promise<T>, ttlSeconds: number, staleSeconds: number // How long stale data is acceptable): Promise<T> { const cached = await redis.get(key); if (cached) { const { data, cachedAt } = JSON.parse(cached); const age = (Date.now() - cachedAt) / 1000; if (age < ttlSeconds) { // Fresh: return immediately return data as T; } if (age < ttlSeconds + staleSeconds) { // Stale but acceptable: return stale data, refresh in background console.log(`Serving stale data for ${key}, refreshing in background`); // Background refresh (don't await) fetcher().then(freshData => { redis.setex(key, ttlSeconds + staleSeconds, JSON.stringify({ data: freshData, cachedAt: Date.now(), })); }).catch(err => console.error('Background refresh failed:', err)); return data as T; } } // Cache miss or too stale: fetch synchronously const data = await fetcher(); await redis.setex(key, ttlSeconds + staleSeconds, JSON.stringify({ data, cachedAt: Date.now(), })); return data;} // Usage: 1 hour fresh, 4 hours stale acceptableconst product = await getWithSWR( 'product:123', () => fetchProductFromDB('123'), 3600, // 1 hour TTL 14400 // 4 hour stale tolerance);After deploying new application instances, their local/memory caches are cold. For critical paths, implement cache warming: proactively populate caches with hot data before accepting traffic. This prevents degraded performance during the warm-up period. Kubernetes readiness probes should account for cache warming time.
Module Complete:
You've now completed the Database Performance module. You understand the full spectrum of database optimization: from writing efficient queries and designing effective indexes, through managing connections and scaling with replicas, to eliminating database load entirely with query caching.
These techniques work together: optimized queries benefit from good indexes; connection pooling enables concurrency; read replicas distribute load; and caching eliminates redundant work. A well-tuned database layer is the foundation of every scalable system.
Congratulations! You've mastered database performance optimization—from query-level tuning to system-level scaling. These skills are essential for building systems that remain fast and reliable as data and traffic grow. Apply these techniques systematically, measure before and after, and your databases will thank you.