Vercel's usage page told me this blog had used 2 h 32 min of function CPU in 30 days: 80% of everything on my account. It's a blog. The posts only change when I deploy.
The cause was one line near the top of my pages:
const stats = await getPostStats(); // read + comment counts, cached for 5 minutes
The counts were cached for 5 minutes. But the 5-minute timer didn't stay on the counts. Every page that showed them took it on, so every post, tag and series page went stale every 5 minutes and was rebuilt on the server the next time a reader or a crawler came by.
The mental model: a shared fridge#
Think of the server cache as one fridge that every visitor shares.
"use cache"puts a result in the fridge. The next caller takes it out instead of cooking it again.cacheLifeis the best-before sticker. After that date, the next visitor still gets the old dish while a fresh one cooks in the background.cacheTagis the name on the lid. You can throw out every box labelledcommentsat once, the moment the comments change.
A page is only as fresh as the shortest best-before date inside it.
How it works#
"use cache": store the result#
async function getPost(slug: string) {
"use cache";
return db.post.findUnique({ where: { slug } });
}
The arguments are part of the key, so getPost("a") and getPost("b") are separate boxes in the fridge. You can put "use cache" on a component too, and its rendered output is stored.
One rule: you can't read cookies() or headers() inside. Read them outside and pass the value in as an argument.
cacheLife: when it goes stale#
async function getExchangeRates() {
"use cache";
cacheLife("hours"); // or "minutes", "days", "max", or your own profile
return fetch("https://api.example.com/rates").then((r) => r.json());
}
After the time is up, the next request still gets the old result straight away, and Next.js refreshes it in the background. Nobody waits.
The trap from my hook: a page's prerendered HTML is a cached thing too. If something inside it has a 5-minute life, the whole page gets a 5-minute life. That's how one counter set the clock for every page.
cacheTag: throw it out on demand#
async function getComments(slug: string) {
"use cache";
cacheLife("max"); // no timer...
cacheTag("comments", `comments:${slug}`); // ...just a name
return db.comment.findMany({ where: { slug, approved: true } });
}
// when a comment is approved:
revalidateTag("comments", "max"); // route handler: serve old, refresh in background
updateTag(`comments:${slug}`); // server action: the next request waits for fresh data
A timer is a guess about when data changes. A tag means you refresh exactly when it changes. My comment list used to sit on the 5-minute timer. Now it's "max" plus a tag, and new comments still show up straight away.
Which one to use#
| Your data | Use |
|---|---|
| Changes only when you deploy (posts, docs) | Nothing. It's static already |
| Changes when you change it (CMS, comments, products) | "use cache" + cacheLife("max") + cacheTag, invalidate on write |
| Changes on its own, slowly (rates, star counts) | "use cache" + cacheLife("hours") |
| Changes constantly and shows on every page (view counts) | A small cached API route, fetched in the browser |
| Belongs to one user (cart, dashboard) | Not the shared fridge. Fetch it per user |
Where TanStack Query fits#
They solve different problems, so you use both:
- Server cache (
use cache) answers: "Don't hit the database for every visitor." One fridge, shared by everyone. - TanStack Query answers: "Don't refetch for this visitor every time a component mounts." It's each visitor's own lunchbox in browser memory, with refetch on focus, polling, retries and optimistic updates.
The pattern I ended up with: render the page from server-cached data, and load live or per-user bits in the browser from a route handler that is itself cached.
// app/api/stats/route.ts — one shared, cached answer
export async function GET() {
return Response.json(await getPostStats()); // getPostStats uses "use cache"
}
// in the browser — one request per visitor, shared by every component
const { data } = useQuery({
queryKey: ["stats"],
queryFn: () => fetch("/api/stats").then((r) => r.json()),
staleTime: 30 * 60_000, // match the server's refresh time
});
When the user changes something, clear both layers: updateTag in the server action, then queryClient.invalidateQueries({ queryKey: [...] }) in the browser.
Honest stakes: after moving the counts into the browser, the build output for my post, tag and series pages went from refresh every 5m to refresh every 1d. Only /api/stats still refreshes on a timer. Most apps won't hit this exact bug, but any value read high up in a page can quietly set the clock for the whole page.
Say it in an interview#
"
use cachemarks a function or component whose result Next.js stores and reuses, keyed by its arguments.cacheLifesets how long it stays fresh and when it's refreshed in the background, and a page can't stay cached longer than the shortest life inside it.cacheTagnames entries so I can invalidate them when the data changes, withrevalidateTagorupdateTag. TanStack Query is a separate, per-user cache in the browser: I use the server cache for shared data and TanStack Query for live or per-user data."
Cheat sheet#
| Tool | One line |
|---|---|
"use cache" | Store this result; arguments are the key |
cacheLife | Best-before: stale → refresh in the background |
cacheTag + revalidateTag | Throw out by name, serve old while refreshing |
cacheTag + updateTag | Throw out by name, next request waits (server actions only) |
| TanStack Query | Per-visitor browser cache on top |
TL;DR#
"use cache"stores,cacheLifesets when it goes stale,cacheTaglets you clear it on demand.- A page is only as fresh as the shortest-lived cache inside it. One 5-minute counter made all my pages refresh every 5 minutes.
- Prefer "cache forever + tag" for data you control, timers for data you don't.
- Use TanStack Query for per-user and live data, ideally fetching from a cached route.
Advanced · optional
Going deeper#
Everything above is enough to explain this in an interview. This part is for the curious: full numbers, edge cases and what's still unproven.
The three numbers behind cacheLife#
Every profile is three numbers:
| Number | Meaning |
|---|---|
stale | How long the browser may reuse it without asking the server |
revalidate | After this, the next request triggers a background refresh |
expire | After this with no traffic at all, the next request waits for a fresh result |
The built-in profiles (Next.js 16.4):
| Profile | stale | revalidate | expire |
|---|---|---|---|
default | 5 min | 15 min | never |
seconds | 30 s | 1 s | 1 min |
minutes | 5 min | 1 min | 1 h |
hours | 5 min | 1 h | 1 day |
days | 5 min | 1 day | 1 week |
weeks | 5 min | 1 week | 30 days |
max | 5 min | 30 days | 1 year |
You can define your own in next.config under cacheLife. Mine was stats: { stale: 300, revalidate: 300, expire: 86_400 }.
Too short to prerender#
Very short lives don't get baked into the static page at all:
revalidate: 0orexpireunder 5 minutes: left out of the prerender and computed on every request (a "dynamic hole").staleunder 30 seconds: also left out of the prerender.
My 5-minute profile sat just above that line, so it was baked in and set the clock for the whole page.
Nested caches#
If an outer "use cache" calls cacheLife itself, its own lifetime wins. If it doesn't, it starts from default (15 min) and shrinks to match any shorter cache inside it. A longer inner cache never makes it longer. Always set cacheLife explicitly so you can read the lifetime at the call site.
revalidateTag vs updateTag#
revalidateTag(tag, "max") | updateTag(tag) | |
|---|---|---|
| Where | Server actions and route handlers | Server actions only |
| Next request gets | The old result while it refreshes | Fresh data (it waits) |
| Good for | Webhooks, admin tools, CMS updates | "I just saved it, show me my change" |
Calling revalidateTag doesn't rebuild anything by itself. It marks entries stale, and each page refreshes when someone next visits it.
Where the fridge lives#
Plain "use cache" keeps entries in memory on each server instance. On serverless platforms instances come and go, so expect some misses. "use cache: remote" stores entries in a shared cache that all instances can read, at the cost of a network round-trip and usually a platform fee. "use cache: private" is for results that depend on cookies or headers and must never be shared between users.
Measuring it yourself#
Run next build and read the Revalidate column in the route table. Before my fix, /p/javascript-this-explained showed 5m. After it, 1d (that last day comes from a cached copyright year in the footer).
Further reading#
use cache,cacheLifeandcacheTagin the Next.js docs- TanStack Query: Advanced Server Rendering


Start the discussion
Questions, corrections, a better approach? I read and reply to every comment.