Terug naar blog
cachingperformanceredisarchitecturescaling

Caching-strategieën voor je SaaS: van trage queries naar razendsnelle responses

Door SaaS Masters16 maart 20267 min leestijd
Caching-strategieën voor je SaaS: van trage queries naar razendsnelle responses

Elke SaaS-applicatie bereikt op een gegeven moment het punt waarop de database het niet meer bijhoudt. Pagina's laden langzamer, API-responses worden trager en gebruikers beginnen te klagen. De oplossing? Een doordachte cachingstrategie.

In dit artikel duiken we diep in de verschillende cachinglagen die je kunt inzetten, wanneer je welke laag gebruikt en hoe je veelvoorkomende valkuilen vermijdt.

Waarom caching onmisbaar is voor SaaS

Bij een SaaS-applicatie bedien je tientallen tot duizenden tenants tegelijkertijd. Zonder caching betekent elk verzoek een volledige database-roundtrip — en dat schaalt niet. Een goed opgezette cachingstrategie kan:

  • Responstijden met 90%+ verlagen (van 200ms naar <20ms)
  • Databasebelasting drastisch verminderen (minder connections, minder CPU)
  • Kosten besparen (minder database-scaling nodig)
  • Beschikbaarheid verbeteren (cache kan dienen als fallback)

De vier cachinglagen

1. Browser- en CDN-cache (Edge Layer)

De snelste cache is die waar je server helemaal niets hoeft te doen. Met de juiste HTTP-headers laat je browsers en CDN's statische en semi-statische content cachen.

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

  return Response.json(data, {
    headers: {
      // Publiek cachebaar, 5 minuten vers, 1 uur stale-while-revalidate
      'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
      'CDN-Cache-Control': 'public, max-age=600',
    },
  });
}

Wanneer gebruiken:

  • Pricing-pagina's, feature-lijsten, blog content
  • Publieke API-endpoints die niet per-user zijn
  • Assets (afbeeldingen, CSS, JS)

Let op: Gebruik private in plaats van public voor tenant-specifieke data. Eén verkeerde header en je serveert data van Tenant A aan Tenant B.

2. Application-level cache (In-Memory)

Voor data die vaak gelezen wordt maar zelden verandert, is een in-memory cache de snelste optie na de browser.

// Simpele in-memory cache met 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 minuten
  return config;
}

Nadeel: Bij meerdere server-instances heeft elke instance een eigen cache. Dit leidt tot inconsistentie en hoger geheugengebruik. Voor horizontaal geschaalde applicaties heb je een gedeelde cache nodig.

3. Distributed cache (Redis)

Redis is de industriestandaard voor distributed caching in SaaS-applicaties. Het biedt een gedeelde cache die alle server-instances kunnen gebruiken.

import Redis from 'ioredis';

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

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

  // 2. Fetch van bron
  const data = await fetcher();

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

  return data;
}

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

Cache-invalidatie strategieën

Het beroemde citaat "Er zijn maar twee moeilijke dingen in de informatica: cache-invalidatie en dingen benoemen" is niet voor niets beroemd. Hier zijn drie bewezen strategieën:

1. Time-based expiry (TTL)

// Simpel maar effectief voor data die "uiteindelijk consistent" mag zijn
await redis.setex(`user:${userId}`, 300, JSON.stringify(user));

2. Event-based invalidatie

// Bij een update, invalideer de cache direct
async function updateTenantSettings(tenantId: string, settings: Settings) {
  await db.tenant.update({
    where: { id: tenantId },
    data: settings,
  });

  // Invalideer alle gerelateerde cache-keys
  const keys = await redis.keys(`tenant:${tenantId}:*`);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}

3. Versioned keys

// Gebruik een versienummer in de key — bij update verhoog je de versie
async function getTenanData(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}`);
  // Oude keys verlopen automatisch via TTL
}

4. Database query cache

Veel databases hebben ingebouwde query-caching, maar je kunt ook op applicatieniveau slim cachen met prepared statements en materialized views.

-- Materialized view voor dashboard-statistieken
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);

-- Ververs periodiek (bijv. elk uur via cron)
REFRESH MATERIALIZED VIEW CONCURRENTLY tenant_monthly_stats;

Multi-tenant caching: de valkuilen

Valkuil 1: Cache-key collisions

Fout:

// NOOIT DOEN — keys zonder tenant-prefix
cache.set('dashboard-stats', stats);

Goed:

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

Valkuil 2: Noisy neighbor in de cache

Eén grote tenant kan de cache vullen en andere tenants verdringen.

// Beperk cache-gebruik 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 oudste keys of weiger
      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);
  }
}

Valkuil 3: Cache stampede

Wanneer een populaire cache-key verloopt, sturen alle gelijktijdige requests dezelfde dure query. Gebruik een lock-mechanisme:

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);

  // Probeer een lock te krijgen
  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);
    }
  }

  // Wacht kort en probeer opnieuw uit cache
  await new Promise(r => setTimeout(r, 100));
  const retried = await redis.get(key);
  if (retried) return JSON.parse(retried);

  // Fallback: haal direct op
  return fetcher();
}

Caching-checklist voor productie

Voordat je naar productie gaat, loop deze checklist af:

  • Alle cache-keys bevatten tenant-id (geen cross-tenant data leaks)
  • TTL's zijn ingesteld op alle keys (geen onbeperkte groei)
  • Cache-invalidatie is getest (update → cache is weg → verse data)
  • Redis heeft een maxmemory-policy (bijv. allkeys-lru)
  • Monitoring is actief (cache hit rate, geheugengebruik, evictions)
  • Fallback werkt (als Redis down is, werkt de app nog — alleen trager)
  • Serialisatie is veilig (geen circular references, geen gevoelige data onversleuteld)

Monitoring: weet of je cache werkt

// Simpele 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 periodiek
setInterval(() => {
  console.log(`Cache hit rate: ${cacheMetrics.hitRate.toFixed(1)}%`);
  console.log(`Hits: ${cacheMetrics.hits}, Misses: ${cacheMetrics.misses}`);
}, 60000);

Streef naar een hit rate van 85%+ voor hot paths. Onder de 70%? Dan is je TTL te kort of je cache-strategie verkeerd.

Conclusie

Caching is geen afterthought — het is een kernonderdeel van je SaaS-architectuur. Begin met de simpelste laag (HTTP-headers en CDN), voeg Redis toe wanneer je meerdere instances draait, en bouw altijd met multi-tenancy in gedachten.

De belangrijkste les: cache-invalidatie is moeilijker dan caching zelf. Kies een strategie (TTL, event-based, of versioned keys) en wees daar consistent in. En test altijd of je cache correct invalideert — een stale cache is erger dan geen cache.

Begin klein, meet alles, en schaal op basis van data. Dat is de SaaS-manier.