Introduction
Caching is one of the most effective ways to improve API performance and reduce server load. By storing frequently accessed data closer to the consumer, caching reduces latency, database load, and bandwidth costs. This guide covers practical caching strategies for web APIs.
Caching Layers
Client ──> CDN ──> API Gateway ──> Application ──> Database
↓ ↓ ↓ ↓
Browser Edge In-Memory Query Cache
Storage Cache (Redis)
Each layer serves different purposes:
- Client-side: Reduces requests entirely
- CDN/Edge: Caches at geographic distribution points
- Application: Caches computed results
- Database: Caches query results
HTTP Caching Headers
Cache-Control
// Laravel response with cache headers
return response()
->json($data)
->header('Cache-Control', 'public, max-age=3600') // Cache for 1 hour
->header('ETag', md5(json_encode($data)));
// Cache-Control directives
// public: CDN and browser can cache
// private: Only the browser can cache
// no-cache: Must revalidate before using cache
// no-store: Don't cache at all
// max-age: Seconds until stale
// s-maxage: Max age for shared caches (CDN)
ETags and Conditional Requests
// Laravel middleware for ETag support
class ETagMiddleware
{
public function handle($request, Closure $next)
{
$response = $next($request);
if ($request->isMethod('GET')) {
$etag = md5($response->getContent());
$response->header('ETag', '"' . $etag . '"');
// Check the If-None-Match header
$requestEtag = $request->header('If-None-Match');
if ($requestEtag && $requestEtag === '"' . $etag . '"') {
return response()->noContent(304);
}
}
return $response;
}
}
Last-Modified
public function show(Product $product)
{
$lastModified = $product->updated_at;
// Check the If-Modified-Since header
$ifModifiedSince = request()->header('If-Modified-Since');
if ($ifModifiedSince) {
$ifModifiedSince = Carbon::parse($ifModifiedSince);
if ($lastModified <= $ifModifiedSince) {
return response()->noContent(304);
}
}
return response()
->json($product)
->header('Last-Modified', $lastModified->toRfc7231String())
->header('Cache-Control', 'private, max-age=60');
}
Application-Level Caching
Redis Caching with Laravel
// Basic caching
$products = Cache::remember('products.all', 3600, function () {
return Product::with('category')->get();
});
// Tagged caching (for invalidation)
$product = Cache::tags(['products'])->remember(
"products.{$id}",
3600,
function () use ($id) {
return Product::with('reviews')->find($id);
}
);
// Invalidate by tag
Cache::tags(['products'])->flush();
// Cache with lock (prevent stampede)
$value = Cache::lock('processing')->get(function () {
return expensiveOperation();
});
Caching Patterns
Cache-Aside (Lazy Loading)
class ProductRepository
{
public function find(int $id): ?Product
{
$cacheKey = "product:{$id}";
// Try cache first
$product = Cache::get($cacheKey);
if ($product === null) {
// Cache miss - load from database
$product = Product::find($id);
if ($product) {
Cache::put($cacheKey, $product, 3600);
}
}
return $product;
}
public function update(int $id, array $data): Product
{
$product = Product::findOrFail($id);
$product->update($data);
// Invalidate cache
Cache::forget("product:{$id}");
return $product;
}
}
Write-Through
public function update(int $id, array $data): Product
{
$product = Product::findOrFail($id);
$product->update($data);
// Update cache immediately
Cache::put("product:{$id}", $product, 3600);
return $product;
}
Cache-Aside with Stale-While-Revalidate
public function getProducts(): array
{
$cacheKey = 'products.list';
$staleKey = 'products.list.stale';
// Check fresh cache
$fresh = Cache::get($cacheKey);
if ($fresh !== null) {
return $fresh;
}
// Check stale cache and trigger background refresh
$stale = Cache::get($staleKey);
if ($stale !== null) {
// Dispatch job to refresh cache
RefreshProductsCacheJob::dispatch();
return $stale;
}
// No cache at all - fetch and cache
$products = $this->fetchProducts();
Cache::put($cacheKey, $products, 60); // Fresh for 1 minute
Cache::put($staleKey, $products, 3600); // Stale for 1 hour
return $products;
}
Query Result Caching
Database Query Cache
// Laravel query caching
$products = Product::where('active', true)
->orderBy('name')
->cacheFor(now()->addMinutes(30))
->cacheTags(['products'])
->get();
Computed Value Caching
class DashboardService
{
public function getStatistics(): array
{
return Cache::remember('dashboard.stats', 300, function () {
return [
'total_orders' => Order::count(),
'revenue' => Order::sum('total'),
'avg_order_value' => Order::avg('total'),
'top_products' => $this->getTopProducts(),
];
});
}
private function getTopProducts(): Collection
{
return Product::withCount('orderItems')
->orderByDesc('order_items_count')
->limit(10)
->get();
}
}
Cache Invalidation
Event-Based Invalidation
// Event listener
class InvalidateProductCache
{
public function handle(ProductUpdated $event): void
{
$product = $event->product;
// Clear specific product
Cache::forget("product:{$product->id}");
// Clear related caches
Cache::tags(['products', 'categories'])->flush();
// Clear listing caches
Cache::forget('products.list');
Cache::forget("category:{$product->category_id}.products");
}
}
// Model observer
class ProductObserver
{
public function saved(Product $product): void
{
Cache::forget("product:{$product->id}");
}
public function deleted(Product $product): void
{
Cache::forget("product:{$product->id}");
}
}
Time-Based Invalidation
// Short TTL for frequently changing data
Cache::put('active_users', $count, 60); // 1 minute
// Longer TTL for stable data
Cache::put('site_settings', $settings, 86400); // 1 day
// Progressive TTL based on age
$ttl = min(86400, $product->created_at->diffInSeconds(now()) / 10);
Cache::put("product:{$id}", $product, $ttl);
CDN Caching
Cloudflare Cache Headers
// Cache at CDN for 1 hour
return response()
->json($data)
->header('Cache-Control', 'public, s-maxage=3600, max-age=60');
// Vary by the Accept header (JSON vs HTML)
return response()
->json($data)
->header('Cache-Control', 'public, max-age=300')
->header('Vary', 'Accept, Accept-Encoding');
Purging CDN Cache
// Cloudflare API
use Illuminate\Support\Facades\Http;
class CloudflareService
{
public function purgeUrls(array $urls): void
{
Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.cloudflare.token'),
'Content-Type' => 'application/json',
])->delete(
"https://api.cloudflare.com/client/v4/zones/{$this->zoneId}/purge_cache",
['files' => $urls]
);
}
public function purgeByTags(array $tags): void
{
Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.cloudflare.token'),
])->delete(
"https://api.cloudflare.com/client/v4/zones/{$this->zoneId}/purge_cache",
['tags' => $tags]
);
}
}
Response Compression
// Nginx configuration
gzip on;
gzip_types application/json text/plain application/xml;
gzip_min_length 1000;
// Laravel middleware for JSON compression
class CompressResponse
{
public function handle($request, Closure $next)
{
$response = $next($request);
if ($request->header('Accept-Encoding') &&
str_contains($request->header('Accept-Encoding'), 'gzip')) {
$content = gzencode($response->getContent(), 6);
$response->setContent($content);
$response->header('Content-Encoding', 'gzip');
$response->header('Content-Length', strlen($content));
}
return $response;
}
}
Client-Side Caching
Browser Cache Control
// Service worker for offline caching
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
caches.open('api-cache').then((cache) => {
return fetch(event.request)
.then((response) => {
cache.put(event.request, response.clone());
return response;
})
.catch(() => cache.match(event.request));
})
);
}
});
React Query Caching
import { useQuery, QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
},
},
});
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json()),
staleTime: 60000, // Consider fresh for 1 minute
});
}
Monitoring and Metrics
// Track cache performance
class CacheMetrics
{
public function get(string $key)
{
$start = microtime(true);
$value = Cache::get($key);
$duration = microtime(true) - $start;
if ($value !== null) {
Metrics::increment('cache.hits', ['key' => $key]);
} else {
Metrics::increment('cache.misses', ['key' => $key]);
}
Metrics::timing('cache.latency', $duration);
return $value;
}
}
The Thundering Herd Problem
Scenario: A popular key (e.g., homepage_news) expires at 12:00:00. Result: 10,000 users request it at 12:00:01. All 10,000 requests hit the database simultaneously. The database crashes.
Solution 1: Probabilistic Early Expiration (Jitter)
Instead of hard expiration, store a delta value. If (now + delta) > expiration, recompute the value before it actually expires:
public function getCachedValue(string $key, int $ttl, callable $compute)
{
$cached = Cache::get($key);
if ($cached) {
// Check if we should proactively refresh
$expiresAt = Cache::get("{$key}:expires_at");
$delta = rand(0, 60); // Random jitter
if ((time() + $delta) > $expiresAt) {
// Proactive refresh in background
dispatch(new RefreshCacheJob($key, $compute));
}
return $cached;
}
$value = $compute();
Cache::put($key, $value, $ttl);
Cache::put("{$key}:expires_at", time() + $ttl, $ttl + 60);
return $value;
}
Solution 2: Cache Locking
When a cache miss occurs: 1. Acquire a Redis lock (SETNX lock:key 1) 2. If locked by another process, sleep 50 ms and retry Redis 3. If acquired, compute the value, update Redis, and release the lock
public function getCachedValueWithLock(string $key, callable $compute)
{
$value = Cache::get($key);
if ($value !== null) {
return $value;
}
// Try to acquire lock
$lock = Cache::lock("lock:{$key}", 10);
if ($lock->get()) {
try {
// Double-check after acquiring lock
$value = Cache::get($key);
if ($value === null) {
$value = $compute();
Cache::put($key, $value, 3600);
}
} finally {
$lock->release();
}
} else {
// Wait for another process to fill cache
usleep(50000); // 50 ms
$value = Cache::get($key);
}
return $value;
}
Redis Eviction Policies
When Redis is full, what happens depends on your eviction policy:
noeviction: Returns an error on write. (Bad for caches)allkeys-lru: Removes least recently used keys. (Standard for caching)volatile-lru: Removes LRU keys with an expire set. (Good if you use Redis for both persistent data and cache)
Set the appropriate policy in Redis config:
maxmemory-policy allkeys-lru
Advanced Redis Structures
Beyond simple key-value caching:
- Sorted Sets (ZSET): Great for leaderboards.
ZADD scores 100 "user1" - HyperLogLog: Count unique visitors with 99% accuracy using only 12 KB of RAM (vs. megabytes for a Set)
Best Practices
- Cache at the right level - closer to client is faster
- Use appropriate TTLs - balance freshness with performance
- Implement cache invalidation - event-based is most reliable
- Monitor cache hit rates - aim for >90%
- Avoid caching user-specific data at shared layers
- Use cache tags for group invalidation
- Implement fallbacks for cache failures
- Consider cache warming for critical data
- Add TTL jitter to prevent synchronized expirations
- Treat Redis as volatile - your app must function if Redis is flushed
Conclusion
Effective caching dramatically improves API performance. Layer caches appropriately—HTTP headers for CDN and browser, Redis for application data, and query cache for database results. Implement proper invalidation strategies, handle thundering herds with locking or jitter, and monitor hit rates to ensure caching is effective. Remember: Redis is an optimization layer, not a source of truth.