Skip to content
ayoubb.dev/blog/version-your-cache-keys

Why every cache key I write ends in :v1

A cached value outlives the code that wrote it. You deploy a new shape for an object, and Redis keeps serving the old shape to your new code until the TTL runs out.

The fix is to end every cache key with a version, and bump it whenever the cached value changes.

Why stale cache entries fail silently

redis.get<Post>(key) doesn't check anything. It returns whatever JSON is stored and labels it Post, whether or not last week's code agreed.

Say you cache posts for a day, then change authorId into an embedded author object. Every post already in Redis still has the old shape. Your components read post.author?.name, so nothing crashes. Every cached post just shows "Unknown author" until its entry expires.

A change in meaning is worse. If readingTime switches from seconds to minutes, old entries still have a number in that field, so the post shows "240 min read" and passes any type check or schema you throw at it.

How a versioned cache key fixes it

Change post:hello-world:v1 to post:hello-world:v2 and the new code misses the cache, reads the database and writes the new shape under the new key. The old entries sit unread until their TTL expires. You don't need a migration or a flush.

It also holds up during a rolling deploy, when old and new instances run side by side:

                 without a version        with a version
 
old instances ─▶ post:hello (old shape)   post:hello:v1
new instances ─▶ post:hello (new shape)   post:hello:v2

     whichever wrote last decides what everyone reads

Without a version, both generations of code overwrite each other's entries until the last old instance is gone.

Keep every key in one file, and write down why each bump happened:

cache-keys.ts
import { Redis } from "@upstash/redis";
 
const redis = Redis.fromEnv();
 
// Every key lives here, so a bump is one diff in one file.
export const cacheKeys = {
  // v2: `authorId` became an embedded `author` object.
  post: (slug: string) => `post:${slug}:v2`,
  // v2: the query is normalized, so "Next.js" and " next.js" share an entry.
  search: (query: string) =>
    `search:v2:${encodeURIComponent(query.trim().toLowerCase())}`,
} as const;
 
export async function cached<T>(
  key: string,
  ttlSeconds: number,
  load: () => Promise<T>,
): Promise<T> {
  const hit = await redis.get<T>(key);
  if (hit !== null) return hit;
 
  const fresh = await load();
  // Always set a TTL: it's what cleans up retired versions.
  await redis.set(key, fresh, { ex: ttlSeconds });
  return fresh;
}

When to bump the version

Bump it when any of these change:

  • The shape of the cached value, like a field added, removed or renamed.
  • The meaning of a field, like a change of units or a new default.
  • How the key is built, like normalizing the search query.
  • Who can read it. If drafts become private, a key without the viewer in it serves them to everyone, so add the scope and bump.

What about flushing or validating instead?

Flushing on deploy also wipes rate limits and sessions if they share the Redis instance, and sends every request to the database at once. During a rolling deploy, an old instance writes the old shape straight back.

Validating on read with a schema is a good addition. It catches the author case, but not the readingTime one, because the old value is still a valid number.

One global version in every key, like the git SHA, works but empties the whole cache on every deploy. Per-key versions only retire what changed.

Where versioning isn't enough

  • Keys shared across services must be bumped in both at once, or each side keeps its own copy.
  • Keys without a TTL never get cleaned up once nothing reads them.
  • Forgetting to bump is still possible. Keeping every key in one file puts the key next to the type change in the same PR, where a reviewer can ask about the entries already in Redis.

Start with :v1 on every key today. Adding a version later is itself a key change, so doing it now costs nothing.