Skip to content
Chapter 32Lesson 4

Lifetimes and tags

How cacheLife and cacheTag control a cached entry's freshness in Next.js, the timeout that decides how long a value lives and the named handle that lets the source push an invalidation when the data changes.

In the previous lesson you learned what 'use cache' does: it marks a function cacheable, and the compiler stores its result under a key built from the function’s arguments, the variables it captures, and its source. You ended that lesson 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 you never gave the cache a name. Because of those two omissions, two things are now true about this entry that you didn’t actually choose.

First, with no lifetime, the entry falls back to a sensible built-in default, but it’s a default you didn’t pick. Second, with no name, nothing can tell this entry that the 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 one of those happens, every reader gets the same stored copy. A cached function with no lifetime and no name caches close to forever, and it does so on autopilot rather than by your decision.

An experienced engineer looking at this fetcher asks two questions right away. How long should this value live? And how does whatever owns the data tell the cache that the data changed? Those two questions have two answers, and together they make up the whole lesson:

  • cacheLife answers the first. It sets the freshness window.
  • cacheTag answers the second. It’s a name you attach so the entry can be invalidated on demand.

Each one is a single extra line inside the function body. By the end of this lesson the fetcher above will carry both, and you’ll understand why each line says what it says. The shape you’re building toward, the one you’ll see on nearly every cached read in a production SaaS codebase, is cacheLife('max') paired with precise tags. That pairing probably looks strange right now, so the lesson builds up to why it’s the default.

cacheLife is a UX decision, not a performance decision

Section titled “cacheLife is a UX decision, not a performance decision”

Before any syntax, there’s a common instinct worth unlearning. Most people meet caching and reach for the same intuition: caching makes things faster, a longer cache is faster still, so cache everything for as long as possible. That intuition will get you into trouble, and it’s worth seeing exactly why.

Caching doesn’t buy you speed for free. It buys speed by serving a value that was computed at some earlier point. The longer you cache, the older that value is allowed to be. So every cache lifetime is a trade: you’re exchanging freshness for speed. The real question is never “how fast can I make this.” It’s “how stale is this data allowed to be before the user is misled?”

That is a product question, not a performance knob. Consider three values from the same 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 themselves can’t be one second stale. Showing them the old number after they hit save is a bug, not a cache hit.

That’s one codebase with three completely different answers, and not one of them came from thinking about speed. Each came from asking how wrong the user is allowed to be when they look at this particular value. That’s what cacheLife configures. Keep that framing in mind through the rest of this section, because each of the three numbers you’re about to meet is an answer to it.

cacheLife describes the life of a cached entry 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. This is how long a client may keep reusing the value with no revalidation at all. The browser holds onto it, and the server isn’t even contacted. 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. It’s also the part where you have the least control, because once a client has the value you can’t reach in and update it until this window passes.

The second is revalidate. After this point, the next request is still served the cached value immediately, but that request also quietly kicks off a refresh in the background. The user who triggered it waits no time and sees the old value; the refresh lands a moment later, so the next visitor gets fresh data. This pattern has a name: stale-while-revalidate . Think of revalidate as best-effort freshness at no cost to the user.

The third is expire. This is the hard ceiling. If the entry sits with no requests for this long, the stored value is considered too old to hand out at all. The next request to arrive can’t be served the stale copy; it has to block and wait for a fresh fetch to finish. Think of expire as the line where staleness becomes unacceptable, the point where you’d rather make one unlucky user wait than show anyone data this old.

The split between those last two is the part worth holding onto. revalidate says “refresh in the background, at no cost to the user.” expire says “past here, do not serve the old value even if it means someone waits.” One is best-effort and invisible; the other is a guarantee that costs one user some latency. Now let’s 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 at all, so the user gets the value instantly and your server is never even 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 considered 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 user gets the fresh value. 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, with no requests in between. The stored value is now too old to serve. The next request has to block and wait for a fresh fetch before it can render, so one unlucky user pays the latency and nobody sees data this stale.

Notice how each phase maps to something the user actually experiences, which is why it’s worth walking through one frame at a time. The three numbers stop being abstract config and become a contract about what a real person sees 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 for 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 it’s never called at module scope, since doing that 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 this file sees the freshness contract in two adjacent lines. '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 together like that. When the two lines sit next to each other, the freshness policy stays local and readable, and someone scanning the file knows what this entry does without hunting through config.

The preset profiles cover the common cases

Section titled “The preset profiles cover the common cases”

Most of the time you don’t pick the three numbers by hand. Next.js ships named presets, and reaching for a custom set of numbers is the exception, not the rule. Learn the presets first, and treat a custom profile as the escape hatch you open only when no preset fits the data.

Each preset is tuned for a shape of data, not a stopwatch reading. The skill is matching your data to the right preset, so think in terms of “what kind of thing is this” rather than memorizing second counts.

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.

The row to pay attention to is 'max'. It’s the longest preset, so it’s tempting to read it as “never refreshes.” It isn’t quite that. Under the hood 'max' still runs a background revalidation on a slow clock, roughly once a month. It earns the name “max” because it’s the right choice for data you don’t want refreshing on a clock at all: data you’re going to refresh yourself, precisely, with a tag, the moment it actually changes. A product catalog, your CMS content, an org’s settings all change when an admin edits them, not on any schedule. So 'max' effectively says “don’t bother refreshing this on a timer; I’ll tell you when it’s stale.” Hold onto that idea, because it’s the bridge to the next two sections.

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

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

The second reason is subtler, and it’s a real engineering point rather than boilerplate. When one cached function calls another, their lifetimes interact. An inner cache with a shorter lifetime can quietly pull down the lifetime of an outer cache that relies on default. A very short inner cache, the 'seconds' preset, can do worse: at build time it propagates upward and turns the outer cache into a dynamic hole. That’s the same shell-and-holes split from the Partial Prerendering lesson, except here it happens by accident, through a nested call you forgot about. When you state the lifetime on every cached function, you can read any one of them and know its behavior without tracing what it calls, so this kind of effect-at-a-distance can’t happen.

When no preset fits, you 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.” You could pass an inline object of those three numbers, and that’s allowed. But for anything reused, the seasoned default is to 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.

An inline object would work, but the named profile wins on both ends: one file to audit when freshness policy comes up in review, and a name at the call site that nobody has to decode from three integers.

The procedure for picking a lifetime is a short series of questions asked in a specific order. The order is what matters here; the leaf you land on is almost incidental. Walk through this one.

Picking a lifetime

Notice that the first question is about the user, not about speed, and 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.

This section is short, but the distinction it draws underpins everything else. A cached entry has exactly two independent ways to become fresh again, and people conflate them constantly. Separate them cleanly now and the rest of the lesson falls into place.

The first way is a timeout, which is what cacheLife controls. The entry refreshes on a clock, on its own schedule, whether or not the data actually changed. This is a pull policy: on a timer, the cache re-runs the function to get a fresh value and replaces what it stored. It’s the right mechanism when you have no way of knowing 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.

The second way is a push, which is what cacheTag plus an invalidation call gives you. The entry refreshes because the source said so, exactly when the data changed, regardless of what the lifetime says. Think about who triggers a change: the admin who saves a product, or the user who edits an invoice. That code already knows the data changed. So instead of letting the cache discover the change later on a clock, you tell the cache directly, 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.
These aren't competing options to choose between. They're orthogonal mechanisms you combine.

That footer band is the shape promised at the top of the lesson, and by now it should read as deliberate rather than strange. cacheLife('max') sets the timeout to effectively never, so you’re not paying 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 is no longer the mechanism; it’s a safety net for the rare case where an invalidation gets missed.

One thing to be clear about before we go further, because it’s easy to miss the first time: this lesson attaches and names tags. It does not pull them. Attaching a tag does nothing observable on its own. It’s wiring you finish in a later lesson in this chapter, where you’ll call the invalidation API after a mutation and watch the user’s change appear instantly. In every example through the rest of this lesson, the tags are inert. They’re labels waiting for a future call to act on them. Keep that in mind, or you’ll keep looking for an effect that only 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”

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

Here’s a catalog fetcher with the full anatomy in place: directive, lifetime, and now a tag. This is the shape the comment in the opening fetcher was promising.

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();
}

That’s the full shape: directive, lifetime, tag. 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 part that takes judgment is what you put in the string.

Tag naming is the durable skill of this section, 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.”

Why two levels? Rather than take it on faith, look at who consumes these tags. When you edit a single invoice later, you need to express two different things to the cache. You need to invalidate that one invoice’s detail view, and you need to invalidate every list that contains that invoice, since each of those lists now shows a stale row. A single flat naming scheme can express one of those but not both. The two-level namespace exists precisely so both are addressable against the same cached data, with the coarse tag and the fine tag pointing at different cache entries.

The fine-grained tag is computed from the function’s argument at call time. That’s the pattern that makes per-record tags work:

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 cached value now carries two handles. Invalidate 'products' and this entry refreshes along with every other product list. Invalidate product:abc and only this one record’s entry refreshes. You have both the coarse handle and the fine one, each pointing at the right slice of your cached data.

It’s worth stating concretely how this interacts with the lifetime, since it’s the payoff of the previous section against a real entry. A tag-targeted invalidation marks this 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 don’t compete, they cover different cases.

Centralize tag strings so read and write never drift

Section titled “Centralize tag strings so read and write never drift”

Everything so far has had you typing tag strings by hand. In real code you never do that, and the reason is worth understanding before you see the fix.

Tags are plain strings, and nothing type-checks them at any call site. On top of that, every tag gets written in two places: the cached fetcher that attaches it (the read side) and, later, the mutation that invalidates it (the write side). Those two strings have to match exactly, character for character, or the connection breaks. The trouble is that a typo on either side doesn’t throw. There’s no error, no warning, no red squiggle. The invalidation call fires, matches nothing, and the user keeps seeing stale data. That makes it a hard bug to catch, because nothing tells you it happened.

The fix follows directly from the problem: never write tag strings inline. Funnel every tag through one helper module so the read side and the write side import the exact same source of truth. The course’s project codebase keeps this at src/lib/tags.ts, and it’s the file the invalidation lesson later in this chapter will import to do the actual invalidating, so it has to exist by the time you get there.

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 mental model; invoiceTags.record(orgId, id) is that same idea made typo-proof, multi-tenant-aware, and greppable. It’s typo-proof because it’s a function call the type checker validates. It’s multi-tenant-aware because it folds in the orgId, so two different orgs never collide on the same tag. And it’s greppable because finding every use of an invoice-record tag is now a search for one symbol instead of a hunt for a string pattern.

Two of those scopes, orgTags.all and userTags.all, go beyond the two-level scheme, and they exist for a case the simple entity / entity:id pair can’t cover. A multi-tenant SaaS sometimes needs to invalidate everything cached for one org in a single call, for instance after a billing-plan change that touches a dozen different cached surfaces at once. Enumerating every entity tag would be fragile, since it’s easy to forget 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.)

Putting the bare string and the helper side by side makes the point. The fragile version isn’t obviously wrong, which is exactly why it slips through a real review. The helper makes that failure mode impossible.

A few things to watch out for cluster around tags.

You can now write the full anatomy of a cached read, both halves, 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 are what 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 will invalidate 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

That’s the shape promised at the start of the lesson: directive, lifetime, two tags, and the real query underneath. The tags are attached and named, sitting there inert for now. In the invalidation lesson later in this chapter you’ll pull them: a mutation edits an invoice, calls the matching invalidation, and the user sees their change land immediately instead of waiting out a clock. The wiring you finished here is what makes that possible.

To start, sort some data into the lifetime it deserves. This exercises the UX-decision habit the lesson opened with.

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 contract itself. The skeleton below is missing the four pieces that go inside a cached function’s 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 explain 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 that a later lesson in this chapter finally fires.