Configuring Redis Caching for the Catalog

Catalog reads dominate a developer portal’s traffic — every entity page, every dependency graph, every search result is a catalog query — so fronting them with Redis is the single highest-leverage backend performance change you can make. This how-to configures a Redis-backed application cache for catalog reads with a bounded TTL and write-through invalidation, so the portal stays fast without ever serving a stale owner. It implements the application-cache layer of Portal Performance & Caching within the broader Developer Portal Architecture & Frameworks strategy.

Prerequisites

  • Backstage 1.20+ (or a portal backend exposing a pluggable cache store) with the catalog backend serving traffic.
  • Redis 7+ reachable from the backend at ${REDIS_URL}, with maxmemory set and an eviction policy of allkeys-lru.
  • A webhook or event on catalog writes so the cache can be invalidated on change rather than relying on TTL alone.
  • redis-cli 7+ for verification.
  • A p95-latency baseline for catalog read endpoints, captured before this change, so the improvement is provable.
Prerequisites for a catalog read cache A pluggable cache store, a Redis instance with an eviction policy, a write event for invalidation, and a latency baseline. pluggable cache store Redis + LRU eviction write event latency baseline Cacheable reads fresh + fast
Without a write event to invalidate on, the cache can only be as fresh as its TTL — set the eviction policy so memory pressure never surprises you.

Exact Configuration

The pattern is cache-aside: a read returns the cached value on a hit, or loads from the database, stores it, and returns it on a miss — and a write deletes the key so the next read repopulates. The diagram traces both paths.

Cache-aside read and write-through invalidation A read hits the cache or falls to the database and stores the result; a write deletes the key. Read entityRef Cache hit? Redis Return cached Load DB + set jittered TTL Write → delete invalidate key
The write path deletes rather than updates the key, so the next read reloads the authoritative value from the database.
  1. Point the backend cache at Redis. The catalog uses the backend’s cache service; setting the store to Redis routes cached reads through it automatically.

    # app-config.yaml
    # Requires Backstage >= 1.20.0
    backend:
      cache:
        store: redis
        connection: ${REDIS_URL}
        defaultTtl: { minutes: 10 }
    
  2. Wrap catalog reads with a cache-aside helper. On a hit, return the cached value; on a miss, read the database, store the result under a stable key, and return it.

    // src/catalog/cachedRead.ts
    // Requires @backstage/backend-plugin-api >= 1.0.0
    import type { CacheService } from '@backstage/backend-plugin-api';
    
    export async function readEntityCached(
      cache: CacheService,
      entityRef: string,
      load: () => Promise<unknown>,
    ) {
      const key = `catalog:${entityRef}`;
      const hit = await cache.get(key);
      if (hit !== undefined) return hit;
      const value = await load();
      // Jitter the TTL to avoid synchronized expiry (stampede protection)
      const ttl = 600_000 + Math.floor(Number(process.env.CACHE_JITTER_MS ?? 0));
      await cache.set(key, value, { ttl });
      return value;
    }
    
  3. Invalidate on write. Register a handler on catalog mutations that deletes the affected key, so the next read repopulates from the database.

    // src/catalog/invalidate.ts
    // Requires @backstage/backend-plugin-api >= 1.0.0
    export async function onEntityChanged(cache: CacheService, entityRef: string) {
      await cache.delete(`catalog:${entityRef}`);
    }
    
  4. Bound Redis memory. Configure eviction so a memory spike degrades gracefully rather than crashing the instance.

    # Requires Redis >= 7
    redis-cli -u ${REDIS_URL} config set maxmemory 512mb
    redis-cli -u ${REDIS_URL} config set maxmemory-policy allkeys-lru
    

Validation

# Requires redis-cli >= 7
# 1. A repeated read populates then serves from cache
curl -s ${PORTAL_API_URL}/api/catalog/entities/by-name/component/default/my-service >/dev/null
redis-cli -u ${REDIS_URL} exists "catalog:component:default/my-service"
# Expected: 1

# 2. Hit ratio trends healthy under normal traffic
redis-cli -u ${REDIS_URL} info stats | grep -E "keyspace_hits|keyspace_misses"
# Expected: hits substantially exceed misses on a read-heavy portal

# 3. A write evicts the key
curl -s -X POST ${PORTAL_API_URL}/api/catalog/refresh \
  -H "Authorization: Bearer ${PORTAL_TOKEN}" \
  -d '{"entityRef":"component:default/my-service"}'
redis-cli -u ${REDIS_URL} exists "catalog:component:default/my-service"
# Expected: 0
Cache-aside validation sequence A repeated read populates the key, the hit ratio trends healthy, and a write evicts the key. Key populated exists = 1 Hit ratio healthy hits >> misses Write evicts exists = 0
The eviction check proves invalidation works end to end — the property that keeps the cache correct, not just fast.

Edge Cases & Troubleshooting

Symptom Root Cause Resolution
Portal shows a stale owner after a change Write did not invalidate the key Ensure the mutation handler calls cache.delete for the exact entityRef key
Latency spikes every ten minutes Synchronized TTL expiry causing a stampede Add jitter to the TTL and guard repopulation with a short lock
Redis memory climbs until errors No maxmemory or eviction policy Set maxmemory and allkeys-lru so old keys are evicted under pressure
Cache never hits Key derived from a varying field (timestamp, request id) Derive the key only from the stable entityRef
Portal breaks when Redis is down Cache treated as required, not optional Fall through to the database on any cache error so an outage degrades, not breaks
Redis cache pitfalls by class Stale owners and never-hitting keys are correctness issues; stampedes, memory growth, and hard dependency are resilience issues. Correctness stale owner never hits → invalidate + stable key Resilience stampede / memory hard dependency → jitter + fall through
Falling through to the database on any cache error is what keeps a Redis outage from becoming a portal outage.

Frequently Asked Questions

Should I cache whole entity responses or individual fields?

Cache whole responses keyed by entityRef. Field-level caching multiplies key management and invalidation complexity for little gain, since a portal almost always reads the full entity for a page. Keep the cache granularity aligned with how the data is consumed.

How do I invalidate related entities when one changes?

For most changes, evicting the single changed entityRef is enough because relations are resolved at read time. When a change alters a relation graph — a new dependsOn, a moved owner — evict the changed entity and any entity that directly references it, which you can derive from the relations the catalog already stores.