About us Guides Projects Contacts
Админка
please wait

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

  1. Cache at the right level - closer to client is faster
  2. Use appropriate TTLs - balance freshness with performance
  3. Implement cache invalidation - event-based is most reliable
  4. Monitor cache hit rates - aim for >90%
  5. Avoid caching user-specific data at shared layers
  6. Use cache tags for group invalidation
  7. Implement fallbacks for cache failures
  8. Consider cache warming for critical data
  9. Add TTL jitter to prevent synchronized expirations
  10. 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.

 
 
 
Языки
Темы
Copyright © 1999 — 2026
ZK Interactive