Skip to content
Chapter 32Lesson 4

Lifetimes and tags

Control a cached entry's freshness in Next.js with cacheLife, the timeout that sets how long a value lives, and cacheTag, the named handle the source invalidates when the data changes.

In the previous lesson you learned that 'use cache' marks a function cacheable: the compiler stores its result under a key built from the function’s arguments, the variables it captures, and its source. You ended with a fetcher that worked but carried a deliberate IOU.

lib/posts.ts
export async function getPost(slug: string) {
'use cache';
// cacheLife + cacheTag → next lesson
const res = await fetch(`https://cms.example.com/posts/${slug}`);
return res.json();
}

This lesson fills in that comment. The directive caches the result, but you never said for how long and never gave the cache a name. Both omissions have consequences you didn’t choose.

With no lifetime, the entry falls back to a built-in default you didn’t pick. With no name, nothing can tell the entry that its data changed and the stored copy should be thrown away; it refreshes only on its own internal clock or when you ship new code on the next deploy. Until then, every reader gets the same stored copy. A cached function with no lifetime and no name caches close to forever, on autopilot rather than by your decision.

Two questions fix that. How long should this value live? And how does whatever owns the data tell the cache that the data changed? Each has an answer, and together they are the lesson:

  • cacheLife sets the freshness window.
  • cacheTag is a name you attach so the entry can be invalidated on demand.

Each is a single extra line in the function body. The shape you’re building toward, the one on nearly every cached read in a production codebase, is cacheLife('max') paired with precise tags.

Most people meet caching with one intuition: it makes things faster, a longer cache is faster still, so cache everything for as long as possible. That will get you into trouble, and it’s worth seeing why.

Caching doesn’t buy speed for free. It serves a value computed earlier, and the longer you cache, the older that value is allowed to be. Every cache lifetime is a trade of freshness for speed. The question is never “how fast can I make this,” it’s “how stale can this data get before it misleads the user?”

That’s a product question. Consider three values from one app:

  • A pricing page can be a full day out of date and nobody is harmed, so you can cache it for a long time.
  • A notification badge can’t be even a minute stale without the user feeling lied to, so you cache it barely, if at all.
  • An invoice total the user just edited can’t be one second stale. Showing the old number after they hit save is a bug, not a cache hit.

One codebase, three different answers, none of them about speed. Each asks how wrong the user is allowed to be when they look at this value. That is what cacheLife configures, with three numbers. We’ll take them one at a time against a single entry’s timeline, since meeting all three at once is what leads people to confuse them.

The first is stale: how long a client may keep reusing the value without contacting the server at all. Inside this window the user gets an instant value and your origin does no work. It’s the cheapest, freshest-feeling part of the entry’s life, and the part where you have the least control, because once a client holds the value you can’t update it until the window passes.

The second is revalidate. Past this point the next request is still served the cached value immediately, but it also kicks off a refresh in the background, so the next visitor gets fresh data. This is stale-while-revalidate : best-effort freshness at no cost to the user.

The third is expire, the hard ceiling. If the entry sits with no requests for this long, the stored value is too old to hand out. The next request can’t take the stale copy; it blocks and waits for a fresh fetch. This is the line where you’d rather make one unlucky user wait than show anyone data this old.

Hold onto the split between those last two. revalidate refreshes in the background at no cost to the user; expire refuses to serve the old value even if someone has to wait. One is best-effort and invisible, the other a guarantee that costs one user some latency. Now watch a single entry move through all three.

Client serves it instantly server not contacted now
stale reuse, no check
revalidate refresh in background
expire hard ceiling
too old

Fresh, inside stale. The entry was just written. The client reuses its own copy with no check, so the user gets the value instantly and your server is never contacted.

Server hands the stored value back no fetch · still instant now
stale reuse, no check
revalidate refresh in background
expire hard ceiling
too old

Past stale. The client now checks with the server, but the stored value is still current, so the server hands it straight back. No fetch, still instant.

stale value served now refetch in background
now
stale reuse, no check
revalidate refresh in background
expire hard ceiling
too old

Past revalidate. This request is served the stale value instantly, and a background refresh fires at the same time. This user waits no time; the next gets fresh data. That is stale-while-revalidate.

request blocks & waits fetch fresh before it can render
now
stale reuse, no check
revalidate refresh in background
expire hard ceiling
too old

Past expire, no requests in between. The stored value is too old to serve. The next request blocks and waits for a fresh fetch before it can render, so one unlucky user pays the latency and nobody sees data this stale.

Each phase maps to something a real person experiences when they load the page at different moments in the entry’s life. Revalidation is less a cache setting than a promise about what the user gets to see.

Now the call site. Like the directive, cacheLife is called inside the function body, right after 'use cache'. It’s a named import from next/cache, and calling it at module scope throws.

import { cacheLife } from 'next/cache';
export async function getProductCatalog() {
'use cache';
cacheLife('max');
const res = await fetch('https://api.example.com/products');
return res.json();
}

The directive you already know. It makes this function’s result cacheable across requests.

import { cacheLife } from 'next/cache';
export async function getProductCatalog() {
'use cache';
cacheLife('max');
const res = await fetch('https://api.example.com/products');
return res.json();
}

The lifetime sits directly under the directive on purpose. The two lines read as one unit, so anyone opening the file sees the freshness contract at a glance. 'max' is a preset we’ll unpack next.

import { cacheLife } from 'next/cache';
export async function getProductCatalog() {
'use cache';
cacheLife('max');
const res = await fetch('https://api.example.com/products');
return res.json();
}

cacheLife is a named import from next/cache, the same module the tag call comes from. It only works inside a cached function body, never at module scope.

1 / 1

Keep the directive and the lifetime adjacent like this. With the two lines together, the freshness policy stays local and readable, and anyone scanning the file knows what this entry does without hunting through config.

You rarely pick the three numbers by hand. Next.js ships named presets, each tuned for a shape of data rather than a stopwatch reading, and a custom set of numbers is the escape hatch you reach for only when no preset fits. The skill is matching your data to a preset: ask “what kind of thing is this,” not “how many seconds.”

Preset
The user sees…
Reach for it when…
'seconds'
Near-real-time — refreshes constantly.
a live metric or status that must track reality second by second.
'minutes'
Can lag a little.
a feed, a news list, a dashboard metric that's fine a few minutes behind.
'hours'
Several updates a day.
inventory or stock levels that change through the day.
'days'
One update a day.
a blog post, a marketing or pricing page.
'weeks'
A weekly cadence.
a newsletter archive, a weekly digest.
'max' production default
Effectively stable.
a product catalog, CMS content, settings — anything you invalidate explicitly with a tag.
default
What a bare 'use cache' gets.
a background refresh on a roughly 15-minute clock; never hard-expires.
Match the data's shape to a preset. The numbers behind each preset matter far less than picking the row that describes how your data actually changes.

Watch the 'max' row. It’s the longest preset, so it reads like “never refreshes,” but it isn’t: under the hood 'max' still runs a background revalidation on a slow clock, roughly once a month. It earns the name because it fits data you don’t want refreshing on a clock at all, data you’ll refresh yourself, precisely, with a tag, the moment it changes. A product catalog, your CMS content, an org’s settings: all of these change when an admin edits them, not on a schedule. So 'max' says “don’t refresh this on a timer; I’ll tell you when it’s stale.”

The Next.js docs recommend a discipline worth adopting: name the lifetime explicitly for any cache that holds business data, even when default would do. There are two reasons.

The first is documentation. A function with cacheLife('days') written into it tells the next reader exactly how fresh it is, with no guessing.

The second is a real engineering hazard. When one cached function calls another, their lifetimes interact: an inner cache with a shorter lifetime quietly pulls down an outer cache that relies on default, and the 'seconds' preset does worse, propagating upward at build time to turn the outer cache into a dynamic hole. That’s the same shell-and-holes split from the Shells and holes (PPR) lesson, happening here by accident through a nested call you forgot about. State the lifetime on every cached function and you can read any one of them in isolation, so this effect-at-a-distance can’t happen.

When no preset fits, define a named profile in next.config.ts and reference it by name. Say a fetcher genuinely needs “stale for 30 minutes, refresh every 5, expire after a day.” An inline object of those three numbers is allowed, but for anything reused, name it in config.

next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
blogPost: { stale: 1800, revalidate: 300, expire: 86400 },
},
};
export default nextConfig;

One file owns freshness policy. The whole team audits every custom lifetime here, named for what it’s for. The three numbers are seconds: stale 30 min, revalidate 5 min, expire 1 day.

Picking a lifetime is a short series of questions asked in a fixed order. The order is what matters; the leaf you land on is almost incidental. Walk through it.

Picking a lifetime

The first question is about the user, not speed; the second is about whether you’ll be told when the data changes. That second question is the hinge of the whole lesson, and it’s where we go next.

Two ways a cached entry refreshes: timeout and push

Section titled “Two ways a cached entry refreshes: timeout and push”

A cached entry has two independent ways to become fresh again, and they are easy to conflate. Separate them cleanly and the rest of the lesson falls into place.

A timeout is what cacheLife controls: the entry refreshes on a clock, whether or not the data actually changed. This is a pull policy, the cache re-runs the function on a timer and replaces what it stored. Use it when you can’t know when the data changes, for third-party data you don’t own or content with no edit hook, or when approximate freshness is good enough.

A push is what cacheTag plus an invalidation call gives you: the entry refreshes because the source said so, exactly when the data changed, regardless of the lifetime. Whoever triggers the change, the admin who saves a product or the user who edits an invoice, already knows the data changed. So instead of letting the cache discover it later on a clock, you tell the cache at the instant it happens.

Timeout pull
cacheLife
  • Refreshes on a clock
  • Whether or not the data changed
  • Good when nothing tells you about changes
Push on change
cacheTag + invalidation
  • Refreshes exactly when data changed
  • Triggered by the mutation itself
  • Good when you own the change event
Production: cacheLife('max') + tags — use both. The clock becomes a safety net; the tag does the real work.
Not competing options to choose between, but orthogonal mechanisms you combine.

This is the footer band promised at the top of the lesson. cacheLife('max') sets the timeout to effectively never, so you don’t pay for pointless background refreshes on data that hasn’t changed. The tags do the real work, refreshing the entry exactly when something changes; the clock becomes a safety net for the rare missed invalidation.

One caution before we go on: this lesson only attaches and names tags, it does not pull them. A tag does nothing observable until you call the invalidation API after a mutation, which is a later lesson in this chapter. Through the rest of this lesson the tags are inert wiring, so don’t go looking for an effect that lands a few lessons from now.

cacheTag names a cache entry so it can be invalidated

Section titled “cacheTag names a cache entry so it can be invalidated”

The mechanics are small. Called inside a 'use cache' body, cacheTag('products') attaches the string 'products' to this entry as a named tag . A later call can then invalidate every entry carrying that tag at once. One function may attach several tags, and any of them invalidates the entry. Like cacheLife, cacheTag is a named import from next/cache that works only inside the cached body.

Here is the catalog fetcher with the full anatomy: directive, lifetime, and now a tag.

lib/products.ts
import { cacheLife, cacheTag } from 'next/cache';
export async function getProductCatalog() {
'use cache';
cacheLife('max');
cacheTag('products');
const res = await fetch('https://api.example.com/products');
return res.json();
}

The directive makes it cacheable, the lifetime says “don’t refresh on a clock,” and the tag gives an invalidation call something to aim at. The judgment is in the string.

Tag naming is the durable skill here, and the convention has two levels:

  • entity-type for a collection, like products or invoices. This is the handle for “the whole list.”
  • entity-type:id for a single record, like product:abc or invoice:42. This is the handle for “this one thing.”

You need both because editing one invoice has two effects on the cache: that invoice’s detail view goes stale, and so does every list containing it, since each now shows a stale row. The fine tag addresses the first, the coarse tag the second, each pointing at a different cache entry.

The fine-grained tag is computed from the function’s argument at call time:

import { cacheLife, cacheTag } from 'next/cache';
export async function getProduct(id: string) {
'use cache';
cacheLife('max');
cacheTag('products');
cacheTag(`product:${id}`);
const res = await fetch(`https://api.example.com/products/${id}`);
return res.json();
}

Cacheable, as before.

import { cacheLife, cacheTag } from 'next/cache';
export async function getProduct(id: string) {
'use cache';
cacheLife('max');
cacheTag('products');
cacheTag(`product:${id}`);
const res = await fetch(`https://api.example.com/products/${id}`);
return res.json();
}

Effectively never refreshed on the clock, because this product changes when an admin edits it, not on a timer.

import { cacheLife, cacheTag } from 'next/cache';
export async function getProduct(id: string) {
'use cache';
cacheLife('max');
cacheTag('products');
cacheTag(`product:${id}`);
const res = await fetch(`https://api.example.com/products/${id}`);
return res.json();
}

The coarse handle. Invalidating 'products' refreshes this entry along with every other product-list entry.

import { cacheLife, cacheTag } from 'next/cache';
export async function getProduct(id: string) {
'use cache';
cacheLife('max');
cacheTag('products');
cacheTag(`product:${id}`);
const res = await fetch(`https://api.example.com/products/${id}`);
return res.json();
}

The fine handle, built from the argument. For product abc the string becomes product:abc, unique to this record, so invalidating it refreshes just this one.

1 / 1

The same value now carries two handles, each aimed at the right slice of the cache.

A tag-targeted invalidation marks the entry stale immediately, no matter what cacheLife says. The 'max' lifetime is the timeout policy for when no invalidation ever arrives; the tag plus an invalidation call is the push policy for when the upstream knows. They cover different cases rather than compete.

Centralize tag strings in one helper so read and write can’t drift

Section titled “Centralize tag strings in one helper so read and write can’t drift”

So far you’ve typed tag strings by hand. Real code never does, and here’s why.

Tags are plain strings, and nothing type-checks them. Worse, every tag is written in two places: the cached fetcher that attaches it (the read side) and the later mutation that invalidates it (the write side). Those two strings must match character for character, or the connection breaks. A typo on either side doesn’t throw, doesn’t warn, doesn’t draw a red squiggle. The invalidation call fires, matches nothing, and the user keeps seeing stale data.

The fix follows from the problem: never write tag strings inline. Funnel every tag through one helper module so both sides import the same source of truth. The project keeps this at src/lib/tags.ts, and the invalidation lesson later in this chapter imports it, so it has to exist by then.

Here’s the shape that file gives you:

src/lib/tags.ts
invoiceTags.list(orgId); // the collection of invoices for an org
invoiceTags.record(orgId, id); // one invoice
orgTags.all(orgId); // everything cached for an org
userTags.all(userId); // everything cached for a user

Read this as a straight upgrade from the colon-strings you just learned: invoice:42 was the right idea, and invoiceTags.record(orgId, id) makes it typo-proof, multi-tenant-aware, and greppable. Typo-proof, because it’s a function call the type checker validates. Multi-tenant-aware, because it folds in the orgId, so two orgs never collide on the same tag. Greppable, because finding every use of a tag is now a search for one symbol, not a string pattern.

Two of those scopes, orgTags.all and userTags.all, reach past the entity / entity:id pair. A multi-tenant app sometimes needs to invalidate everything cached for one org in a single call, say after a billing-plan change that touches a dozen cached surfaces at once. Listing every entity tag by hand would be fragile, since it’s easy to miss one. orgTags.all(orgId) is the one handle that catches them all.

The read side then uses the helper exactly where it used to use a hand-typed string:

// read side — the cached fetcher attaches the tag
cacheTag('invoices');
// write side — a future mutation invalidates the same tag
invalidate('invoces');

A silent no-op. The write side has a typo: 'invoces'. Nothing catches it, not the compiler, not the linter, not the runtime. The call fires, matches no entry, and the user sees stale data indefinitely. Two strings in two files have drifted apart and nobody noticed. (invalidate(...) stands in for the real invalidation call you’ll meet later in this chapter.)

The fragile version isn’t obviously wrong, which is why it slips through a real review. The helper makes that failure mode impossible.

A few things to watch out for:

You can now write a cached read end to end. Here’s the canonical shape in the invoices domain, using the tags.ts helper for both the collection and the record:

import { cacheLife, cacheTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
export async function getInvoice(orgId: string, id: string) {
'use cache';
cacheLife('max');
cacheTag(invoiceTags.list(orgId));
cacheTag(invoiceTags.record(orgId, id));
return db.query.invoices.findFirst({
where: (t, { eq, and }) => and(eq(t.orgId, orgId), eq(t.id, id)),
});
}

Opt in to caching. Without this line the function is dynamic and runs every request.

import { cacheLife, cacheTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
export async function getInvoice(orgId: string, id: string) {
'use cache';
cacheLife('max');
cacheTag(invoiceTags.list(orgId));
cacheTag(invoiceTags.record(orgId, id));
return db.query.invoices.findFirst({
where: (t, { eq, and }) => and(eq(t.orgId, orgId), eq(t.id, id)),
});
}

The freshness policy: don’t refresh on a clock. An invoice changes when someone edits it, not on a timer, so the tags drive freshness.

import { cacheLife, cacheTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
export async function getInvoice(orgId: string, id: string) {
'use cache';
cacheLife('max');
cacheTag(invoiceTags.list(orgId));
cacheTag(invoiceTags.record(orgId, id));
return db.query.invoices.findFirst({
where: (t, { eq, and }) => and(eq(t.orgId, orgId), eq(t.id, id)),
});
}

The collection handle. Editing this invoice invalidates every list it appears in through this tag.

import { cacheLife, cacheTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
export async function getInvoice(orgId: string, id: string) {
'use cache';
cacheLife('max');
cacheTag(invoiceTags.list(orgId));
cacheTag(invoiceTags.record(orgId, id));
return db.query.invoices.findFirst({
where: (t, { eq, and }) => and(eq(t.orgId, orgId), eq(t.id, id)),
});
}

The record handle, org-scoped and unique to this invoice. Editing it invalidates this exact detail view.

import { cacheLife, cacheTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
export async function getInvoice(orgId: string, id: string) {
'use cache';
cacheLife('max');
cacheTag(invoiceTags.list(orgId));
cacheTag(invoiceTags.record(orgId, id));
return db.query.invoices.findFirst({
where: (t, { eq, and }) => and(eq(t.orgId, orgId), eq(t.id, id)),
});
}

The actual work, cached under all of the above. It’s the same query you’d write anyway: caching is the four lines on top, not a rewrite.

1 / 1

The tags are attached and named, inert for now. The invalidation lesson later in this chapter puts them to work: a mutation edits an invoice, calls the matching invalidation, and the user sees the change immediately instead of waiting out a clock.

First, sort some data into the lifetime it deserves.

Sort each piece of data into the lifetime it deserves. Ask how stale it can be before a user is misled, and whether an explicit event tells you when it changed. Drag each item into the bucket it belongs to, then press Check.

cacheLife('minutes') Harmless staleness, fast cadence
cacheLife('days') Updates roughly daily
cacheLife('max') + tag Stable; invalidated on an explicit event
Don't cache Stale reads mislead and no change event exists
An org-wide “visitors this hour” chart on an analytics dashboard
A public status-page incident feed
The marketing site’s pricing page
A published blog post
The product catalog, edited by admins
An org’s settings page
The signed-in user’s unread notification count
An invoice total on the page where the user is editing it

Now assemble the function. The skeleton below is missing the four pieces that go inside a cached body. Fill in each blank.

Fill the four blanks inside the body. This is a list fetcher, so it carries the collection tag only. Pick the right option from each dropdown, then press Check.

import { cacheLife, cacheTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
export async function listOrgInvoices(orgId: string) {
___;
___('___');
___(invoiceTags.list(orgId));
return db.query.invoices.findMany({
where: (t, { eq }) => eq(t.orgId, orgId),
});
}

If you can place those four pieces from memory and say why each says what it says, you have the full anatomy of a cached function: the directive opts in, the lifetime sets the timeout policy, and the tags set up the push policy.