SaaS Caching Strategy: What to Cache and Where in 2026

A practical SaaS caching strategy for indie founders: the five cache layers, what belongs in each, how invalidation works, and what breaks when it goes wrong.

· Justin Boggs

Close-up of glowing server cooling fans inside a data center

Photo by Winston Chen on Unsplash

A SaaS caching strategy is a set of decisions about which data to store closer to the user, at which layer, and how to throw it away when it changes. It is not one setting you turn on. It is five separate layers — the browser, the CDN, the framework data cache, an application cache like Redis, and the database — each with its own speed, its own invalidation model, and its own way of biting you when it goes stale. For a non-technical founder, the goal is not to cache everything; it is to cache the handful of read paths that are slow and rarely change, leave everything else alone, and understand what breaks when a cache serves old data. This post walks through each layer, what belongs in it, and where the traps are.

TL;DR

  • Caching has five layers — browser, CDN, framework data cache, application cache (Redis), and database. Each is faster but staler than the one behind it.
  • Cache the read paths that are slow and change infrequently. Never cache user-specific or must-be-fresh data at a shared layer — that is how one customer sees another customer's data.
  • The hard part is not storing data, it is invalidation: knowing when cached data is wrong and getting rid of it. Next.js gives you time-based, tag-based, and path-based revalidation for exactly this.
  • Start with the free layers you already have — Next.js static rendering and the Vercel Data Cache cost you nothing extra. Only reach for Redis when a specific query is measurably slow.
  • Every cache key in a multi-tenant SaaS must be namespaced by tenant, or you will leak data across customers.

What caching actually is, in plain terms

A cache is a copy of data kept somewhere faster to reach than the original. When your app needs a piece of information, it checks the fast copy first. If the copy is there and still good, it uses it and skips the slow work — a database query, an external API call, rendering a page from scratch. That is the entire idea, and everything else is detail about where the copy lives and when you throw it away.

The reason caching matters for an indie SaaS is money and speed at the same time. A database query that takes 200 milliseconds and runs on every page load costs you compute and makes the page feel sluggish. Serve that same result from a cache and it comes back in single-digit milliseconds, your database does less work, and your bill for the database and the serverless functions goes down. Caching is one of the few optimizations that improves the user experience and cuts costs in the same move — which is why it shows up in every conversation about the hidden costs of SaaS infrastructure.

But a cache is a copy, and copies go stale. The moment the original data changes, every cached copy is wrong until something updates or deletes it. This is the central tension of caching, and it is worth saying plainly: the hard problem is never storing the data, it is knowing when the stored copy has become a lie. A pricing page that caches for an hour is fine. A user's account balance that caches for an hour is a support ticket. The skill is telling those two cases apart.

There is an old joke among engineers that there are only two hard problems in computer science: cache invalidation and naming things. It is a joke because it is true. Storing a copy is trivial. Deciding the exact moment that copy is no longer trustworthy, and reliably getting rid of it everywhere at once, is genuinely hard — and it is where founders who bolt caching on carelessly end up with bugs that only appear in production and only for some users.

The five layers, from closest to the user to furthest

Caching is not a single place. A request for a page passes through a stack of possible caches, each closer to the user and faster than the one behind it. Here is the path a request travels and where a copy might be waiting at each stop.

flowchart LR
  U[User's browser] -->|1. browser cache| CDN[CDN edge]
  CDN -->|2. CDN cache| APP[Your app on Vercel]
  APP -->|3. framework data cache| REDIS[Application cache<br/>Redis]
  REDIS -->|4. cache-aside| DB[(Database)]
  DB -->|5. query cache| DB

The browser cache lives on the user's own machine. When your app sends a Cache-Control header, the browser stores the response and reuses it without hitting the network at all. This is where static assets belong — the JavaScript, CSS, images, and fonts that make up your app's shell. Next.js handles this automatically: files served from /_next/static/ get a public, max-age=31536000, immutable header, meaning the browser keeps them for a year, because their filenames include a content hash that changes whenever the file changes. You get this for free.

The CDN cache lives at the edge — servers physically close to your users around the world. It stores full HTTP responses so a page can be served from a city near the user instead of from your origin. Next.js sets Cache-Control headers based on how each route renders: a fully static page gets s-maxage=31536000 (cache for a year), an ISR page gets s-maxage={revalidate} plus stale-while-revalidate, and a dynamic page gets private, no-cache, no-store so it is never cached. Your marketing pages and blog live here; your logged-in dashboard does not.

The framework data cache sits inside your app. On Vercel, the Data Cache stores the results of individual fetch calls and database queries so that two requests needing the same data do not both hit the origin. The application cache — usually Redis — is a shared, in-memory store you reach for when a specific query is expensive and you want explicit control. And the database itself keeps its own query cache and buffer pool. Each layer is faster than the next but holds a staler copy, and the art is putting each piece of data at the shallowest layer that is still safe.

Which layer for which data

The decision that matters is matching each kind of data to the right layer. Cache too shallow and you leak stale or private data; cache too deep and you leave speed and money on the table. Here is the mapping I use as a default.

| Data | Right layer | Why | Invalidation | | --- | --- | --- | --- | | JS, CSS, fonts, images | Browser + CDN | Never changes without a new filename | Content hash in filename | | Marketing pages, blog, docs | CDN (static / ISR) | Same for everyone, changes rarely | Time-based or on-demand rebuild | | Public API responses, shared config | Framework data cache | Same across users, changes occasionally | Tags + revalidateTag | | Expensive computed reads (dashboards, reports) | Application cache (Redis) | Slow to compute, tolerate seconds of staleness | Manual delete on write | | User-specific data, balances, permissions | Do not cache at a shared layer | Must be correct and private | N/A — read live | | Raw row lookups by primary key | Database query cache | The database already does this well | Handled by the DB |

The single most important row in that table is the one that says do not cache. User-specific data — account balances, permissions, anything that differs per customer and must be correct — should be read live from the database on every request, or cached only in a way that is strictly scoped to that one user. The Vercel Data Cache documentation is explicit that it is not a good fit for user-specific data that differs for each request or data that must be fresh on every request. Getting this wrong is not a performance bug, it is a data-leak bug, and in a multi-tenant SaaS it is the worst kind.

That leads to the rule that protects you: every cache key in a multi-tenant app must be namespaced by tenant. If your cache key is dashboard-summary, two different customers can collide on it and one sees the other's numbers. If the key is tenant:{tenantId}:dashboard-summary, they never can. This is the caching equivalent of row-level security in the database — isolation is not optional, it is the default you build in from the first cache you add. Add a schema version to the key too (v2:tenant:...) so a shape change to your cached objects does not serve a new code path an old, incompatible copy.

How invalidation works in Next.js

Storing the copy is the easy 20 percent. The other 80 percent is invalidation, and this is where a modern framework earns its keep. Next.js gives you three ways to decide a cached copy is stale, and they cover almost every case a solo founder hits.

Time-based revalidation says "this copy is good for N seconds." You set it on a fetch — fetch(url, { next: { revalidate: 3600 } }) — or on a whole route, and after that window the next request triggers a refresh in the background while still serving the stale copy instantly. This is the right default for data that changes on a loose schedule: a pricing page, a public stats endpoint, a list of blog posts. On Vercel you can revalidate a single fetch at most every 60 seconds, which is plenty for anything that is not real-time.

Tag-based revalidation is the precise one. You attach a tag to cached data and later invalidate everything with that tag in a single call. For non-fetch work like a database query, you wrap it with unstable_cache and give it tags:

import { unstable_cache } from 'next/cache'

export const getCachedUser = unstable_cache(
  async (id: string) => db.select().from(users).where(eq(users.id, id)).then(r => r[0]),
  ['user'],
  { tags: ['user'], revalidate: 3600 }
)

Then, the moment that user changes, you call revalidateTag('user') inside the server action that made the change, and the cache is invalidated immediately rather than waiting for a timer. Path-based revalidation with revalidatePath('/profile') does the same thing for a whole route. The pattern that keeps caches honest is: cache on read, invalidate on write. Every server action that mutates data ends by invalidating the tags or paths that just became stale.

One trap worth knowing: the CDN and the framework data cache are different layers, and invalidating one does not invalidate the other. Next.js is direct about this — revalidateTag() and revalidatePath() invalidate the Next.js server cache, but a CDN keeps serving its own cached copy until the s-maxage TTL expires. If you need an on-demand change to appear at the edge instantly, you have to purge the CDN too, not just revalidate the server cache. For most indie SaaS pages this does not matter; for a price change you want live everywhere in the same second, it does.

When to reach for Redis (and when not to)

The application cache — Redis — is the layer founders reach for too early. The honest guidance is: do not add Redis until you have a specific, measured slow query that the framework caches cannot help with. The Vercel Data Cache and Next.js static rendering are free, require no new service, and cover the majority of read paths. Redis is worth its operational weight only when you have expensive computed reads that many users request and that tolerate a few seconds of staleness — a leaderboard, an analytics rollup, an aggregate that takes real database work to produce.

When you do reach for it, the standard pattern is cache-aside, also called lazy loading: your app checks Redis first, and on a miss it queries the database, writes the result back to Redis with an expiry, and returns it. The next request finds it in Redis and skips the database entirely. Upstash, the serverless-oriented Redis provider, documents cache-aside for database query results as a primary use case, alongside session storage, rate limiting, and distributed locks. For a serverless SaaS on Vercel, an HTTP-based Redis like Upstash matters specifically because it sidesteps the connection-pool problems that a traditional TCP Redis hits when hundreds of short-lived serverless functions all try to connect at once.

Two failure modes are worth designing against up front. The first is the thundering herd: a popular cache key expires, and in the same instant a hundred requests all miss, all hit the database, and all try to recompute the same value. The fix is a short lock or a "single-flight" approach so only the first request recomputes while the others wait for it. The second is the stampede on cold start after a deploy or a full cache purge — the Vercel docs warn that purging your data cache creates a temporary increase in request times as data is refetched. Warm critical keys deliberately rather than purging everything at peak traffic.

Redis also has hard limits you inherit from whichever cache you use. The Vercel Data Cache, for example, caps individual items at 2 MB and evicts least-recently-used entries when it fills up. Cache small, hot values — not giant blobs — and let the LRU policy do its job. If you are still choosing where your app runs and how these caches behave per plan, that decision overlaps with choosing a host, because the free-tier cache limits differ across platforms.

A caching plan you can ship this week

You do not need all five layers on day one. Here is the order I would add caching to a young SaaS, cheapest and safest first.

Start by letting the framework do its job. Render your marketing pages, blog, and docs statically or with ISR so they cache at the CDN automatically — this is the Next.js + Supabase + Stripe + Resend stack working as designed, and it costs nothing. Confirm your static assets carry the year-long immutable header (Next.js does this for you) and that your logged-in routes are correctly marked dynamic so no private data ever lands in a shared cache.

Next, cache the specific server-side reads that are shared across users and change on a schedule — a public stats endpoint, a shared configuration object — with unstable_cache and tags, and invalidate those tags in the server actions that change the underlying data. This is the highest-leverage step because it removes repeated database work without any new service to run.

Only then, if a dashboard or report is measurably slow and hammered, add Redis with cache-aside for that one path — namespaced by tenant, with a sane expiry and a lock against the thundering herd. Measure before and after. Most indie SaaS apps never need more than the first two steps, and reaching for Redis before you have a proven-slow query is optimizing a bill you do not yet have.

Frequently asked questions

What is the difference between the CDN cache and the data cache?

The CDN cache stores complete HTTP responses — the full rendered page or file — at edge locations near your users. The framework data cache stores the results of individual data fetches and queries inside your app. Vercel is explicit that they are separate layers: purging or invalidating one does not touch the other, so an on-demand change may need both a revalidateTag call and a CDN purge to appear everywhere instantly.

Should I cache logged-in dashboard pages?

Not at a shared layer. Dashboard data is user-specific and often must be correct, so it should be read live or cached only in a way strictly scoped to that one user. Caching per-user data at a CDN or shared cache risks one customer seeing another's data, which is a security bug, not a performance win. Mark those routes dynamic and cache only the expensive, shared computations behind them.

Do I need Redis for an indie SaaS?

Usually not at first. Next.js static rendering and the Vercel Data Cache are free and cover most read paths. Add Redis only when you have a specific, measured slow query — typically an expensive aggregate or dashboard read that many users request — that the framework caches cannot handle. Reaching for Redis before you have a proven bottleneck adds an operational dependency you do not yet need.

How do I stop caches from leaking data between customers?

Namespace every cache key by tenant, so a key looks like tenant:{id}:resource rather than a global resource. This guarantees two customers never collide on the same key. Add a schema version to the key too, so a change to your cached object's shape does not serve incompatible old data to new code. This is the caching equivalent of row-level security in the database.

What is cache invalidation and why is it hard?

Cache invalidation is deciding the exact moment a cached copy is no longer correct and removing it. It is hard because the original data can change at any time, from many places, and every stored copy across every layer becomes wrong the instant it does. Storing a copy is trivial; reliably getting rid of every stale copy at the right moment, across browser, CDN, and server caches, is the genuinely difficult part.

What is the stale-while-revalidate pattern?

Stale-while-revalidate serves the existing cached copy instantly while refreshing it in the background, so users never wait for a slow origin fetch and the cache stays reasonably current. Next.js includes it in the Cache-Control header for ISR pages by default. The trade-off is that a user may briefly see slightly old data during the refresh window, which is acceptable for most content but not for anything that must be exactly current.

The bottom line

Caching is not a single switch you flip; it is a set of small, deliberate decisions about which data is slow, which data is safe to serve slightly stale, and which data must never leave the database live. The founders who get burned are the ones who cache everything and discover invalidation the hard way — in production, for some users, at the worst time. The founders who benefit start with the free layers they already have, cache the two or three read paths that are genuinely slow and shared, namespace every key by tenant, and reach for Redis only when a real bottleneck earns it.

If you are building a SaaS with AI coding tools and want the caching, tenant isolation, and revalidation patterns already wired into a working Next.js app, Coding Capybaras is the free boilerplate I built for exactly this workflow — and the marketplace has copy-paste prompts for the specific integrations, like a serverless Redis cache, referenced above.