Cache lifetimes and tags
Set how long a cached result lasts with cacheLife's profiles, write your own, see which lifetime wins in nested caches, and name results with cacheTag.
- 01Read a lifetimeSay what stale, revalidate and expire do, and find each one in the build's table and the response's headers.
- 02Choose and write profilesPick a built-in profile, write one of your own in next.config.ts, and predict the lifetime of nested caches.
- 03Name results, and keep the shellTag cached results so you can expire them later, and know which short lifetimes leave a hole in the static shell.
How long a copy lasts, and its name
A cached result is a stored copy of what a function returned, used again instead of doing the work again (The 'use cache' directive showed how to make one). Every copy raises two questions. How long may Next.js keep using it? And when the data behind it changes, how do you point at it? cacheLife answers the first, and cacheTag the second. Here is the tea shop's price list, a real Next.js 16.4.3 project, built with next build and run with next start. The result under the files is a recording of what happened.
lib/supplier.ts1import { cacheLife, cacheTag } from "next/cache";2 3const api = process.env.SUPPLIER_API_URL;4 5export type Tea = { id: string; name: string; price: number };6 7export async function getPrices(): Promise<Tea[]> {8 "use cache";9 cacheLife("hours");10 cacheTag("price-list");11 const response = await fetch(`${api}/prices`);12 return response.json();13}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /_not-found└ ○ /teas 1h 1d ○ (Static) prerendered as static content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html 9.1 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.3 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.4 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.4 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 3.9 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/teas.html 5.7 kB.next/server/app/teas.meta 749 bytes.next/server/app/teas.rsc 3.6 kB.next/server/app/teas.segments/_full.segment.rsc 3.6 kB.next/server/app/teas.segments/_tree.segment.rsc 165 bytes.next/server/app/teas.segments/teas/__PAGE__.segment.rsc 3.1 kBlib/supplier.ts is shown first. getPrices, on lines 7 to 13, asks the tea supplier's API for the prices. Line 8, "use cache", caches what it returns. Line 9, cacheLife("hours"), gives the copy a lifetime: how long it's used, and when it's replaced. "hours" is the name of a profile, a set of three numbers that Next.js has built in. Line 10, cacheTag("price-list"), gives the copy a name, a tag, that code elsewhere can use to say "the price list changed". Both functions come from next/cache (line 1) and only work inside a "use cache" function or component.
In app/teas/page.tsx, Prices waits for getPrices() and lists the teas inside Suspense, on lines 21 to 23. While the prices are cached for hours, the Loading prices… fallback never shows; it matters for the short lifetimes at the end.
The Terminal tab is open on the build's route table. ○ /teas is followed by 1h under Revalidate and 1d under Expire, and the legend under it says what ○ means: (Static) prerendered as static content. The build ran getPrices and put the prices in the page it prerendered, and that page can be made again later, because the price list inside it has a lifetime. Now open the Network tab. The response carries the lifetime too: x-nextjs-stale-time: 300 and Cache-Control: s-maxage=3600, stale-while-revalidate=82800. Those three numbers are the hours profile:
- stale, 5 minutes: how long the browser may reuse the page it already has, without asking the server.
- revalidate, 1 hour: after it, the copy is out of date, and
cacheLifeallows it to be refreshed in the background. For a prerendered page like this one, the first request still gets the old page, and starts making a new one. - expire, 1 day: after it, if no request came in between, the next request waits for a new copy.
/teas, with cacheLife("hours"). Until revalidate (1 hour), it's sent as it is. After it, the first request still gets the old page and starts a new one. After expire (1 day) with no request in between, the next request waits. Other cache handlers may drop a stale copy sooner. stale, 5 minutes, is about the browser's copy.The café makes a pot of tomato soup in the morning. A customer who takes a cup away drinks from it for a while without coming back to ask. After an hour, the soup is no longer at its best: the next customer still gets a bowl from the old pot, and the cook starts a new one. If nobody comes in for a whole day, the old pot is thrown out, and the next customer waits while a new one cooks. The label on the pot lets the cook find it when the supplier calls to say the tomatoes changed.
- In real life: A customer keeps the cup they took away for 5 minutes, without asking the café
- In Next.js:
stale: the browser reuses its copy for 5 minutes - In real life: After an hour, the next customer still gets soup from the old pot, and the cook starts a new pot
- In Next.js:
revalidate: after 1 hour, the old copy is sent once and a new one is made - In real life: After a day with no customers, the old pot is thrown out, and the next customer waits
- In Next.js:
expire: after 1 day, the next request waits for a new copy - In real life: A label on the pot: "tomato"
- In Next.js:
cacheTag("price-list"): a name for the copy
cacheLife gives a cached result three numbers: stale for the browser, revalidate and expire for the server. cacheTag gives it names, so you can expire it on purpose. Both are called inside the "use cache" function. The build prints the lifetime next to each route, and every response carries it in its headers.
The built-in profiles
Next.js has seven profiles built in, and you can pass any of the seven names to cacheLife, including "default". default is also what a cached result gets when it calls no cacheLife at all. Each row is one profile, with its numbers as Next.js 16.4.3 defines them, and what the recorded build printed for the price list with it:
| Profile | stale | revalidate | expire | What the build printed |
|---|---|---|---|---|
default (no cacheLife) | 5 minutes | 15 minutes | Never | 15m and 1y |
"seconds" | 30 seconds | 1 second | 1 minute | ◐ /teas, no times |
"minutes" | 5 minutes | 1 minute | 1 hour | 1m and 1h |
"hours" | 5 minutes | 1 hour | 1 day | 1h and 1d |
"days" | 5 minutes | 1 day | 1 week | 1d and 1w |
"weeks" | 5 minutes | 1 week | 30 days | 1w and 30d |
"max" | 5 minutes | 30 days | 1 year | 30d and 1y |
Every profile except seconds lets the browser keep its copy for 5 minutes. The default profile takes its stale from staleTimes.static in next.config.ts, which is 300 seconds unless you change it. Its expire is "never", which the build and the header show as a year: 1y.
Try each profile below. Before you click, guess what the build prints for the last one, "seconds".
lib/supplier.tsNext.jslib/supplier.ts1import { cacheLife, cacheTag } from "next/cache";2 3const api = process.env.SUPPLIER_API_URL;4 5export type Tea = { id: string; name: string; price: number };6 7export async function getPrices(): Promise<Tea[]> {8 "use cache";9 cacheLife("hours");10 cacheTag("price-list");11 const response = await fetch(`${api}/prices`);12 return response.json();13}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /_not-found└ ○ /teas 1h 1d ○ (Static) prerendered as static content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html 9.1 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.3 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.4 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.4 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 3.9 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/teas.html 5.7 kB.next/server/app/teas.meta 749 bytes.next/server/app/teas.rsc 3.6 kB.next/server/app/teas.segments/_full.segment.rsc 3.6 kB.next/server/app/teas.segments/_tree.segment.rsc 165 bytes.next/server/app/teas.segments/teas/__PAGE__.segment.rsc 3.1 kB○ /teas 1h 1d. In the Network tab, x-nextjs-stale-time: 300 and Cache-Control: s-maxage=3600, stale-while-revalidate=82800.
Each version only changes line 9 of lib/supplier.ts, and the route's times follow it. The last one is different: with "seconds", the build prints ◐ /teas and no times. The legend says (Partial Prerender) prerendered as static HTML with dynamic server-streamed content: the page's shell was prerendered with Loading prices… in place of the prices, and the prices are made for each request and streamed in. A lifetime that short isn't worth putting in a prerendered page. Short lifetimes and the shell gives the exact rule.
Pick a profile from how often the data really changes, and how old a visitor may see it. The docs suggest seconds for live numbers, minutes for feeds, hours for stock and weather, days for blog posts, weeks for newsletters and max for pages that almost never change. They also recommend calling cacheLife in every cached function, even when the default would do, so a reader sees the lifetime where the cache is.
stale, revalidate and expire
The three numbers act in different places. Each row is one number: where it acts, and where the price list's recording shows it for "hours".
| Number | What it decides | Where you can see it |
|---|---|---|
stale | In the browser: how long the router may show a page it already has without asking the server. | The response header x-nextjs-stale-time: 300. |
revalidate | On the server: after it, the copy is out of date. It may still be sent once while a new one is made. | The Revalidate column, 1h, and s-maxage=3600 in Cache-Control. |
expire | On the server: after it, with no request in between, the next request waits for a new copy. | The Expire column, 1d. In Cache-Control, s-maxage plus stale-while-revalidate adds up to it. |
stale: the browser's copy
When a visitor moves between pages, Next.js's router keeps the pages it fetched in the browser, in its client cache (Linking and navigating showed the router). Stale says how long it may show such a page again without asking the server. The server sends it with every page as x-nextjs-stale-time. The docs add that the router uses at least 30 seconds, so a link it prefetched is still usable when it's clicked, and that a Server Action that revalidates clears the client cache at once.
revalidate and expire: the server's copy
Revalidate and expire decide what the server may do with its copy. After revalidate, the copy is stale: out of date, but still allowed to be sent while a new one is made. For a prerendered page, that's what happens: the first request after revalidate gets the old page at once, and Next.js starts making a new one in the background. A later request gets the new page once it's ready. This is called stale-while-revalidate. Nothing happens just because time passes: the new copy is started by the first request that finds the old one stale. If no request comes in before expire, the copy can no longer be sent at all, and the next request waits while the new one is made. Revalidating cached data records both, one visit at a time.
The three numbers say what may happen: how long a copy may be reused, when it may be refreshed in the background, and when a request must wait. The cache handler that stores the copy decides whether a stale copy is still there to send. A prerendered page behaves exactly as above. A cached result kept by Next.js 16.4.3's default in-memory cache handler is simply dropped once its revalidate has passed, in production, so that request waits; that's how this version's handler is built, not a promise of the API.
Shared caches, like a CDN (a network of servers close to visitors), read the same two numbers in Cache-Control: s-maxage is revalidate, and stale-while-revalidate is how much longer an old copy may be handed out. For "hours", 3,600 plus 82,800 is 86,400 seconds: one day, the expire.
Profiles of your own
When no built-in profile fits, name your own in next.config.ts, under cacheLife. The shop's supplier sends new prices about every two hours, so the shop makes a priceList profile:
next.config.ts1import type { NextConfig } from "next";2 3const nextConfig: NextConfig = {4 cacheComponents: true,5 partialPrefetching: true,6 cacheLife: {7 priceList: { stale: 600, revalidate: 7200, expire: 172800 },8 },9};10 11export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /_not-found└ ○ /teas 2h 2d ○ (Static) prerendered as static content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html 9.1 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.3 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.4 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.4 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 3.9 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/teas.html 5.7 kB.next/server/app/teas.meta 749 bytes.next/server/app/teas.rsc 3.6 kB.next/server/app/teas.segments/_full.segment.rsc 3.6 kB.next/server/app/teas.segments/_tree.segment.rsc 165 bytes.next/server/app/teas.segments/teas/__PAGE__.segment.rsc 3.1 kBLines 6 to 8 of next.config.ts add the profile: 600 seconds of stale (10 minutes), 7,200 of revalidate (2 hours) and 172,800 of expire (2 days). Line 9 of lib/supplier.ts now says cacheLife("priceList"). The build prints ○ /teas 2h 2d, and the Network tab shows x-nextjs-stale-time: 600 and Cache-Control: s-maxage=7200, stale-while-revalidate=165600.
A few rules, each one recorded or taken from the docs:
- Leave a number out, and it comes from
default. With onlypriceList: { revalidate: 7200 }, the recorded build prints2hand1y, and the page hasx-nextjs-stale-time: 300: the default's stale and expire. - For one function, pass the numbers directly.
cacheLifealso takes an object, like this one:
export async function getPrices(): Promise<Tea[]> {
"use cache";
cacheLife({ revalidate: 60, expire: 300 });
cacheTag("price-list");
const response = await fetch(`${api}/prices`);
return response.json();
}
Line 3 gives this copy a revalidate of 60 seconds and an expire of 300: the recorded build of the price list with it prints ○ /teas 1m 5m. The numbers are seconds.
- expire must be greater than revalidate. That's the documented rule: an expire that comes first would make the copy unusable before it's even out of date. The build checks every profile in
next.config.tsbefore it starts:
next.config.ts1import type { NextConfig } from "next";2 3const nextConfig: NextConfig = {4 cacheComponents: true,5 partialPrefetching: true,6 cacheLife: {7 priceList: { revalidate: 7200, expire: 3600 },8 },9};10 11export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack) > Build error occurredError: If providing both the revalidate and expire options, the expire option must be greater than the revalidate option. The expire option indicates how many seconds from the start until it can no longer be used. at ignore-listed framesLine 7 asks for a revalidate of 2 hours and an expire of 1 hour. The build stops before it compiles anything: If providing both the revalidate and expire options, the expire option must be greater than the revalidate option. The expire option indicates how many seconds from the start until it can no longer be used. Next.js 16.4.3 only rejects an expire that is smaller, though: a profile with both at 3,600 seconds builds, prints 1h and 1h, and sends Cache-Control: s-maxage=3600 with no stale window at all. Follow the documented rule, and keep expire above revalidate.
- Profile names are checked too.
next buildwrites the type ofcacheLifefrom your config, so a name that isn't a profile is a TypeScript error, and the build stops at that line. - You can redefine a built-in profile, even
default. The docs allow it, and ask you to write it down in your project: a reader expects"days"to mean about a day. A new name is often clearer.
A cache inside a cache
Cached work often calls other cached work. Here the shop caches a whole PriceBoard component, which calls the cached getPrices. Each has its own lifetime, but the page can only have one. Before you click, guess what the first two versions print.
app/teas/page.tsx1import { cacheLife } from "next/cache";2import { getPrices } from "@/lib/supplier";3 4async function PriceBoard() {5 "use cache";6 cacheLife("days");7 const teas = await getPrices();8 return (9 <ul>10 {teas.map((tea) => (11 <li key={tea.id}>12 {tea.name}: ${tea.price}13 </li>14 ))}15 </ul>16 );17}18 19export default function TeasPage() {20 return (21 <main>22 <h1>Our teas</h1>23 <PriceBoard />24 </main>25 );26}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /_not-found└ ○ /teas 1d 1w ○ (Static) prerendered as static content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html 9.1 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.3 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.4 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.4 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 3.9 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/teas.html 5.6 kB.next/server/app/teas.meta 749 bytes.next/server/app/teas.rsc 3.5 kB.next/server/app/teas.segments/_full.segment.rsc 3.5 kB.next/server/app/teas.segments/_tree.segment.rsc 165 bytes.next/server/app/teas.segments/teas/__PAGE__.segment.rsc 3.0 kBPriceBoard calls cacheLife("days"), around a getPrices with "hours": ○ /teas 1d 1w. The outer numbers win.
The versions follow two rules:
- An outer cache that calls
cacheLifeuses its own numbers, whether they're longer or shorter than the ones inside it. That's safe: whenPriceBoard's copy is used, the inner function doesn't run at all, because the copy already holds everything it rendered. - An outer cache with no
cacheLifestarts from the default profile, and takes any shorter number from the caches inside it. Around"hours", revalidate stays at the default's15m, and expire becomes the inner1d. A longer inner lifetime never makes it longer.
cacheLife keeps its numbers, so /teas gets 1d and 1w. Right: without one, it starts from the default profile and takes any shorter number from inside: 15m from the default, 1d from the inner expire.The last version stops the build. The inner "seconds" has a short expire, which would quietly turn the whole PriceBoard into a short-lived cache. Next.js won't guess, and says so: A nested "use cache" with a short expire (under 5 minutes) is inside an outer "use cache" that has no cacheLife(). Add a cacheLife to the outer cache: a longer one keeps it in the prerendered page, and a short one, inside Suspense, makes it short on purpose.
Each row is one case: who decides the outer cache's lifetime, and what the recorded build printed.
| Case | The outer cache gets | Recorded example |
|---|---|---|
The outer cache calls cacheLife | Its own numbers, longer or shorter than the inner ones. | Outer "days", inner "hours": 1d and 1w. |
The outer cache has no cacheLife | The default profile, lowered by any shorter number from the caches inside it. | Inner "hours": 15m from the default, 1d from the inner expire. |
The outer cache has no cacheLife, and an inner one is short | The build stops, and asks you to choose with a cacheLife on the outer cache. | Inner "seconds": the nested "use cache" error. |
One more case, inside a single function: if cacheLife runs twice, the shortest of each number wins. The docs ask for one call per run; calling it in different if branches is fine, like "minutes" for a tea that isn't on sale yet and "days" for the rest.
Naming results with cacheTag
A lifetime replaces a copy on a schedule. A tag lets you replace it when you know the data changed. Here the shop also shows a daily offer on its home page, and both the prices and the offer now live in files that the supplier updates:
lib/shop.ts1import { readFile } from "node:fs/promises";2import { cacheLife, cacheTag } from "next/cache";3 4export type Tea = { id: string; name: string; price: number };5 6export async function getPrices(): Promise<Tea[]> {7 "use cache";8 cacheLife("hours");9 cacheTag("price-list");10 return JSON.parse(await readFile("data/prices.json", "utf8"));11}12 13export async function getOffer(): Promise<string> {14 "use cache";15 cacheLife("hours");16 cacheTag("daily-offer");17 return readFile("data/offer.txt", "utf8");18}GET /200
The whole response is .next/server/app/index.html, a file next build wrote, byte for byte (the Terminal tab lists it).
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=3600, stale-while-revalidate=82800Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 15 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<main>
<h1>Tea shop</h1>
<p>Today: <!-- -->Mint tea, 2 boxes for $5</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>In lib/shop.ts, getPrices reads data/prices.json and is tagged price-list on line 9. getOffer, on lines 13 to 18, reads data/offer.txt and is tagged daily-offer on line 16. The home page shows the offer, and /teas the prices.
app/api/supplier/route.ts is a webhook: an address another company's server calls when something changes on their side. Anyone can send a request to that address, so lines 5 to 10 check that this one really comes from the supplier: isSigned computes an HMAC, a code made from the exact body and the secret the shop and the supplier share, and compares it with the x-signature header without leaking timing (Route Handlers explained it line by line). Line 14 turns away a request without a valid signature. Lines 17 to 19 save the new prices and the new offer; the files stand in for the shop's database in this recording. Line 20 calls revalidateTag("price-list", { expire: 0 }): every cached result with that tag expires at once.
The recorder visited both pages, called the webhook as the supplier, then visited both again. The Network tab is open on /, after: it still says Today: Mint tea, 2 boxes for $5, although the file now holds a new offer. Pick /teas, after: Green tea: $5 and Mint tea: $4, the new prices. Only results tagged price-list expired; the offer keeps its copy until its own lifetime ends or something expires daily-offer.
price-list, so only getPrices' copy expires: /teas shows the new prices on its next visit, and / keeps the offer tagged daily-offer.A tag travels with the page. Open the Terminal tab: the build lists the files it wrote for each prerendered page, and the one that describes /teas, teas.meta, holds the text price-list, while index.meta, for /, holds daily-offer. (Those file names belong to this build, not to an API you can count on.) That's how a tag reaches every page whose cached work used it, without you listing the pages.
Several tags, and tags from data
cacheTag takes any number of names, and you can call it more than once. A tag can also come from the data, so you can expire one tea without touching the others:
export async function getTea(id: string): Promise<Tea> {
"use cache";
cacheLife("hours");
const tea: Tea = await fetch(`${api}/teas/${id}`).then((response) => response.json());
cacheTag("price-list");
cacheTag(`tea-${tea.id}`);
return tea;
}
Line 5 tags every tea's copy price-list, and line 6 adds a tag of its own, like tea-mint. Expiring tea-mint reaches only Mint tea's copy; expiring price-list reaches all of them. Tagging the same name twice does nothing more.
Next.js 16.4.3 sets two limits, in lib/constants.ts:
- Up to 128 tags in one call (
NEXT_CACHE_TAG_MAX_ITEMS), as the docs say. Stay within it. - At most 256 characters in a tag (
NEXT_CACHE_TAG_MAX_LENGTH). Build tags from short ids, never from whole addresses or search text.
Here is a price list that breaks both, built to see what happens:
lib/supplier.ts1import { cacheLife, cacheTag } from "next/cache";2 3const api = process.env.SUPPLIER_API_URL;4 5export type Tea = { id: string; name: string; price: number };6 7export async function getPrices(): Promise<Tea[]> {8 "use cache";9 cacheLife("hours");10 cacheTag(...Array.from({ length: 130 }, (_, index) => `tea-${index + 1}`));11 cacheTag(`search-${"mint ".repeat(60)}`);12 const response = await fetch(`${api}/prices`);13 return response.json();14}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Warning: exceeded max tag count for `cacheTag()`, dropped tags: tea-129, tea-130Warning: invalid tags passed to `cacheTag()`:tag: "search-mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint mint " exceeded max length of 256✓ Generating static pages (3/3) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /_not-found└ ○ /teas 1h 1d ○ (Static) prerendered as static content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html 9.1 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.3 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.4 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.4 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 3.9 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/teas.html 5.7 kB.next/server/app/teas.meta 1.7 kB.next/server/app/teas.rsc 3.6 kB.next/server/app/teas.segments/_full.segment.rsc 3.6 kB.next/server/app/teas.segments/_tree.segment.rsc 165 bytes.next/server/app/teas.segments/teas/__PAGE__.segment.rsc 3.1 kBLine 10 passes 130 tags, tea-1 to tea-130. Line 11 passes one of 307 characters. The build still succeeded, and printed two warnings. The long tag was skipped: invalid tags passed to cacheTag(), then the tag and exceeded max length of 256. For the count, it printed exceeded max tag count for cacheTag(), dropped tags: tea-129, tea-130. Yet the Terminal tab's file list says teas.meta still holds tea-129; only tea-130 is gone. In 16.4.3, validateTags in patch-fetch.ts checks the count just after it adds a tag, so it keeps one more than the limit and its warning names one tag too many. That's a quirk of this version, not something to build on: keep to 128.
Like cacheLife, cacheTag only works inside "use cache": called in a plain page, it stops the build with cacheTag() can only be called inside a "use cache" or "use cache: private" function. Expiring the results that have a tag is the job of revalidateTag and updateTag; Updating the UI after a mutation used them after a form, and Revalidating cached data compares them all.
Short lifetimes and the shell
A page's static shell is what the build prerenders and caches for it: everything that waits for nothing, and everything cached (Partial Prerendering looked inside it). A cached result with a very short lifetime isn't worth putting there: the page around it would be out of date almost at once. So Next.js leaves it out, as a hole: the shell holds the Suspense fallback, and the result is filled in for each request. Three numbers in Next.js's source decide it. Each version below is one second either side of a threshold:
lib/supplier.tsNext.jslib/supplier.ts1import { cacheLife, cacheTag } from "next/cache";2 3const api = process.env.SUPPLIER_API_URL;4 5export type Tea = { id: string; name: string; price: number };6 7export async function getPrices(): Promise<Tea[]> {8 "use cache";9 cacheLife({ revalidate: 60, expire: 300 });10 cacheTag("price-list");11 const response = await fetch(`${api}/prices`);12 return response.json();13}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /_not-found└ ○ /teas 1m 5m ○ (Static) prerendered as static content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html 9.1 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.3 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.4 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.4 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 3.9 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/teas.html 5.7 kB.next/server/app/teas.meta 749 bytes.next/server/app/teas.rsc 3.6 kB.next/server/app/teas.segments/_full.segment.rsc 3.6 kB.next/server/app/teas.segments/_tree.segment.rsc 165 bytes.next/server/app/teas.segments/teas/__PAGE__.segment.rsc 3.1 kBexpire: 300 is exactly the threshold: ○ /teas 1m 5m, and the built teas.html holds the prices.
Each row is one rule: what triggers it, what Next.js does, and what the recorded builds printed.
| When | What Next.js does | Recorded |
|---|---|---|
revalidate is 0, or expire is under 300 seconds | Left out of the prerendered page: a hole, filled for each request. | expire: 299 and revalidate: 0 print ◐ /teas; expire: 300 prints ○ /teas. |
stale is under 30 seconds | Left out of the prerendered page too: a prefetched copy would be out of date before a click. | stale: 29 prints ◐ /teas; stale: 30 prints ○ /teas. |
stale is 30 seconds or more, but under 300 | In the prerendered page, but kept out of the App Shell the router may reuse for longer. | stale: 30 prints ○ /teas, with x-nextjs-stale-time: 30. |
Suspense. A stale from 30 to 299 seconds stays in the page, but out of the App Shell. Of the built-in profiles, only "seconds" is shaded: its expire is 60 seconds.Open the Terminal tab on expire: 299. Under the route table, the files the build wrote for /teas are listed: teas.html doesn't hold Mint tea, and teas.meta holds postponed state, what Next.js keeps to finish the page when a request comes in. In the Network tab, the HTML that arrived first shows Loading prices…, and the prices follow later in the same response.
The middle rule is about the App Shell: the part of a page that's the same for every visitor, which the router prefetches for links on the screen and may reuse for a while. A result with a stale of 30 to 299 seconds is still in the prerendered page (○ /teas for stale: 30), but not in the App Shell, because the browser would keep that longer than 5 minutes. Instant navigations and partial prefetching looks at what a prefetch holds.
A hole needs a Suspense boundary above it, like any work done for each request. Here is the "seconds" version of the page without one:
app/teas/page.tsx1import { getPrices } from "@/lib/supplier";2 3async function Prices() {4 const teas = await getPrices();5 return (6 <ul>7 {teas.map((tea) => (8 <li key={tea.id}>9 {tea.name}: ${tea.price}10 </li>11 ))}12 </ul>13 );14}15 16export default function TeasPage() {17 return (18 <main>19 <h1>Our teas</h1>20 <Prices />21 </main>22 );23}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error: Route "/teas": Next.js encountered uncached or runtime data during prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. Ways to fix this: - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`) - [block] Set `export const instant = false` to allow a blocking route Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic at main (<anonymous>) at body (<anonymous>) at html (<anonymous>)To get a more detailed stack trace and pinpoint the issue, try one of the following: - Start the app in development mode by running `next dev`, then open "/teas" in your browser to investigate the error. - Rerun the production build with `next build --debug-prerender` to generate better stack traces.Error occurred prerendering page "/teas". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /teas/page: /teas, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullLine 20 of app/teas/page.tsx renders Prices with nothing around it. The build stops with the error Cache Components met for uncached data: Next.js encountered uncached or runtime data during prerendering. To Next.js, a cached result this short is request-time work. Put it back in Suspense, or give it a longer lifetime.
In the shell, or a hole?
When you choose a lifetime, you also choose where the result is sent from: the prerendered page, or a hole filled for each request. Sort these lifetimes for the price list:
cacheLife("hours")cacheLife("seconds")- No
cacheLifecall cacheLife({ revalidate: 60, expire: 120 })cacheLife({ revalidate: 60, expire: 600 })cacheLife({ revalidate: 0 })cacheLife({ stale: 10, revalidate: 900, expire: 3600 })cacheLife({ stale: 60, revalidate: 900, expire: 3600 })
Sort each cacheLife call by where the cached result ends up.
In a real shop, start from the data. Prices that change a few times a day fit "hours" or a profile of your own. A stock count that must be close to right fits a short lifetime inside Suspense, or no cache at all. Give the results that change together one tag, so a single call can expire them.
Common mix-ups
- "After revalidate, the next visit gets fresh data." No. For a prerendered page, the first visit after revalidate still gets the old copy, and starts making a new one; a later visit gets the new copy once it's ready. A result in 16.4.3's default in-memory handler is dropped instead, and that visit waits.
- "A prerendered page is made once and never changes." No. It's cached first during the build, and made again after its revalidate, when a request comes in:
○ /teas 1h 1dsays how often. - "stale is how long the server keeps the copy." No. Stale is for the browser's router. The server uses revalidate and expire.
- "No
cacheLifemeans the copy is kept forever." No. The default profile revalidates after 15 minutes. It never expires, but it's replaced after 15 minutes when a request comes in. - "The shortest lifetime always wins in nested caches." Only when the outer cache has no
cacheLife. An outer"days"around an inner"hours"gives1dand1w. - "A tag is the page's address." No. A tag names cached results. Every page that used a result with that tag is reached, whatever its address.
- "Any cached result goes in the static shell." No. A revalidate of 0, an expire under 300 seconds or a stale under 30 seconds leaves it out, as a hole that needs
Suspense.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
The price list now uses cacheLife("days"). What does the build print under Revalidate and Expire for /teas?
import { cacheLife, cacheTag } from "next/cache";
const api = process.env.SUPPLIER_API_URL;
export type Tea = { id: string; name: string; price: number };
export async function getPrices(): Promise<Tea[]> {
"use cache";
cacheLife("days");
cacheTag("price-list");
const response = await fetch(`${api}/prices`);
return response.json();
}
1d and 1w. The days profile revalidates after a day and expires after a week. The recorded build prints ○ /teas 1d 1w.
The price list uses cacheLife("priceList") with this next.config.ts. What does Cache-Control say for /teas?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
cacheLife: {
priceList: { stale: 600, revalidate: 7200, expire: 172800 },
},
};
export default nextConfig;
s-maxage=7200, stale-while-revalidate=165600. 7,200 is the revalidate, and 172,800 minus 7,200 is 165,600: together, the expire. The recording of this config shows exactly that header.
The project's next.config.ts defines the priceList profile shown in the exercise above, and the build stops at line 9 of this file. What should line 9 pass to cacheLife?
import { cacheLife, cacheTag } from "next/cache";
const api = process.env.SUPPLIER_API_URL;
export type Tea = { id: string; name: string; price: number };
export async function getPrices(): Promise<Tea[]> {
"use cache";
cacheLife("price-list");
cacheTag("price-list");
const response = await fetch(`${api}/prices`);
return response.json();
}
import { cacheLife, cacheTag } from "next/cache";
const api = process.env.SUPPLIER_API_URL;
export type Tea = { id: string; name: string; price: number };
export async function getPrices(): Promise<Tea[]> {
"use cache";
cacheLife("priceList");
cacheTag("price-list");
const response = await fetch(`${api}/prices`);
return response.json();
}
cacheLife("priceList"). price-list is the tag's name, not the profile's. The build writes cacheLife's type from the config, so a name that isn't a profile is a TypeScript error: the recorded build stops at line 9.
PriceBoard is cached with no cacheLife, and the getPrices it calls uses cacheLife("minutes"). What does the build print under Revalidate and Expire?
import { getPrices } from "@/lib/supplier";
async function PriceBoard() {
"use cache";
// No cacheLife() call.
const teas = await getPrices();
return (
<ul>
{teas.map((tea) => (
<li key={tea.id}>
{tea.name}: ${tea.price}
</li>
))}
</ul>
);
}
export default function TeasPage() {
return (
<main>
<h1>Our teas</h1>
<PriceBoard />
</main>
);
}
1m and 1h. Without its own cacheLife, the outer cache takes the shorter of each number: 1 minute beats 15 minutes, and 1 hour beats never. The recorded build prints ○ /teas 1m 1h.
The build stops at line 4 of this page. Where must cacheTag("price-list") go instead?
import { cacheTag } from "next/cache";
export default function TeasPage() {
cacheTag("price-list");
return (
<main>
<h1>Our teas</h1>
</main>
);
}
import { cacheLife, cacheTag } from "next/cache";
const api = process.env.SUPPLIER_API_URL;
export type Tea = { id: string; name: string; price: number };
export async function getPrices(): Promise<Tea[]> {
"use cache";
cacheLife("hours");
cacheTag("price-list");
const response = await fetch(`${api}/prices`);
return response.json();
}
Inside getPrices, after "use cache", as on line 10 here. A tag names one cached result, so it's called in the function whose result it names. The page then needs no cacheTag at all.
The shop wants its prices never older than two minutes, and changes line 9 to this. The page keeps its Suspense. Which symbol does the build print for /teas?
import { cacheLife, cacheTag } from "next/cache";
const api = process.env.SUPPLIER_API_URL;
export type Tea = { id: string; name: string; price: number };
export async function getPrices(): Promise<Tea[]> {
"use cache";
cacheLife({ revalidate: 60, expire: 120 });
cacheTag("price-list");
const response = await fetch(`${api}/prices`);
return response.json();
}
◐ /teas. An expire of 120 seconds is under 300, so the prices become a hole: the shell holds Loading prices…, and they're filled in for each request. The recorded build prints exactly that.
Check your understanding
For each question, ask which number decides, and where it acts.
Question 1 of 7Which numbers does
cacheLife("hours")set?Choose an answer to see the explanation.
Question 2 of 7This
getPricescalls nocacheLife. What does the build print for/teas?Read the code, then predictTypeScriptimport { cacheLife, cacheTag } from "next/cache"; const api = process.env.SUPPLIER_API_URL; export type Tea = { id: string; name: string; price: number }; export async function getPrices(): Promise<Tea[]> { "use cache"; // No cacheLife() call: the default profile. cacheTag("price-list"); const response = await fetch(`${api}/prices`); return response.json(); }Choose an answer to see the explanation.
Question 3 of 7The price list uses
cacheLife({ revalidate: 60, expire: 299 }), insideSuspense. What does the build print?Choose an answer to see the explanation.
Question 4 of 7
PriceBoardhas"use cache"andcacheLife("days"). ThegetPricesit calls hascacheLife("hours"). What does/teasget?Read the code, then predictTSXimport { cacheLife } from "next/cache"; import { getPrices } from "@/lib/supplier"; async function PriceBoard() { "use cache"; cacheLife("days"); const teas = await getPrices(); return ( <ul> {teas.map((tea) => ( <li key={tea.id}> {tea.name}: ${tea.price} </li> ))} </ul> ); } export default function TeasPage() { return ( <main> <h1>Our teas</h1> <PriceBoard /> </main> ); }Choose an answer to see the explanation.
Question 5 of 7Where does the
stalenumber act?Choose an answer to see the explanation.
Question 6 of 7The supplier's webhook calls
revalidateTag("price-list", { expire: 0 }). Which pages show new data on their next visit?Choose an answer to see the explanation.
Question 7 of 7What happens when this page calls
cacheTagoutside any cache?Read the code, then predictTSXimport { cacheTag } from "next/cache"; export default function TeasPage() { cacheTag("price-list"); return ( <main> <h1>Our teas</h1> </main> ); }Choose an answer to see the explanation.
Key takeaways
cacheLifesets three numbers: stale for the browser's copy, revalidate for when the server's copy is out of date, and expire for when it can no longer be sent.- After revalidate,
cacheLifeallows a background refresh: a prerendered page is sent once more and replaced. After expire with no request in between, the next request waits. The cache handler decides whether a stale copy is still there. - Seven profiles are built in. Without
cacheLife, the default applies: 5 minutes, 15 minutes, never. The build prints revalidate and expire next to the route. - Your own profiles live in
next.config.ts; missing numbers come fromdefault, and expire must be greater than revalidate (16.4.3 only rejects a smaller one). - An outer cache with its own
cacheLifekeeps its numbers. Without one, it takes the shorter of each number, and a short inner lifetime stops the build. cacheTagnames cached results, up to 128 tags of at most 256 characters in a call, so one call can expire every result with that name.- A revalidate of 0, an expire under 300 seconds or a stale under 30 seconds leaves a hole in the shell, which needs
Suspense.
Remember the one-liner.
cacheLife says how long a copy lasts, cacheTag says what it's called, and a very short life keeps it out of the shell.
These links point at release v16.4.3. The built-in profiles are defaultConfig.cacheLife, at lines 2346 to 2382 of config-shared.ts. cacheLife keeps the shortest of each number when it runs twice, at lines 95 to 121 of cache-life.ts, and an outer cache prefers its own numbers over the ones collected from inside it, at lines 1273 to 1289 of use-cache-wrapper.ts. The three thresholds are in use-cache/constants.ts, and the tag limits at lines 34 and 35 of lib/constants.ts. The course's last stage, Under the hood, reads them.
Coming next: Revalidating cached data. It watches a cached copy over time, and compares the ways to replace it on purpose: revalidateTag, updateTag, revalidatePath and refresh().