Back to blog
cachingperformanceredisarchitecturescaling

Caching Strategies for Your SaaS: From Slow Queries to Lightning-Fast Responses

By SaaS Masters16 maart 20267 min read
Caching Strategies for Your SaaS: From Slow Queries to Lightning-Fast Responses

Every SaaS application eventually hits the point where the database can't keep up. Pages load slower, API responses lag, and users start complaining. The solution? A well-thought-out caching strategy.

In this article, we'll dive deep into the different caching layers you can deploy, when to use each one, and how to avoid common pitfalls.

Why caching is essential for SaaS

In a SaaS application, you're serving tens to thousands of tenants simultaneously. Without caching, every request means a full database roundtrip — and that doesn't scale. A well-designed caching strategy can:

  • Reduce response times by 90%+ (from 200ms to <20ms)
  • Dramatically lower database load (fewer connections, less CPU)
  • Save costs (less database scaling needed)
  • Improve availability (cache can serve as a fallback)

The four caching layers

1. Browser and CDN cache (Edge Layer)

The fastest cache is the one where your server doesn't have to do anything. With the right HTTP headers, you let browsers and CDNs cache static and semi-static content.

// Next.js API route with cache headers
export async function GET(request: Request) {
  const data = await fetchPricingPlans();

  return Response.json(data, {
    headers: {
      // Publicly cacheable, 5 minutes fresh, 1 hour stale-while-revalidate
      'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
      'CDN-Cache-Control': 'public, max-age=600',
    },
  });
}

When to use:

  • Pricing pages, feature lists, blog content
  • Public API endpoints that aren't per-user
  • Assets (images, CSS, JS)

Watch out: Use private instead of public for tenant-specific data. One wrong header and you're serving Tenant A's data to Tenant B.

2. Application-level cache (In-Memory)

For data that's read often but rarely changes, an in-memory cache is the fastest option after the browser.

// Simple in-memory cache with TTL
class MemoryCache {
  private cache = new Map<string, { data: any; expiresAt: number }>();

  get<T>(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;
    if (Date.now() > entry.expiresAt) {
      this.cache.delete(key);
      return null;
    }
    return entry.data as T;
  }

  set(key: string, data: any, ttlSeconds: number): void {
    this.cache.set(key, {
      data,
      expiresAt: Date.now() + ttlSeconds * 1000,
    });
  }
}

const cache = new MemoryCache();

async function getTenantConfig(tenantId: string) {
  const cacheKey = `tenant-config:${tenantId}`;
  const cached = cache.get(cacheKey);
  if (cached) return cached;

  const config = await db.tenantConfig.findUnique({
    where: { tenantId },
  });

  cache.set(cacheKey, config, 300); // 5 minutes
  return config;
}

Downside: With multiple server instances, each has its own cache. This leads to inconsistency and higher memory usage. For horizontally scaled applications, you need a shared cache.

3. Distributed cache (Redis)

Redis is the industry standard for distributed caching in SaaS applications. It provides a shared cache that all server instances can use.

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

// Cache-aside pattern with Redis
async function getCachedData<T>(
  key: string,
  ttlSeconds: number,
  fetcher: () => Promise<T>
): Promise<T> {
  // 1. Try cache
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached) as T;
  }

  // 2. Fetch from source
  const data = await fetcher();

  // 3. Store in cache (non-blocking)
  redis.setex(key, ttlSeconds, JSON.stringify(data)).catch(console.error);

  return data;
}

// Usage
const dashboardStats = await getCachedData(
  `dashboard:${tenantId}:${period}`,
  60, // 1 minute cache
  () => calculateDashboardStats(tenantId, period)
);

Cache invalidation strategies

The famous quote "There are only two hard things in computer science: cache invalidation and naming things" exists for a reason. Here are three proven strategies:

1. Time-based expiry (TTL)

// Simple but effective for data that can be "eventually consistent"
await redis.setex(`user:${userId}`, 300, JSON.stringify(user));

2. Event-based invalidation

// On update, invalidate the cache immediately
async function updateTenantSettings(tenantId: string, settings: Settings) {
  await db.tenant.update({
    where: { id: tenantId },
    data: settings,
  });

  // Invalidate all related cache keys
  const keys = await redis.keys(`tenant:${tenantId}:*`);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}

3. Versioned keys

// Use a version number in the key — increment on update
async function getTenantData(tenantId: string) {
  const version = await redis.get(`tenant-version:${tenantId}`) || '1';
  const cacheKey = `tenant:${tenantId}:v${version}`;

  return getCachedData(cacheKey, 3600, () => fetchTenantData(tenantId));
}

async function invalidateTenantCache(tenantId: string) {
  await redis.incr(`tenant-version:${tenantId}`);
  // Old keys expire automatically via TTL
}

4. Database query cache

Many databases have built-in query caching, but you can also cache smartly at the application level with prepared statements and materialized views.

-- Materialized view for dashboard statistics
CREATE MATERIALIZED VIEW tenant_monthly_stats AS
SELECT
  tenant_id,
  date_trunc('month', created_at) AS month,
  COUNT(*) AS total_orders,
  SUM(amount) AS total_revenue,
  COUNT(DISTINCT user_id) AS active_users
FROM orders
GROUP BY tenant_id, date_trunc('month', created_at);

-- Refresh periodically (e.g., every hour via cron)
REFRESH MATERIALIZED VIEW CONCURRENTLY tenant_monthly_stats;

Multi-tenant caching: the pitfalls

Pitfall 1: Cache key collisions

Wrong:

// NEVER DO THIS — keys without tenant prefix
cache.set('dashboard-stats', stats);

Correct:

// ALWAYS include tenant-id in the key
cache.set(`t:${tenantId}:dashboard-stats`, stats);

Pitfall 2: Noisy neighbor in the cache

One large tenant can fill the cache and evict other tenants' data.

// Limit cache usage per tenant
class TenantAwareCache {
  private readonly maxKeysPerTenant = 1000;

  async set(tenantId: string, key: string, data: any, ttl: number) {
    const tenantKey = `t:${tenantId}:${key}`;
    const countKey = `t:${tenantId}:_count`;

    const count = await redis.get(countKey);
    if (Number(count) >= this.maxKeysPerTenant) {
      // Evict oldest keys or reject
      console.warn(`Cache limit reached for tenant ${tenantId}`);
      return;
    }

    await redis.setex(tenantKey, ttl, JSON.stringify(data));
    await redis.incr(countKey);
    await redis.expire(countKey, ttl);
  }
}

Pitfall 3: Cache stampede

When a popular cache key expires, all concurrent requests fire the same expensive query. Use a lock mechanism:

async function getCachedWithLock<T>(
  key: string,
  ttl: number,
  fetcher: () => Promise<T>
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

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

  if (acquired) {
    try {
      const data = await fetcher();
      await redis.setex(key, ttl, JSON.stringify(data));
      return data;
    } finally {
      await redis.del(lockKey);
    }
  }

  // Wait briefly and retry from cache
  await new Promise(r => setTimeout(r, 100));
  const retried = await redis.get(key);
  if (retried) return JSON.parse(retried);

  // Fallback: fetch directly
  return fetcher();
}

Production caching checklist

Before going to production, run through this checklist:

  • All cache keys include tenant-id (no cross-tenant data leaks)
  • TTLs are set on all keys (no unbounded growth)
  • Cache invalidation is tested (update → cache cleared → fresh data)
  • Redis has a maxmemory-policy (e.g., allkeys-lru)
  • Monitoring is active (cache hit rate, memory usage, evictions)
  • Fallback works (if Redis is down, the app still works — just slower)
  • Serialization is safe (no circular references, no sensitive data unencrypted)

Monitoring: know if your cache is working

// Simple cache metrics
const cacheMetrics = {
  hits: 0,
  misses: 0,

  get hitRate() {
    const total = this.hits + this.misses;
    return total === 0 ? 0 : (this.hits / total) * 100;
  },

  record(hit: boolean) {
    if (hit) this.hits++;
    else this.misses++;
  }
};

// Log periodically
setInterval(() => {
  console.log(`Cache hit rate: ${cacheMetrics.hitRate.toFixed(1)}%`);
  console.log(`Hits: ${cacheMetrics.hits}, Misses: ${cacheMetrics.misses}`);
}, 60000);

Aim for a hit rate of 85%+ on hot paths. Below 70%? Your TTL is too short or your caching strategy is wrong.

Conclusion

Caching isn't an afterthought — it's a core part of your SaaS architecture. Start with the simplest layer (HTTP headers and CDN), add Redis when you're running multiple instances, and always build with multi-tenancy in mind.

The most important lesson: cache invalidation is harder than caching itself. Choose a strategy (TTL, event-based, or versioned keys) and be consistent. And always test that your cache invalidates correctly — a stale cache is worse than no cache.

Start small, measure everything, and scale based on data. That's the SaaS way.