Skip to content

Caching Strategies for a SaaS

Aman Kumar Singh8 min read
Part 37 of 40From the Full Stack SaaS Masterclass series
Caching Strategies for a SaaS — article by Aman Kumar Singh

In Disaster Recovery and Backups, we made peace with the database disappearing entirely: accept it can happen, and make recovery boring and rehearsed. Caching starts from the opposite end of that problem. It exists so most requests never reach the database at all, turning the failure mode from "the request was slow" into "the request served data that's a few seconds old," an easier failure to live with as long as you're deliberate about which requests get that treatment.

This is part of the Full Stack SaaS Masterclass, still in Module 4: the production concerns a demo never surfaces, the ones that decide whether a SaaS survives its first real traffic spike. Caching is one of the highest-impact additions here. It's also one of the easiest to get wrong in ways that only surface under load, across tenants, or during an incident.

The temptation is to reach for caching everywhere, on the theory that faster is always better. I'd rather walk through where it actually pays for itself, the pattern that covers most SaaS workloads without much ceremony, and the specific ways multi-tenant caching bites teams that treat it as a generic performance trick instead of a piece of production architecture.

Where caching earns its place

A typical SaaS dashboard is read-heavy in a particular way: a handful of expensive aggregate queries (usage stats, billing summaries, activity feeds) get requested by the same organization dozens of times an hour, while the underlying data changes far less often than it's read. That gap between read frequency and write frequency is exactly where caching pays off. Caching a value that changes on every read buys you almost nothing and adds a real chance of serving something wrong.

The other honest reason to cache is protecting the database's connection pool. A query that's fine at ten requests a second can start queuing connections at a hundred. The failure isn't graceful. Timeouts cascade across unrelated endpoints that happen to share the same pool. Caching the expensive, frequently-hit queries takes pressure off the pool without needing a bigger database instance, usually the cheaper fix by a wide margin.

I'd resist caching defensively, before you've seen a slow endpoint or a connection pool warning. Every cache is a second source of truth that can drift from the first, and that drift is the whole cost of caching. Add it where a specific query is genuinely hot, not as a blanket policy applied to every read path in the app.

Choosing where the cache lives

For a public marketing page or a static asset, browser and CDN caching (Cache-Control, ETags, a CDN in front of the origin) is the right tool, and it's mostly free once configured. It doesn't apply to most SaaS API responses, though, because those responses are per-organization and often per-user, which defeats a shared CDN cache almost entirely.

For authenticated, tenant-scoped data, the cache needs to live somewhere every API instance can share. An in-process cache (a plain Map in a Node service) looks tempting because it's simple to write. It breaks down once you run more than one instance behind a load balancer: each instance builds its own copy, they drift out of sync, and invalidating a key means finding every instance and clearing it locally, which usually means nobody bothers. Redis solves this by being one shared cache every instance reads and writes, so invalidation and TTLs behave consistently no matter how many API pods are running.

Cache-aside as the default pattern

Of the common caching patterns, cache-aside covers most SaaS read paths without much complexity. The application checks the cache first, falls through to the database on a miss, and populates the cache before returning the result. Write-through and write-behind exist for workloads where writes are frequent and need the cache kept in lockstep, but most SaaS data (organization settings, dashboard aggregates, plan limits) is written rarely relative to how often it's read, so cache-aside's occasional post-write invalidation is a fine tradeoff for the simplicity it buys.

// cache.service.ts
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class CacheService {
  constructor(private readonly redis: Redis) {}

  async getOrSet<T>(
    key: string,
    ttlSeconds: number,
    loader: () => Promise<T>,
  ): Promise<T> {
    const cached = await this.redis.get(key);
    if (cached) {
      return JSON.parse(cached) as T;
    }

    const value = await loader();
    await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
    return value;
  }
}

A service that uses it stays simple, since the caching decision is isolated from the query logic entirely:

// dashboard.service.ts
import { Injectable } from '@nestjs/common';
import { CacheService } from './cache.service';
import { OrganizationStatsRepository } from './organization-stats.repository';

const STATS_TTL_SECONDS = 60;

@Injectable()
export class DashboardService {
  constructor(
    private readonly cache: CacheService,
    private readonly statsRepo: OrganizationStatsRepository,
  ) {}

  async getStats(organizationId: string) {
    const key = `org:${organizationId}:dashboard:stats:v1`;
    return this.cache.getOrSet(key, STATS_TTL_SECONDS, () =>
      this.statsRepo.computeExpensiveStats(organizationId),
    );
  }
}

The TTL here is doing the actual product decision: sixty seconds of staleness on a dashboard is usually invisible to a user, and it bounds how wrong the cached value can ever be without any explicit invalidation logic at all. That's often enough on its own, and it's worth trying before reaching for event-based invalidation.

Multi-tenant keys and invalidation

The organizationId in that cache key is the whole safety mechanism. A cache key that isn't scoped to a tenant is a data leak waiting to happen: the first organization to request an endpoint populates the cache, and every other organization hitting the same unscoped key gets served that data back. This is easy to miss in review because it looks like a performance detail, not an authorization boundary. Every cache key touching tenant data should include the organization or user ID, the same way every query does.

The v1 suffix earns its keep when the shape of cached data changes. Rather than hunting down and deleting every existing key matching an old shape, which usually means an expensive SCAN across a production Redis instance, bump the version segment and old keys simply age out on their existing TTL while new requests populate fresh ones under the new key.

For invalidation triggered by an actual write, delete the specific key rather than waiting out the TTL, since a user who just changed a setting expects to see it reflected immediately:

async updateOrganizationName(organizationId: string, name: string) {
  await this.orgRepo.update(organizationId, { name });
  await this.redis.del(`org:${organizationId}:dashboard:stats:v1`);
}

This only scales if the set of keys touched by a given write is small and known ahead of time. Once a single mutation needs to invalidate a dozen keys across features, that's usually a sign the data should be cached at a coarser grain, or that the TTL alone is doing enough work and explicit invalidation isn't worth the coupling it adds between unrelated services.

Stampede protection and eviction pitfalls

A cache with a TTL eventually expires, and if that key is hot enough, dozens of requests can arrive in the same window right after expiry, each finding a miss and each hitting the database at once. This is a cache stampede, and it turns your caching layer into something that occasionally sends a burst of load at the database instead of protecting it, right at the moment a popular value goes stale. A short lock around the rebuild step keeps that burst down to a single query:

async getOrSetWithLock<T>(
  key: string,
  ttlSeconds: number,
  loader: () => Promise<T>,
): Promise<T> {
  const cached = await this.redis.get(key);
  if (cached) {
    return JSON.parse(cached) as T;
  }

  const lockKey = `lock:${key}`;
  const acquired = await this.redis.set(lockKey, '1', 'NX', 'EX', 10);

  if (!acquired) {
    // Someone else is already rebuilding this key. Wait briefly and check
    // the cache again instead of piling another query onto the database.
    await new Promise((resolve) => setTimeout(resolve, 100));
    return this.getOrSetWithLock(key, ttlSeconds, loader);
  }

  try {
    const value = await loader();
    await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
    return value;
  } finally {
    await this.redis.del(lockKey);
  }
}

Only the request that acquires the lock queries the database; everyone else waits briefly and reads the value it just populated. It's a small amount of extra code for a failure mode that's genuinely painful to diagnose in production, since it usually shows up only as periodic database load spikes correlated with a TTL set weeks earlier.

Eviction policy is the other pitfall worth checking explicitly. A Redis instance used purely as a cache should run maxmemory-policy allkeys-lru (or allkeys-lfu), so under memory pressure it evicts the least useful values rather than refusing writes. The default noeviction policy suits Redis as a primary store. Left on a cache instance, a full cache starts throwing errors on every write instead of degrading gracefully. This matters more if the same instance holds sessions or job queues too, since the wrong eviction policy can crowd out data that was never meant to be evictable. Keep caching on its own logical database or instance, separate from anything that needs to persist.

Finally, watch hit ratio, not just latency. A low hit ratio usually means invalidation firing too aggressively, keys scoped too specifically for their own access pattern, or a TTL too short to ever pay off, none of which show up as an obvious bug. They show up as a cache that's technically working and practically useless.

Key takeaways

  • Cache queries that are read far more often than the underlying data changes; caching something that mutates on every read adds risk without real benefit.
  • Use Redis, not an in-process cache, for anything shared across API instances; a per-instance cache drifts and can't be invalidated consistently.
  • Cache-aside with a sensible TTL covers most SaaS read paths; reach for write-through only when staleness is actually a problem, not by default.
  • Every cache key touching tenant data must include the organization or user ID; an unscoped key is a data leak, not just a performance detail.
  • Protect hot keys from stampedes with a short rebuild lock, and set `allkeys-lru` on any Redis instance used purely as a cache.
  • Watch hit ratio alongside latency; a low ratio means the caching strategy needs revisiting, not just the infrastructure behind it.

Frequently asked questions

What's the difference between cache-aside and write-through caching?

Cache-aside populates the cache on a read miss and lets writes go straight to the database, with the cache invalidated or left to expire. Write-through updates cache and database together on every write. Cache-aside is simpler and fits most SaaS workloads where reads vastly outnumber writes; write-through earns its complexity only when the cache must stay in lockstep on every mutation.

How long should a cache TTL be for SaaS dashboard data?

It depends on how stale a user can tolerate the data being, not a fixed rule. Thirty to ninety seconds is a reasonable start for aggregate stats; pair it with explicit invalidation on writes where users expect an instant update, like renaming a resource they just edited.

Can caching cause data leaks between tenants?

Yes, if a cache key isn't scoped by organization or user ID. The first tenant to populate an unscoped key ends up serving their data to every other tenant hitting the same key afterward. Treat cache key scoping with the same seriousness as query scoping, since it enforces the same tenant boundary.

What is a cache stampede and how do I prevent it?

A stampede happens when a popular cache key expires and many concurrent requests all miss at once, each querying the database simultaneously. A short-lived rebuild lock, where only the first request that misses actually queries the database and the rest wait for it to finish, keeps that burst down to a single query.

Should I use the same Redis instance for caching and sessions or queues?

You can, but keep them on separate logical databases or instances if you can afford to. A cache should run `allkeys-lru` so it degrades gracefully under memory pressure; sessions and queues usually need `noeviction` since losing them isn't acceptable. Mixing the two under one policy is wrong for at least one of them.

How do I know if my caching strategy is actually working?

Track hit ratio alongside request latency. A high hit ratio with lower latency on the cached path means the strategy is paying off; a low hit ratio usually points to a TTL that's too short, keys too specific to ever be reused, or invalidation firing more often than the underlying data actually changes.

Related articles

Scaling a SaaS Beyond One Server — Aman Kumar Singh
Feature Flags and Safe Rollouts — Aman Kumar Singh
The Outbox Pattern — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.