Software Teams

Caching Strategies for Web Applications: Beyond the Basics

Advanced caching strategies covering cache invalidation patterns, multi-layer caching, cache warming, and avoiding common pitfalls.

The Two Hard Problems

There is a famous quote: "There are only two hard things in Computer Science: cache invalidation and naming things." After years of building web applications, I would add a third: knowing when not to cache.

Caching is the most effective performance optimization available, but poorly implemented caching creates bugs that are maddeningly difficult to reproduce and debug. This guide covers strategies that work reliably in production.

Caching Layers

A well-designed application uses multiple caching layers, each serving a different purpose:

Layer 1: Browser Cache

The closest cache to the user. Controlled via HTTP headers:

Cache-Control: public, max-age=31536000, immutable

For assets with content hashes in filenames (app.a3b2c1.js), set aggressive caching with immutable. The hash changes when the content changes, so browsers always get fresh files.

For HTML pages, be more conservative:

Cache-Control: private, no-cache
ETag: "abc123"

no-cache does not mean "do not cache." It means "always revalidate with the server before using the cached version." If the ETag matches, the server responds with 304 Not Modified and the browser uses its cached copy.

Layer 2: CDN / Reverse Proxy

Sits between users and your application. Serves cached responses without hitting your application server at all.

What to cache at the CDN level:

  • Static assets (images, CSS, JavaScript, fonts)
  • Public pages that are identical for all users (marketing pages, documentation)
  • API responses for public data (product listings, category trees)

What not to cache at the CDN level:

  • Authenticated pages with user-specific content
  • Real-time data (stock prices, live dashboards)
  • POST/PUT/DELETE responses

Use Vary headers to ensure the CDN serves the correct variant:

Vary: Accept-Encoding, Accept-Language

Layer 3: Application Cache (Redis/Memcached)

Your in-application cache for computed data, database query results, and external API responses.

// Cache an expensive database query
$dashboard = Cache::remember('dashboard:' . $userId, 300, function () use ($userId) {
    return [
        'invoices_due' => Invoice::forUser($userId)->overdue()->count(),
        'revenue_mtd' => Invoice::forUser($userId)->thisMonth()->sum('total'),
        'recent_activity' => Activity::forUser($userId)->recent()->limit(10)->get(),
    ];
});

Layer 4: Database Query Cache

MySQL's query cache (deprecated in MySQL 8.0) and PostgreSQL's shared buffers cache query plans and results at the database level. You typically do not manage these directly, but understanding that they exist helps you reason about performance.

Cache Invalidation Patterns

Time-Based Expiry (TTL)

The simplest approach. Set a time-to-live and accept that data might be stale within that window.

Cache::put('product-catalog', $products, now()->addMinutes(15));

Works well for: Data where slight staleness is acceptable. Product listings, blog posts, category trees, configuration values.

Fails for: Data where stale values cause incorrect behavior. Account balances, inventory counts, permission changes.

Event-Based Invalidation

Clear or update the cache when the underlying data changes:

class InvoiceObserver
{
    public function saved(Invoice $invoice): void
    {
        Cache::forget('dashboard:' . $invoice->customer_id);
        Cache::forget('invoice-list:' . $invoice->customer_id);
    }
}

The challenge: You must identify every cache key affected by a change. Miss one and you serve stale data. As the codebase grows, maintaining these relationships becomes error-prone.

Cache Tags

Tags group related cache entries for bulk invalidation:

// When writing
Cache::tags(['invoices', 'customer:42'])->put('invoice-list:42', $invoices, 3600);
Cache::tags(['invoices', 'customer:42'])->put('invoice-stats:42', $stats, 3600);

// When invalidating
Cache::tags(['customer:42'])->flush(); // Clears everything for customer 42
Cache::tags(['invoices'])->flush();    // Clears all invoice caches

Note: Cache tags require a backend that supports them (Redis, Memcached). File and database cache drivers do not support tags.

Versioned Cache Keys

Instead of invalidating cache entries, change the cache key:

// Store a version counter
Cache::put('product-catalog-version', $version);

// Use the version in cache keys
$cacheKey = 'product-catalog:v' . Cache::get('product-catalog-version');
$products = Cache::remember($cacheKey, 3600, fn () => Product::all());

When products change, increment the version. Old cache entries expire naturally via TTL. This avoids the thundering herd problem where cache invalidation causes all requests to hit the database simultaneously.

Cache Warming

For data that is expensive to compute and must be immediately available, warm the cache proactively instead of waiting for the first request:

class WarmDashboardCache extends Command
{
    protected $signature = 'cache:warm-dashboards';

    public function handle(): void
    {
        Customer::active()->chunk(100, function ($customers) {
            foreach ($customers as $customer) {
                Cache::put(
                    'dashboard:' . $customer->id,
                    DashboardData::compute($customer),
                    now()->addMinutes(30),
                );
            }
        });
    }
}

Schedule this command to run before cache entries expire. Users never experience a cache miss.

Common Caching Mistakes

Caching Too Early

Do not cache anything until you have evidence it is slow. Premature caching adds complexity without solving a real problem. Profile first, cache second.

Caching Mutable State Across Requests

// Dangerous: user permissions cached for an hour
$permissions = Cache::remember('permissions:' . $userId, 3600, fn () => $user->permissions);

// An admin revokes a permission, but the cache still grants access for up to 60 minutes

For security-sensitive data, use short TTLs or event-based invalidation. Never cache permissions or authentication state with long TTLs.

Cache Stampede

When a popular cache entry expires, hundreds of simultaneous requests hit the database to rebuild it. Use atomic locks to prevent this:

$value = Cache::remember('popular-products', 600, function () {
    return Cache::lock('popular-products-lock', 10)->block(5, function () {
        // Only one process rebuilds the cache
        return Product::popular()->get();
    });
});

Not Monitoring Cache Hit Rates

A cache with a 50% hit rate is barely helping. A cache with a 99% hit rate is transformative. Monitor hit rates and investigate drops:

$value = Cache::get($key);

if ($value === null) {
    Metrics::increment('cache.miss', ['key_prefix' => $prefix]);
    $value = $this->computeExpensiveValue();
    Cache::put($key, $value, $ttl);
} else {
    Metrics::increment('cache.hit', ['key_prefix' => $prefix]);
}

The Golden Rule

Cache the result of computation, not the computation itself. If you find yourself caching Eloquent models with all their relationships loaded, consider whether a simpler data structure (an array, a DTO) would serve the read path better with less memory overhead.

Let's talk about your software teams needs

Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.

Book a 30-min Call