staff-engineering-skills-cache-invalidation

작성자: triggerdotdev

캐시 무효화 누락 또는 불완전으로 인한 오래된 데이터 버그를 방지합니다. 캐싱 계층(Redis, 인메모리 Map, CDN, HTTP 캐시 헤더)을 추가할 때 사용합니다…

npx skills add https://github.com/triggerdotdev/staff-engineering-skills --skill staff-engineering-skills-cache-invalidation

Cache Invalidation Trap

You added a cache for performance. Now users see stale data. Before caching anything, ask: when the source data changes, how does every copy get updated or discarded?

The Fundamental Problem

A cache is a copy, and copies diverge from the source. Every cache creates a consistency obligation: when the source changes, you must update or discard the copy. If you can't answer "how does this cache learn that the source changed?", you don't have a caching strategy -- you have a stale data bug with a variable delay.

The Cache Hierarchy

Data passes through multiple cache layers; invalidating one doesn't invalidate the others. Invalidate the application cache but not the CDN (or the CDN but not the browser), and the stale layer keeps serving old data.

LayerControlInvalidationRisk
Browser cacheLimited (Cache-Control headers)Can't force purge from serverStale until user refreshes
CDNPurge API, surrogate keysPropagation delay (seconds)Millions served stale data
Reverse proxyFull controlDirect purgeStale responses after deploy
Application cache (Redis, in-memory)Full controlEvent-driven or TTLProcess-local caches diverge across instances
ORM/query cacheFramework configOften implicit, hard to invalidateStale reads after direct DB writes

Detection: When You're Creating a Stale Cache

Stop and fix if you see:

  1. cache.set() in the read path but no cache.delete() in the write path -- writes don't invalidate reads; the cache serves old data until TTL or restart.

  2. new Map() or {} used as a cache with no size limit -- grows forever. Staleness bug AND memory leak. Use LRU with a max size.

  3. TTL-only invalidation on security-sensitive data (permissions, access tokens, feature flags) -- a revoked admin keeps access for the entire TTL. Invalidate on change, not on timer.

  4. Cache-Control: max-age=86400 on mutable data -- the browser won't ask the server for 24 hours. Can't fix server-side; the only escape is changing the URL.

  5. Cache key that doesn't include all variables affecting the value -- product:${id} when the response varies by locale, role, or currency. Users see each other's cached content.

  6. Caching at the application level while also serving through a CDN -- two caches, probably one invalidation path. Invalidate Redis, the CDN still has the old version.

  7. Write path that bypasses cache invalidation -- the API invalidates on update, but a background job or admin tool writes directly to the DB. The cache never learns.

Patterns

Content-addressed caching (best -- no invalidation needed)

The cache key IS the content hash, so when content changes the key changes and old entries evict naturally via LRU.

function getAssetUrl(content: Buffer): string {
  const hash = crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
  return `/assets/${hash}.js`; // serve with: Cache-Control: public, max-age=31536000, immutable
}

This is why build tools hash filenames (main.a1b2c3d4.js) while serving the referencing HTML with no-cache. Use whenever the cached value is derived deterministically from its inputs.

Cache-aside with event-driven invalidation

const CACHE_TTL = 300; // safety net, not the primary mechanism

async function getProfile(userId: string): Promise<UserProfile> {
  const cached = await redis.get(`profile:${userId}`);
  if (cached) return JSON.parse(cached);

  const profile = await db.users.findUnique({ where: { id: userId } });
  await redis.setex(`profile:${userId}`, CACHE_TTL, JSON.stringify(profile));
  return profile;
}

async function updateProfile(userId: string, data: Partial<UserProfile>) {
  const updated = await db.users.update({ where: { id: userId }, data });
  // Write-through: set to the known-correct value from the write
  await redis.setex(`profile:${userId}`, CACHE_TTL, JSON.stringify(updated));
  // For other instances with local caches, publish invalidation
  await redis.publish("cache-invalidation", JSON.stringify({ type: "profile", id: userId }));
}
  • Write-through (set to new value) beats delete (let next read repopulate): delete risks repopulation from a stale replica -- see the Consistency Models skill.
  • The TTL is a safety net that catches writes bypassing the invalidation path (admin tools, migrations, background jobs).
  • Multiple instances with local caches need a pub/sub invalidation channel.

Bounded in-memory cache with LRU

Never use a plain Map as a cache. Always use an LRU (or similar) with a size limit and TTL. allowStale: true gives stale-while-revalidate: the caller gets the old value immediately while the fresh value loads in the background.

import { LRUCache } from "lru-cache";

const profileCache = new LRUCache<string, UserProfile>({
  max: 10_000,
  ttl: 5 * 60 * 1000,
  allowStale: true,
  fetchMethod: async (userId) => db.users.findUnique({ where: { id: userId } }),
});

async function getProfile(userId: string): Promise<UserProfile> {
  return profileCache.fetch(userId);
}

Cache stampede prevention (single-flight)

When a popular entry expires, hundreds of requests see a miss simultaneously and all hit the database. Coalesce them into one.

const inFlight = new Map<string, Promise<any>>();

async function getCached<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number): Promise<T> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
  if (inFlight.has(key)) return inFlight.get(key) as Promise<T>; // already fetching, wait for it

  const promise = fetcher()
    .then(async (result) => {
      await redis.setex(key, ttlSeconds, JSON.stringify(result));
      inFlight.delete(key);
      return result;
    })
    .catch((err) => {
      inFlight.delete(key);
      throw err;
    });

  inFlight.set(key, promise);
  return promise;
}

This works within a single process only. For multi-instance stampede prevention, use a distributed lock or probabilistic early expiration (each request has a small random chance of refreshing before TTL).

Cache key design

Every variable that affects the cached value must be in the key, or different contexts share data they shouldn't.

const key = `product:${productId}`;                                  // BAD: ignores locale/currency
const key = `product:${productId}:locale:${locale}:currency:${currency}`;
const key = `dashboard:${userId}:${orgId}`;                          // user-specific data
const key = `orders:${userId}:page:${page}:size:${pageSize}:sort:${sortBy}`; // paginated data

What to Cache Where

DataInvalidation strategyTTLNotes
Static assets (JS, CSS, images)Content-addressed URLImmutable (1 year)No invalidation -- URL changes
User profile (own)Write-through on update5 min safety netMust see own changes immediately
User profile (others)TTL-only5-15 minEventual consistency is fine
Permissions / access controlInvalidate on change30 sec safety netNever TTL-only for security data
Feature flagsInvalidate on change1 min safety netLong TTL delays incident response
Product catalogEvent-driven + TTL15 minFlash sales need faster invalidation
Search resultsTTL-only1-5 minEventually consistent by nature
API rate limit countersN/A (not a cache)N/AUse Redis atomic ops, not caching

Anti-Patterns

// No invalidation: cache written, never cleared
function getUser(id) { /* sets cache */ }
function updateUser(id, data) { /* updates DB, doesn't touch cache */ }

// Unbounded: grows until OOM
const queryCache = new Map<string, any>();

// TTL on security data: revoked user keeps access for 15 minutes
const permCache = new Map();
const TTL = 15 * 60 * 1000;

// Delete-then-repopulate race: del, then another request reads a lagging replica and re-caches stale data
await redis.del(`user:${id}`);

// Cache key missing context: users see each other's locale-specific content
await redis.set(`product:${id}`, JSON.stringify(localizedProduct));

Related Traps

  • Consistency Models -- cache staleness is a consistency problem. Write-through caching avoids the race where delete-then-repopulate reads from a stale replica. See the Consistency Models skill for read-after-write patterns.
  • Memory Leaks -- every unbounded cache is a memory leak. If it has no max size and no eviction policy, it grows until the process is killed.
  • Thundering Herd -- cache stampede (popular key expires, all requests hit the database simultaneously) is the thundering herd problem applied to caching. Single-flight and stale-while-revalidate prevent this.
  • Denormalization -- a cache is a form of denormalization. Every cached value is a copy that must be kept in sync with the source. The consistency obligation is the same.

triggerdotdev의 다른 스킬

trigger-dev-tasks
triggerdotdev
Trigger.dev 백그라운드 작업과 워크플로우를 작성, 설계 또는 최적화할 때 이 스킬을 사용하세요. 여기에는 안정적인 비동기 작업 생성, AI 구현 등이 포함됩니다.
official
trigger-authoring-chat-agent
triggerdotdev
@trigger.dev/sdk/ai의 chat.agent를 사용하여 지속형 AI 채팅 에이전트를 작성하고 실행합니다: 턴별 실행 루프, ...chat.toStreamTextOptions()를 반드시 펼쳐야 하는 이유
official
trigger-agents
triggerdotdev
Trigger.dev를 사용한 AI 에이전트 패턴 - 오케스트레이션, 병렬화, 라우팅, 평가자-최적화기, 인간-인-더-루프. LLM 기반 작업을 구축할 때 사용합니다…
official
trigger-config
triggerdotdev
Trigger.dev 프로젝트를 trigger.config.ts로 구성합니다. Prisma, Playwright, FFmpeg, Python용 빌드 확장을 설정하거나 배포를 사용자 지정할 때 사용합니다…
official
trigger-cost-savings
triggerdotdev
Trigger.dev 작업, 일정 및 실행을 분석하여 비용 최적화 기회를 찾습니다. 지출 절감, 비용 최적화, 사용량 감사, 적정 규모 조정 등을 요청받을 때 사용하세요.
official
trigger-realtime
triggerdotdev
Trigger.dev 작업 실행을 프론트엔드와 백엔드에서 실시간으로 구독합니다. 진행률 표시기, 라이브 대시보드, 스트리밍 AI/LLM 응답 등을 구축할 때 사용하세요.
official
trigger-setup
triggerdotdev
프로젝트에 Trigger.dev를 설정합니다. Trigger.dev를 처음 추가하거나, trigger.config.ts를 생성하거나, trigger 디렉토리를 초기화할 때 사용하세요.
official
trigger-tasks
triggerdotdev
AI 에이전트, 워크플로우 및 지속적인 백그라운드 작업을 Trigger.dev로 구축하세요. 작업 생성, 작업 트리거, 재시도 처리, 크론 작업 예약 시 사용하거나...
official