Your app is slow, your database bill is climbing, and someone on the team has
just said “let’s add some caching.” It’s good instinct — but caching strategy
for web apps is one of those areas where a well-intentioned quick fix can create more
problems than it solves. This guide is for founders, CTOs and product leads
who need to understand the trade-offs well enough to make the call, brief an
engineering team, or sanity-check a proposal, without having to become a
caching expert themselves.
We’ll walk through where caching actually belongs in your stack, the main
caching strategies worth knowing, the invalidation problem that catches most
teams out, and a practical decision framework for what to cache first.
Why a caching strategy for web apps matters more than “just add Redis”
Caching reduces the number of times your application has to do expensive
work — hitting a database, calling a third-party API, rendering a page — by
storing the result somewhere faster and reusing it. Done well, it can cut
response times dramatically and meaningfully reduce infrastructure spend,
because every cached read is a read your database never has to serve.
Done badly, it introduces a second source of truth that quietly drifts out
of sync with your real data. Users see stale prices, old account balances, or
their own profile changes reverting after a refresh. These bugs are
notoriously hard to reproduce because they depend on timing, not logic — the
code “works” in testing and fails under real traffic. The engineering
discipline isn’t really about choosing a caching library; it’s about deciding
what can safely be stale, for how long, and what happens when it changes.
The cache hierarchy: four layers, four jobs
Most production systems end up with caching at several layers, each solving
a different problem:
- Browser cache — static assets, fonts, and sometimes API
responses cached on the user’s device via HTTP cache headers. Free, fast, but
entirely outside your control once set. - CDN / edge cache — caches full pages or API responses
close to the user, geographically. Ideal for public, mostly-static content:
marketing pages, product catalogues, blog posts. - Application cache — typically Redis or Memcached, sitting
between your application code and your database. This is where most
business-logic caching happens: computed results, session data, rate-limit
counters, expensive query results. - Database cache — the database’s own internal caching
(buffer pools, query plan caches). Largely automatic, but worth knowing it
exists before you duplicate its job badly.
A common mistake is reaching for an application-level cache to solve a
problem that a CDN or correct HTTP headers would solve more simply, with far
less code to maintain. If the data is public and doesn’t change per user,
push the caching as close to the edge as possible before adding a Redis
layer.
The main caching patterns, in plain terms
Once you’ve decided application-level caching is genuinely needed, there
are a handful of well-established patterns. You don’t need all of them — pick
the simplest one that fits the data.
Cache-aside (lazy loading)
Your application checks the cache first. If the data isn’t there, it reads
from the database, returns the result, and writes it into the cache for next
time. This is the sensible default for most teams: it’s simple, it only
caches data that’s actually being requested, and a cache failure just means
falling back to the database rather than breaking the app.
Write-through
Every write updates the cache and the database at the same time, so the
cache is never stale after a write you control. It costs a little write
latency but removes a whole category of “why is this out of date” bugs — a
sensible trade for data like user settings or account state where correctness
matters more than shaving milliseconds off writes.
Write-behind (write-back)
Writes go to the cache immediately and are flushed to the database
asynchronously. It’s fast, but it means a crash before the flush can lose
data — only worth considering for workloads where occasional loss is
tolerable, such as view counters or analytics events, not for anything
transactional.
TTL-based expiry
Rather than actively invalidating, you simply let cached entries expire
after a set time. It’s the easiest pattern to reason about and often the
right starting point: decide how stale is acceptable for a given piece of
data (five seconds? five minutes? a day?) and set the time-to-live
accordingly, rather than trying to invalidate perfectly on every change.
The real problem: invalidation, not caching
Phil Karlton’s old line that there are only two hard problems in computer
science — cache invalidation and naming things — holds up because it’s true.
Writing code that stores a value is easy. Writing code that reliably removes
or updates that value everywhere it’s stored, the moment it becomes wrong, is
not.
The failure pattern we see most often isn’t a bad caching library — it’s a
cache that depends on an invalidation event firing correctly every single
time, with no fallback. A deploy changes a cache key format and the old
invalidation code can’t find the new keys. A background queue silently stalls
and invalidation events stop arriving. A race condition means a write and a
cache refresh happen in the wrong order, and the old value wins. None of
these show up in a demo; all of them show up in production, usually as a
confused support ticket about data that “won’t update.”
Two habits meaningfully reduce this risk. First, always set a TTL even on
data you also invalidate explicitly — treat active invalidation as an
optimisation, not the only line of defence, so a missed event self-heals
within minutes rather than lingering indefinitely. Second, be deliberate about
what you’re willing to cache at all: data that’s genuinely expensive to
compute and tolerant of a short delay is a good candidate; anything involving
money, permissions, or anything a user just changed and expects to see
reflected immediately is not.
A simple framework for what to cache first
If you’re deciding where to start, work through these questions in order:
- Is it expensive? Profile first. Caching a query that
already takes 2ms adds complexity for no benefit. Look for the handful of
queries or external calls that dominate your response times or your
third-party API costs. - Is it read-heavy relative to writes? Caching shines when
something is read far more often than it changes — product listings, pricing
tiers, a dashboard summary refreshed every few minutes. It’s a poor fit for
data that changes on almost every request. - How stale can it be? Put a number on it, not a feeling.
“Up to 60 seconds old” is a spec you can build and test against; “fresh
enough” is not. - What happens if the cache is wrong or unavailable? If the
honest answer is “a customer gets charged twice” or “someone sees another
user’s data,” don’t cache it, full stop — solve the performance problem a
different way, such as query optimisation or an index.
This is the same discipline we bring to cloud cost optimisation
work generally: the cheapest infrastructure win is usually removing
unnecessary work, not adding a faster way to repeat it. Caching is powerful
precisely because it’s selective — it should speed up the small number of
things that are genuinely hot, not become a default wrapped around every
database call.
Caching and the systems around it
Caching strategy rarely sits in isolation. If you’ve already thought
through database schema design, the right indexes may remove the need for a
cache layer entirely for a given query. If you’re running
background job queues, those queues are often exactly where cache
invalidation events should be published and processed reliably, rather than
firing inline inside a request. And if performance is being measured through
Core Web Vitals, CDN and browser caching of static assets is usually
the highest-leverage, lowest-risk change available before any application
caching is touched.
It’s also worth deciding this early rather than retrofitting it. Caching
bolted onto an existing system under load tends to be caching bolted on
badly, under time pressure, into code that wasn’t written with it in mind.
Teams we work with on bespoke web application
builds get the most value when caching boundaries — what’s cacheable,
for how long, invalidated by what — are part of the architecture discussion
from the start, not a firefighting measure six months after launch.
FAQ
Do we need Redis, or can we start without a cache at all?
Most products don’t need an application-level cache on day one. Start with
correct database indexing and HTTP/CDN caching for static content; add Redis
or similar when you can point to specific slow, read-heavy, repeatable
queries that it would help.
What’s the biggest mistake teams make with caching?
Caching data that changes frequently or that users expect to see update
instantly — account balances, permissions, anything just edited by the user —
without a reliable invalidation path. The second biggest is caching
everything by default rather than the specific hot paths that need it.
How long should cached data live before it expires?
There’s no universal number — it depends on how stale the business can
tolerate that specific piece of data being. Set an explicit time-to-live for
every cached value based on that tolerance, and treat any active invalidation
logic as a bonus on top, not the only safeguard.
Can caching actually reduce our cloud costs?
Yes, often significantly, because every cache hit is a database query, API
call, or compute cycle you don’t pay for. The savings are real but depend on
targeting genuinely hot, expensive, read-heavy operations rather than caching
indiscriminately.
If you’re weighing up where caching fits into your architecture — or
want a second opinion before you build it in — book a free
consultation and we’ll talk through your specific system.