Skip to main content
Next.js · lesson 6 of 11
Next.js intermediate Lesson 6 of 11

cacheLife and cacheTag

Custom cacheLife profiles reshape a Next.js 16 route table's Revalidate/Expire columns; cacheTag plus revalidateTag invalidate a cached value on demand.

By SkillByExample · Updated

nextjs caching cachelife cachetag cache-components

Caching with “use cache” showed the Revalidate/Expire columns that appear the moment a route caches real data — 15m and 1y, Next.js’s own default. This lesson changes those two numbers on purpose, with both a built-in and a custom cacheLife profile, then uses cacheTag to invalidate a cached value on demand instead of waiting for either number to pass. The project setup reuses the Docker pattern from the first lesson — the node:26.13 image, the nextjs-modules volume, MSYS_NO_PATHCONV=1 on Git Bash — see it for why each flag is there.

cacheLife Needs a Name next.config.ts Knows

Six files set up the project: package.json, tsconfig.json, and app/layout.tsx are unchanged from Caching with “use cache”; app/page.tsx carries a new comment, since this lesson’s new routes are the point, not the home page; data-server.js is the same stand-in Node http server as before. next.config.ts is new — it defines one custom cacheLife profile, demo, right alongside the two flags every earlier lesson has already shown:

{
  "name": "next-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.4.3",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "20.19.43",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0"
  }
}
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  cacheLife: {
    demo: {
      revalidate: 5,
      expire: 20,
    },
  },
};

export default nextConfig;
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
  "exclude": ["node_modules"]
}
// app/layout.tsx — required; every page in the app renders inside this
export const metadata = {
  title: "Next App",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
// app/page.tsx — a plain static home page; this lesson's new routes are /profile, /ticker, and /tagged
export default function HomePage() {
  return <h1>Home</h1>;
}
// data-server.js — stands in for an external API or a database
const http = require("http");

http.createServer((req, res) => {
  res.setHeader("Content-Type", "application/json");
  res.end(JSON.stringify({ now: Date.now() }));
}).listen(4000, () => console.log("listening on 4000"));

app/profile/page.tsx calls cacheLife with a name that sounds plausible but was never configured anywhere — not a built-in name, not demo from next.config.ts above. Predict whether the build fails with a Next.js error naming the bad profile, or something else entirely, before building:

// app/profile/page.tsx — "fresh" is not a configured cacheLife profile
import { cacheLife } from "next/cache";

async function getTime() {
  "use cache";
  cacheLife("fresh");
  const res = await fetch("http://nextjs-cachelife-data:4000/time");
  return res.json();
}

export default async function ProfilePage() {
  const data = await getTime();
  return <p>Profile timestamp: {data.now}</p>;
}

The data source has to be reachable before anything can build or run against it, so this capture target starts with the same two steps every build in this lesson depends on: create a Docker network, nextjs-cachelife-net, and start data-server.js on it as its own container, nextjs-cachelife-data. A direct curl confirms it on its own, before Next.js ever touches it.

$ docker network create nextjs-cachelife-net
e831e3cf197692352b08d9ea7f76636972f6b7932f3450602e5d6d4e4478eb48
$ docker run -d --name nextjs-cachelife-data --network nextjs-cachelife-net -p 4100:4000 -v "$(pwd)":/root/next-app -w /root/next-app node:26.13 node data-server.js
343360effb14b83e9aa9b2754d42fcbdf846922a86afa402e8a9de5b939fa15f
$ until curl -s -o /dev/null http://localhost:4100/time; do sleep 0.3; done
$ curl -s http://localhost:4100/time
{"now":1793777596167}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 28 packages in 27s
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 95ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 5.6s
  Running TypeScript ...
app/profile/page.tsx(6,13): error TS2769: No overload matches this call.
  The last overload gave the following error.
    Type '"fresh"' has no properties in common with type '{ stale?: number | undefined; revalidate?: number | undefined; expire?: number | undefined; }'.
Failed to type check.

TypeScript, not Next.js’s own runtime check, is what actually catches this. Next.js always generates .next/types/cache-life.d.ts from next.config.ts’s cacheLife object — even a project with no custom profile at all gets this file, typed with every built-in name — so the generated types restrict cacheLife to exactly the names it knows (every built-in one, plus demo here) from the very first build, not only once a custom profile is defined. A string that matches none of them has no valid overload — the build fails during Running TypeScript, before Next.js ever tries to render a single route. Fix the name to a real built-in profile, minutes:

// app/profile/page.tsx — "minutes" is a built-in cacheLife profile
import { cacheLife } from "next/cache";

async function getTime() {
  "use cache";
  cacheLife("minutes");
  const res = await fetch("http://nextjs-cachelife-data:4000/time");
  return res.json();
}

export default async function ProfilePage() {
  const data = await getTime();
  return <p>Profile timestamp: {data.now}</p>;
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 108ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.9s
  Running TypeScript ...
  Finished TypeScript in 2.9s ...
  Finalizing page optimization ...

Route (app)      Revalidate  Expire
┌ ○ /
├ ○ /_not-found
└ ○ /profile             1m      1h


○  (Static)  prerendered as static content

The build succeeds, and /profile gets its own Revalidate/Expire row — 1m and 1h, the exact numbers minutes’ built-in definition sets, nothing like the 15m/1y default Caching with “use cache” showed. A named profile replaces the default outright; it doesn’t adjust it.

A Custom Profile Still Has a Floor

app/ticker/page.tsx uses the custom demo profile defined in next.config.ts at the top of this lesson — revalidate: 5, expire: 20, short enough to watch change in real time. Predict whether a route this short-lived builds at all:

// app/ticker/page.tsx — the custom "demo" profile, no Suspense yet
import { cacheLife } from "next/cache";

async function getTime() {
  "use cache";
  cacheLife("demo");
  const res = await fetch("http://nextjs-cachelife-data:4000/time");
  return res.json();
}

export default async function TickerPage() {
  const data = await getTime();
  return <p>Ticker timestamp: {data.now}</p>;
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 86ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.5s
  Running TypeScript ...
  Finished TypeScript in 2.7s ...
Error: Route "/ticker": 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 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 "/ticker" 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 "/ticker". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /ticker/page: /ticker, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null

The build fails with the exact same generic error Dynamic by Default: Rendering Modes and Caching with “use cache” both showed for an uncached read — even though this function is wrapped in "use cache". The installed next package enforces a floor on its own, confirmed by reading its source: any cache entry whose expire is under five minutes can’t be baked into a static build at all, no matter how it’s configured. demo’s expire: 20 is nowhere close. The fix is the same [stream] option Caching with “use cache” used for /live — wrap the read in <Suspense> instead of asking it to be fully static:

// app/ticker/page.tsx — "demo" works once the read is behind Suspense
import { Suspense } from "react";
import { cacheLife } from "next/cache";

async function getTime() {
  "use cache";
  cacheLife("demo");
  const res = await fetch("http://nextjs-cachelife-data:4000/time");
  return res.json();
}

async function Ticker() {
  const data = await getTime();
  return <p>Ticker timestamp: {data.now}</p>;
}

export default function TickerPage() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Ticker />
    </Suspense>
  );
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 90ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.6s
  Running TypeScript ...
  Finished TypeScript in 2.3s ...
  Finalizing page optimization ...

Route (app)      Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ○ /profile             1m      1h
└ ◐ /ticker


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

/ticker builds now, marked ◐ with no Revalidate/Expire columns of its own — same as /live in Caching with “use cache”. There’s nothing to show in the route table because nothing was baked into the static build; demo’s numbers only govern what happens once the app is actually serving requests, which is what the next section checks for real.

Time-Based Revalidation, Observed for Real

Start the app and request /ticker three times — once, again two seconds later, and again eight seconds after that — predicting demo’s revalidate: 5 draws the line between the first pair and the second:

$ docker run -d --name nextjs-cachelife-1 --network nextjs-cachelife-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
00eeeb3ea552dd6747add18a63b803fb9a5e265d722722b74f25dd92e949f95c
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ a=$(curl -s http://localhost:3100/ticker | grep -oE '<p>Ticker timestamp: [^/]+/p>'); echo "$a"
<p>Ticker timestamp: <!-- -->1793777688249</p>
$ sleep 2; b=$(curl -s http://localhost:3100/ticker | grep -oE '<p>Ticker timestamp: [^/]+/p>'); echo "$b"
<p>Ticker timestamp: <!-- -->1793777688249</p>
$ [ "$a" = "$b" ] && echo SAME
SAME
$ sleep 8; c=$(curl -s http://localhost:3100/ticker | grep -oE '<p>Ticker timestamp: [^/]+/p>'); echo "$c"
<p>Ticker timestamp: <!-- -->1793777699438</p>
$ [ "$a" != "$c" ] && echo DIFFERENT
DIFFERENT
$ docker rm -f nextjs-cachelife-1
nextjs-cachelife-1

$a and $b, two seconds apart, print SAME — well inside the five-second revalidate window, so the same baked value answers both. $c, ten seconds after $a, prints DIFFERENT — past the window, the next request gets a freshly computed value instead of the stale one. demo’s expire: 20 never even comes into play here: with a request landing every few seconds, revalidate keeps refreshing the entry long before expire’s “no traffic at all” clock could ever run out.

cacheTag and On-Demand Invalidation

app/tagged/page.tsx adds one more call next to cacheLife: cacheTag("time"), which labels this cached value so it can be invalidated by name later, independent of minutes’ own one-minute revalidate window. app/api/invalidate/route.ts is a Route Handler meant to trigger that invalidation — its first version reaches for updateTag, which Next.js 16.4.3 also exports from next/cache:

// app/tagged/page.tsx — cacheTag labels this value for later on-demand invalidation
import { cacheLife, cacheTag } from "next/cache";

async function getTime() {
  "use cache";
  cacheLife("minutes");
  cacheTag("time");
  const res = await fetch("http://nextjs-cachelife-data:4000/time");
  return res.json();
}

export default async function TaggedPage() {
  const data = await getTime();
  return <p>Tagged timestamp: {data.now}</p>;
}
// app/api/invalidate/route.ts — updateTag, called from a Route Handler
import { updateTag } from "next/cache";
import { NextResponse } from "next/server";

export async function POST() {
  updateTag("time");
  return NextResponse.json({ invalidated: true });
}

Predict whether POSTing this route actually invalidates /tagged, before building and running it:

$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 78ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.7s
  Running TypeScript ...
  Finished TypeScript in 3.2s ...
  Finalizing page optimization ...

Route (app)          Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/invalidate
├ ○ /profile                 1m      1h
├ ○ /tagged                  1m      1h
└ ◐ /ticker


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
$ docker run -d --name nextjs-cachelife-2 --network nextjs-cachelife-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
3a90ab6a36e96f22de5a6877a05deca6863f87731d4b027279dfa9ef0d679d71
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3100/api/invalidate
HTTP/1.1 500 Internal Server Error
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
Date: Wed, 04 Nov 2026 07:35:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
$ docker logs nextjs-cachelife-2 2>&1 | tail -3
⨯ Error: updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag
    at b (.next/server/chunks/[root-of-the-server]__0lyk55rbj4nd-._.js:74:10745)
    at n (.next/server/chunks/[root-of-the-server]__0lyk55rbj4nd-._.js:1:98)
$ docker rm -f nextjs-cachelife-2
nextjs-cachelife-2

The build itself succeeds — next build never calls a dynamic Route Handler’s function, so there’s nothing to fail yet. The POST request is a different story: a bare 500, no body, exactly like Route Handlers showed for an uncaught exception. The server’s own log names the real reason: updateTag only works inside a Server Action, not a Route Handler. Swap it for revalidateTag, which does work here. Its required second argument is a cacheLife profile name that decides what happens next: "max" is Next.js’s own stale-while-revalidate profile — the very next request to a tagged route still gets served the last cached value, and that same request is what triggers Next.js to recompute it in the background, so only the request after that one sees the fresh value:

// app/api/invalidate/route.ts — revalidateTag, called from a Route Handler
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";

export async function POST() {
  revalidateTag("time", "max");
  return NextResponse.json({ invalidated: true });
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 93ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.6s
  Running TypeScript ...
  Finished TypeScript in 2.6s ...
  Finalizing page optimization ...

Route (app)          Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/invalidate
├ ○ /profile                 1m      1h
├ ○ /tagged                  1m      1h
└ ◐ /ticker


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
$ docker run -d --name nextjs-cachelife-3 --network nextjs-cachelife-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
73774a6211c1b9c507ac51e31522514fafbab474a05cb5bab9bc108abfcdec5a
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ p=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$p"
<p>Tagged timestamp: <!-- -->1793777652016</p>
$ q=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$q"
<p>Tagged timestamp: <!-- -->1793777652016</p>
$ [ "$p" = "$q" ] && echo SAME
SAME
$ curl -s -X POST http://localhost:3100/api/invalidate; echo
{"invalidated":true}
$ r=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$r"
<p>Tagged timestamp: <!-- -->1793777652016</p>
$ [ "$p" = "$r" ] && echo SAME
SAME
$ sleep 2; s=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$s"
<p>Tagged timestamp: <!-- -->1793777751524</p>
$ [ "$p" != "$s" ] && echo DIFFERENT
DIFFERENT
$ docker rm -f nextjs-cachelife-3
nextjs-cachelife-3

$p and $q, requested back to back with no invalidation at all, print SAME — minutes’ one-minute revalidate window is nowhere close to elapsing on its own. After the POST, $r — requested immediately — still matches $p: that’s "max"’s stale-while-revalidate behavior specifically, not something invalidation does on its own. Marking the tag stale doesn’t recompute anything by itself; it’s $r’s own request that triggers the recomputation, and $r itself is still served the old, stale value while that recompute runs in the background. Only $s, requested two seconds later once that background recompute has actually finished, prints DIFFERENT. cacheTag and revalidateTag invalidate on demand, by name, independent of whatever revalidate/expire numbers the cached function’s own profile set — but with the "max" profile, “on demand” still means “starting next request,” not “instantly.” The FAQ below covers the other profile shape, { expire: 0 }, which does expire immediately instead.

Practice

Each task below builds on the files the previous ones (and the main lesson above) left in place — do them in order. The last task also removes the data source and network this lesson created.

1. Add cacheTag("time") to /profile too, so it shares the same tag /tagged already uses. Confirm that a single POST to /api/invalidate changes both routes’ values, not just /tagged’s.

Solution
// app/profile/page.tsx — now shares the "time" tag with /tagged
import { cacheLife, cacheTag } from "next/cache";

async function getTime() {
  "use cache";
  cacheLife("minutes");
  cacheTag("time");
  const res = await fetch("http://nextjs-cachelife-data:4000/time");
  return res.json();
}

export default async function ProfilePage() {
  const data = await getTime();
  return <p>Profile timestamp: {data.now}</p>;
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 89ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.9s
  Running TypeScript ...
  Finished TypeScript in 2.1s ...
  Finalizing page optimization ...

Route (app)          Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/invalidate
├ ○ /profile                 1m      1h
├ ○ /tagged                  1m      1h
└ ◐ /ticker


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
$ docker run -d --name nextjs-cachelife-4 --network nextjs-cachelife-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
eee4dd9995a9a1badec05c1c1bb34fcdf4da5eb510a4b460629d9930677617ef
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ p1=$(curl -s http://localhost:3100/profile | grep -oE '<p>Profile timestamp: [^/]+/p>'); echo "$p1"
<p>Profile timestamp: <!-- -->1793777652016</p>
$ t1=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$t1"
<p>Tagged timestamp: <!-- -->1793777652016</p>
$ curl -s -X POST http://localhost:3100/api/invalidate; echo
{"invalidated":true}
$ curl -s http://localhost:3100/profile >/dev/null; curl -s http://localhost:3100/tagged >/dev/null
$ sleep 2
$ p2=$(curl -s http://localhost:3100/profile | grep -oE '<p>Profile timestamp: [^/]+/p>'); echo "$p2"
<p>Profile timestamp: <!-- -->1793777778625</p>
$ t2=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$t2"
<p>Tagged timestamp: <!-- -->1793777778770</p>
$ [ "$p1" != "$p2" ] && echo "profile DIFFERENT"
profile DIFFERENT
$ [ "$t1" != "$t2" ] && echo "tagged DIFFERENT"
tagged DIFFERENT
$ docker rm -f nextjs-cachelife-4
nextjs-cachelife-4

$p1 and $t1 start out identical, even though they’re two different routes’ own cached functions: both fetch the exact same URL, http://nextjs-cachelife-data:4000/time, during the same build, and Caching with “use cache” already showed Next.js caching the inner fetch() call by URL — so /profile and /tagged share one fetch result, not two separate ones. The throwaway requests right after the POST (curl ... >/dev/null, discarding their bodies) and the sleep 2 that follows aren’t idle padding: with "max"’s stale-while-revalidate behavior, it takes a request to actually trigger the background recompute, and that recompute needs a moment to finish before a later request can see it — the two discarded curls are what trigger /profile’s and /tagged’s recomputes, and the sleep gives both time to finish before $p2/$t2 ask again. Both then print DIFFERENT. One revalidateTag("time", "max") call invalidates every cache entry carrying that tag, regardless of which route or which function originally computed it — cacheTag groups entries by label, not by route.

2. Call revalidateTag with only its first argument, the way its own deprecation message suggests was once allowed, and predict whether that produces the runtime warning the message describes or something else.

Solution
// app/api/invalidate/route.ts — revalidateTag with only one argument
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";

export async function POST() {
  revalidateTag("time");
  return NextResponse.json({ invalidated: true });
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 104ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.4s
  Running TypeScript ...
app/api/invalidate/route.ts(6,3): error TS2554: Expected 2 arguments, but got 1.
Failed to type check.

No warning ever prints, because the build never gets far enough to run it: revalidateTag’s TypeScript signature requires the second argument, so leaving it out fails type checking with error TS2554: Expected 2 arguments, but got 1. The runtime deprecation warning in Next.js’s own source is real, but a TypeScript project can never actually trigger it — exactly like cacheLife’s unknown-profile check earlier in this lesson, the compile-time type and the runtime check both exist, and the stricter one always wins first.

3. Replace /profile’s cacheLife("minutes") with an inline profile object, cacheLife({ revalidate: 30, expire: 600 }) — no change to next.config.ts needed — and confirm the route table reflects the new numbers directly.

Solution
// app/api/invalidate/route.ts — back to the correct two-argument call
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";

export async function POST() {
  revalidateTag("time", "max");
  return NextResponse.json({ invalidated: true });
}
// app/profile/page.tsx — an inline cacheLife profile instead of a named one
import { cacheLife, cacheTag } from "next/cache";

async function getTime() {
  "use cache";
  cacheLife({ revalidate: 30, expire: 600 });
  cacheTag("time");
  const res = await fetch("http://nextjs-cachelife-data:4000/time");
  return res.json();
}

export default async function ProfilePage() {
  const data = await getTime();
  return <p>Profile timestamp: {data.now}</p>;
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 75ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.7s
  Running TypeScript ...
  Finished TypeScript in 3.0s ...
  Finalizing page optimization ...

Route (app)          Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/invalidate
├ ○ /profile                30s     10m
├ ○ /tagged                  1m      1h
└ ◐ /ticker


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

/profile’s row now reads 30s and 10m — exactly the two numbers passed inline, with no entry in next.config.ts at all. A name is only a shortcut to a profile object defined once and reused; the object form works anywhere a name does, as long as its expire still clears the five-minute floor from earlier in this lesson.

4. Remove the <Suspense> wrapper from /ticker, keeping cacheLife("demo"), and confirm it fails the exact same way it did before Suspense was added the first time. Then remove the data source and the network this lesson created.

Solution
// app/ticker/page.tsx — Suspense removed again, "demo" is still too short to prerender
import { cacheLife } from "next/cache";

async function getTime() {
  "use cache";
  cacheLife("demo");
  const res = await fetch("http://nextjs-cachelife-data:4000/time");
  return res.json();
}

export default async function TickerPage() {
  const data = await getTime();
  return <p>Ticker timestamp: {data.now}</p>;
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 95ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.8s
  Running TypeScript ...
  Finished TypeScript in 2.8s ...
Error: Route "/ticker": 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 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 "/ticker" 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 "/ticker". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /ticker/page: /ticker, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
$ docker rm -f nextjs-cachelife-data
nextjs-cachelife-data
$ docker network rm nextjs-cachelife-net
nextjs-cachelife-net

Same generic “encountered uncached or runtime data” error as the very first time this lesson tried it — the five-minute floor doesn’t care whether the short profile is new or already proven to work behind <Suspense>; remove the boundary and the floor applies all over again. The last two steps remove the data source and the network this lesson created.

Frequently Asked Questions

Why did an unconfigured cacheLife profile name fail at compile time instead of the runtime error Next.js's own source defines for it?
Next.js always generates a TypeScript module augmentation, .next/types/cache-life.d.ts, from next.config.ts's own cacheLife object — even an empty or absent one — and it always adds one function overload per built-in profile name (seconds, minutes, hours, days, weeks, max, default), plus one more for every custom profile a project defines. Once that file exists, which is every Cache Components TypeScript project after its first build, cacheLife()'s fallback "any string" overload is typed as never, so TypeScript's own type checker rejects an unknown literal whether or not next.config.ts defines any custom profile at all. The runtime check in next/dist/server/use-cache/cache-life.js ("Unknown cacheLife() profile...") still exists, but a plain string literal in a TypeScript project never reaches it — only a dynamically computed profile name, which TypeScript can't narrow to a literal, would get that far.
Why did a route using "use cache" with a 20-second cacheLife profile still fail the build without Suspense?
The installed next package enforces an internal five-minute floor (MIN_PRERENDERABLE_EXPIRE, confirmed in node_modules/next/dist/server/use-cache/constants.js) on any cache entry a route tries to bake into a static build. A profile whose expire is under that floor can't be prerendered at all — Next.js falls back to the same generic "encountered uncached or runtime data" error [Dynamic by Default: Rendering Modes](/nextjs/rendering-modes/) first showed for connection(). The same source file's check is actually `revalidate === 0 || expire < MIN_PRERENDERABLE_EXPIRE` (confirmed in node_modules/next/dist/server/use-cache/use-cache-wrapper.js), so a profile with `revalidate: 0` fails the same way even if its own `expire` is set well above five minutes. Wrapping the same fetch in <Suspense> sidesteps both checks entirely, because the value is then computed per-request behind a streamed hole, never baked into the static shell.
Why didn't calling updateTag() in a Route Handler fail the build the way an unknown cacheLife profile did?
A Route Handler's own code only runs once a real request actually arrives — next build never calls a dynamic (ƒ) route's handler function during prerendering, because there's no static output to generate for it. updateTag()'s restriction (Server Actions only) is a request-time check, so the build itself succeeds; the error only ever surfaces in the server's own log, and as a bare 500 to the client, the moment something actually POSTs to the route.
Does revalidateTag() finish invalidating a cache entry by the time it returns?
It depends on the required second argument, a cacheLife profile. With "max" — Next.js's own stale-while-revalidate profile, and the one this lesson uses — no: revalidateTag() marks the tag stale and returns immediately, but the actual recompute only starts once the next request for that tag arrives, and that very request is still served the old, stale value while the recompute runs in the background; only a later request, once the recompute has finished, sees the new one. This lesson's own curl sequence shows exactly that: a request made immediately after invalidation still matching the pre-invalidation value, with only a request after a short wait seeing the change. A profile like { expire: 0 } instead expires the entry outright, so the very next request already computes a fresh value with no stale read first.
What's the difference between revalidateTag() and updateTag()?
Both take a cache tag set with cacheTag() and mark every cache entry carrying it as stale. revalidateTag() works from a Server Action or a Route Handler, but not from inside render — calling it while a component renders throws ("can't be called during render"), the same family of restriction updateTag() has for Server Actions specifically. It requires a second argument, a cacheLife profile name; in a TypeScript project, leaving it out fails type checking with TS2554 (Practice 2 shows this), not the runtime deprecation warning Next.js's own source logs for a plain JavaScript caller. That required profile decides how soon the new value appears: "max" is eventually consistent, serving one more stale response before a later request sees the change, while { expire: 0 } expires the entry immediately instead. updateTag() only works inside a Server Action, needs no second argument, and is designed for "read your own writes": the Server Action's own caller sees the new value immediately, without either of revalidateTag()'s profile-dependent delays.