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}, withmaxmemoryset and an eviction policy ofallkeys-lru. - A webhook or event on catalog writes so the cache can be invalidated on change rather than relying on TTL alone.
redis-cli7+ for verification.- A p95-latency baseline for catalog read endpoints, captured before this change, so the improvement is provable.
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.
-
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 } -
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; } -
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}`); } -
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
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 |
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.