Skip to content
instant.by-design
L0212 min

use cache and the migration traps

Migrate off unstable_cache and revalidate. Automatic keys and closure capture, frozen non-determinism, the new revalidateTag signature, and serverless caveats.

One directive replaces four APIs you know

'use cache' is not just a new way to cache. It is the replacement for unstable_cache, the fetch cache options, fetchCache, and dynamic = 'force-static'. This lesson is the migration, plus the four things that surprise people who cached the old way.

The mapping

unstable_cache'use cache' · revalidate = NcacheLife({ revalidate: N }) · force-static'use cache' + cacheLife('max'). You place the directive at a file, component, or function scope; the function level is the common one.

unstable_cache → use cachetsx
// Before: unstable_cache with manual keysconst getUser = unstable_cache(  async (id) => db.users.find(id),  ['user'],                 // keyParts — you maintained these by hand  { tags: ['users'], revalidate: 60 },) // After: 'use cache' — keys are generated for youasync function getUser(id: string) {  'use cache'  cacheTag('users')  cacheLife({ revalidate: 60 })  return db.users.find(id)}
1

Trap 1: keys are automatic, including the closure

No more keyParts. The key is built from the function id, the serializable arguments, and the captured closure variables. That last part is the trap: anything you close over is part of the key, whether you meant it or not.

The closure is in the keytsx
function Report({ orgId }: { orgId: string }) {  const big = buildHugePolicyObject()   // captured in the closure below   async function load(range: string) {    'use cache'    // Cache key = orgId (closure) + big (closure) + range (argument).    // 'big' is serialized into the key on every call. That is the trap.    return db.metrics(orgId, range)  }  return load('30d')}
Predict first

Why is capturing `big` in the key a problem, and what breaks if `big` were a per-request value instead of a heavy constant?

2

Trap 2: request data cannot enter a cache

A cached result must be reusable, so cookies(), headers(), and searchParams throw inside 'use cache'. Read them outside and pass the value in; it becomes part of the key. The one escape hatch is 'use cache: private', which you will meet in Lesson 5, not the default.

This one passes the build

A cookies() read buried deep in a cached call tree does not fail next build. It throws at request time under next start. Verify cached code on a running server, not just a green compile.

3

Trap 3: non-determinism freezes at fill time

Math.random(), Date.now(), new Date(), and crypto.randomUUID() inside a cache execute once when the entry fills, then serve that frozen value to everyone. If you want per-request values, use connection() to defer back to request time, outside the cache.

Frozen vs freshtsx
import { connection } from 'next/server' async function getBanner() {  'use cache'  // Runs ONCE when the cache fills, then frozen. Every user sees the same id.  const id = crypto.randomUUID()  return db.banner(id)} async function RequestId() {  await connection()          // opt back into request time  return <span>{crypto.randomUUID()}</span>  // fresh per request}
4

Trap 4: revalidateTag changed, and updateTag is new

Two changes from the invalidation you know. revalidateTag now takes a cacheLife profile as a second argument to enable stale-while-revalidate. And updateTag is new: it gives read-your-writes in the same request, which revalidateTag never guaranteed.

app/actions.tstsx
'use server'import { revalidateTag, updateTag } from 'next/cache' export async function publishPost(data: FormData) {  await db.posts.create({ data })  // Next.js 16: revalidateTag now takes a cacheLife profile as a 2nd arg.  revalidateTag('posts', 'max')     // background, stale-while-revalidate} export async function editPost(id: string, data: FormData) {  await db.posts.update({ where: { id }, data })  updateTag(`post-${id}`)            // immediate, same request reads its own write}
Picking an invalidation call
CallTimingReach for it when
revalidateTag(tag, profile)BackgroundA later request may see fresh data. The common choice.
updateTag(tag)ImmediateThe same response must reflect the write (Server Actions only).
refresh()ImmediateYou need to refresh uncached data only, without touching the cache.
Serverless caveat

Plain 'use cache' is in-memory and does not persist across serverless instances or deploys. For a shared, durable cache use 'use cache: remote' (platform KV/Redis).

Check yourself

A dashboard number is cheap to compute, changes a few times a day, and every user should see the same value. It is currently a `fetch(url, { next: { revalidate: 3600 } })`. What is the clean migration?

What you learned

  • 'use cache' replaces unstable_cache, fetch options, and force-static.
  • Keys are automatic from arguments and the closure; keep request data and heavy captures out.
  • Non-deterministic calls freeze at fill time; use connection() for per-request values.
  • revalidateTag takes a profile arg now; updateTag adds read-your-writes.
  • In-memory cache is per instance; use 'use cache: remote' to share it.

Next: navigation. Your existing loading.tsx and top-level boundaries are about to become the thing that blocks the shell.