Skip to content

← Writing

engineering

Caching and CDN: Don't Compute What You Already Know

· Jerwin Arnado ·

Part ten of the full-stack series. So far we’ve made the app correct and safe. This layer makes it fast — and fast is a feature users feel before they notice anything else. The whole idea reduces to one sentence: don’t compute what you already know. If a result is expensive to produce and doesn’t change every second, store it and serve the stored copy. Caching does this on your servers; a CDN does it at the edge, near the user.

Caching is layered, top to bottom

There isn’t one cache — there’s a stack of them, each catching what the one above missed:

Layer Caches Lives in
Browser static assets, responses the user’s machine
CDN / edge static files, cacheable responses servers worldwide
Application query results, computed values Redis / Memcached
Database query plans, hot pages the DB’s own buffers
OPcache compiled PHP bytecode the PHP runtime

A request ideally gets answered as high up this stack as possible. The closer to the user it’s satisfied, the faster — and the less work every layer below has to do.

Application caching: the expensive-result store

The bread-and-butter pattern: wrap an expensive operation (a heavy aggregate query, a third-party API call) so the result is computed once and reused until it expires.

// Compute once, serve from Redis for an hour
$stats = Cache::remember("dashboard:{$user->id}", now()->addHour(), function () use ($user) {
    return $user->orders()                 // the expensive query we don't want to repeat
        ->selectRaw('count(*) total, sum(amount) revenue')
        ->first();
});

Cache::remember checks the store, returns the hit, or runs the closure and stores it on a miss. Back it with Redis in production — it’s in-memory (microsecond reads) and shared across all your app servers, which the file/DB cache drivers aren’t. Redis pulls double duty here: it’s also the backbone for sessions, queues, and the rate limiter.

The hard part: invalidation

There are only two hard things in computer science: cache invalidation and naming things.

The joke is true. A stale cache serves wrong data, which is often worse than slow data. Two strategies that keep you sane:

  • TTL (time-based): let it expire after N minutes. Simple; tolerates a window of staleness. Right for dashboards, counts, “good enough” freshness.
  • Event-based (bust on write): when the underlying data changes, delete the key so the next read recomputes. Precise; correct immediately.
// When an order changes, kill the cached dashboard so it rebuilds fresh
Order::saved(fn ($order) => Cache::forget("dashboard:{$order->user_id}"));

Pick TTL when slightly-stale is fine and simplicity wins; pick event-based when correctness must be immediate. Many systems use both — short TTL as a safety net, plus busting on the writes that matter.

CDN: move bytes closer to people

A user across the world shouldn’t wait while bytes cross an ocean. A Content Delivery Network (Cloudflare, Bunny, CloudFront) caches your static content — images, CSS, JS, fonts, and the files in object storage — on servers near every user. First request fills the edge cache; the rest are local-fast.

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

This works because of the hashed asset filenames from the frontend post: app.a3f9.css can be cached forever, because when content changes the hash changes, so the URL changes — instant invalidation without ever serving stale CSS. A CDN also slashes your egress bill and absorbs traffic spikes before they reach your origin.

Caveats and best practices

  • Never cache user-private data in a shared cache without keying it per user. A per-user dashboard cached under a global key leaks one user’s data to all — an access-control bug wearing a performance hat.
  • Cache the expensive thing, not everything. A 1ms query behind a cache adds complexity for nothing. Measure first; cache the proven-slow paths.
  • Set a sane default TTL. An eternal cache with no expiry is a future debugging session where “the data won’t update” and nobody remembers the cache.
  • A cache is an optimization, not a source of truth. The app must work correctly (just slower) with the cache flushed. If flushing it breaks correctness, you’ve built a bug.

Conclusion

Idea     → don't compute what you already know
Layers   → browser → CDN → app (Redis) → DB → OPcache
App cache→ Cache::remember(key, ttl, fn) on proven-slow paths
Invalidate→ TTL (simple) vs event-based bust (precise) — often both
CDN      → static + files at the edge; hashed names = free invalidation

Caching and a CDN are how a correct app becomes a fast one — by doing expensive work once and serving it from as close to the user as possible. Just respect invalidation, and never cache one user’s data where another can read it. Next: load balancing and scaling — what happens when even a fast app has more users than one server can hold.