Skip to content
Chapter 72Lesson 2

Picking the right invalidation call

A decision tree for choosing among Next.js's four cache invalidation calls, updateTag, revalidateTag, revalidatePath, and router.refresh, at any mutation site.

In the previous lesson you tagged every cached read in the app. Each one now carries a string from lib/tags.ts that names exactly what it holds: invoiceTags.list(orgId) for a list, invoiceTags.record(orgId, id) for a single record. That was the read side. This lesson is the write side. A mutation just landed, and something has to tell the cache that one of those reads is now wrong.

The mutation could come from anywhere you’ve already built. A user edits an invoice through a Server Action. Stripe fires a webhook that flips a plan, the pattern from the billing chapters. A nightly job rebuilds a summary, the background work from the Trigger.dev chapters. Each of those is a place where data changes, and each one needs to invalidate the cache, but the call you reach for is not the same in all three.

Next.js gives you four invalidation calls, and picking between them trips up most people for longer than it should. By the end of this lesson you’ll run a two-question decision on any mutation site and land on the right call without guessing. The four calls themselves are one-liners. The skill is the order of questions that makes the answer obvious.

Start with the vocabulary, before the reasoning. Here are the four calls, each with the one situation it’s built for. Don’t memorize the table; read it once so the names mean something when the decision tree resolves to them later.

updateTag(tag)

Server Actions only. Expires the entry immediately, so the next read blocks and refetches. Use it when the user who triggered the change is about to see it. Takes a tag and nothing else.

revalidateTag(tag, profile)

Works anywhere on the server: actions, route handlers, background jobs. Marks the entry stale, so the next visitor to a page using that tag is served the stale value once, then it refreshes. profile is a cacheLife preset, and 'max' is the default. The single-argument form is a type error in Next.js 16.

revalidatePath(path)

Invalidates by URL path or route pattern instead of by tag, which is coarser than a tag. Use it as the escape hatch when the affected surface has no clean tag to name it.

router.refresh()

Client-side, from useRouter(). Re-runs the current route’s server render in the browser. It does not invalidate cached reads; it only re-renders the route. Use it for a fresh render after a non-action interaction on the client.

Slow down on the fourth card, because it hides the most common misconception in this whole topic.

Two words from those cards do a lot of work, so let’s pin them down. The behavior revalidateTag produces is stale-while-revalidate : the cache hands out the old value one last time and refreshes underneath. The behavior updateTag produces is read-your-writes : the user who just made the change sees it immediately, with no stale value in between. The whole decision turns on one question: does the next reader see one stale render, or not?

Four calls sounds like four things to remember, but it isn’t. The four are the product of two yes-or-no questions, and once you see the questions, the calls stop being a list and become coordinates.

Axis one is the one that matters: read-your-writes or eventual. Phrase it as a question about a human: is a specific person sitting on the screen right now, expecting to see this exact change?

When you edit your own profile and the form redirects, you are staring at the result, expecting your new name. When you post a comment, you expect it in the thread. When you upgrade your plan, you expect “Pro” on the next screen. Someone is watching, so this is read-your-writes, and it means updateTag or router.refresh().

Now flip it. A Stripe webhook flips an invoice to paid, and nobody is sitting on a screen waiting for that specific HTTP request to finish. A nightly job rebuilds a summary at 3am, with no audience. An admin in one org touches shared reference data that a hundred other users will read tomorrow, and none of them is watching this write land. That’s eventual, and it means revalidateTag or revalidatePath. The next person to load the page sees one stale render, then it’s fresh, and that’s completely fine, because nobody was waiting.

Most of your decisions end right here, on axis one. Roughly four out of five mutation sites never need the second question.

Axis two is the tiebreaker: tag or path. A tag names one entity’s reads precisely; invoiceTags.record(orgId, id) is exactly the reads for that one invoice. A path names a whole route or subtree, coarsely. In a codebase with a real lib/tags.ts, the answer is almost always “tag”, since that’s the entire point of having built the scheme. Path is the rare fallback for when no tag can name the surface that changed.

Put the two axes on a grid and the four calls fall into the four corners. Each call is just “which row, which column.”

The four calls are two binary choices, not a flat list to memorize.

The headline rule is this: axis one decides the call, and you only reach for axis two to break the tag-versus-path tie. A shortcut often answers axis one before you even finish asking it, and the shortcut is where am I? A webhook is never read-your-writes for a specific watcher, because no specific person triggered it interactively. So the moment you notice you’re in a webhook, you already know it’s revalidateTag. Where you are is not a constraint fighting you; it’s a signal that has already narrowed the choice.

That “where am I” signal is so reliable that it deserves its own section, because one of the four calls enforces it at the language level.

updateTag throws if you call it outside a Server Action. The first time you hit that, it reads like an arbitrary restriction. It isn’t: the API is telling you something true about where you are.

Read-your-writes is a promise that the user will see fresh data on the very next render, with no stale flash. To keep that promise, the framework has to sequence three things inside one request: mutate the data, expire the tag, then render fresh. A Server Action is the one place that path exists. The action mutates, calls updateTag, and then redirect()s. The redirect’s render happens in the same request, after the tag was expired, so it reads fresh data. That tight loop is the in-band redirect .

A route handler, a background job, and a client callback don’t own a redirect the framework controls in that way. So updateTag can’t keep its promise there, and rather than keep it badly, it throws. When you see that throw, don’t reach for a workaround. Read what it’s saying: read-your-writes isn’t physically available here, so you don’t want it; you want eventual. Switch to revalidateTag.

The two snippets below are the same invalidation in two places. The left one works; the right one throws, with the fix sitting right under it.

app/invoices/[id]/actions.ts
'use server';
import { updateTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
export const updateInvoice = authedAction(async ({ orgId }, input) => {
// ...validate, then write the row inside a transaction...
updateTag(invoiceTags.record(orgId, input.id));
redirect(`/invoices/${input.id}`);
});

Read-your-writes, in band. Inside an action, updateTag expires the tag, and the redirect that follows renders fresh.

Both updateTag and revalidateTag import from next/cache. With that boundary understood, the rest of the lesson is running the two axes on real cases, starting with the simplest one.

A user opens an invoice’s detail page, edits the amount, and saves. The Server Action writes the new amount and redirects to the list. Two cached reads are now wrong: the list read, tagged invoiceTags.list(orgId), still shows the old amount, and the detail read, tagged invoiceTags.record(orgId, id), does too.

Run the axes. We’re in a Server Action, and the editor is the viewer: they’re about to land on the list expecting their new number. That’s read-your-writes with tags, so it’s updateTag, fired after the write and before the redirect.

Notice we fire it twice, once per affected read. This is the fan-out, the discipline that separates a cache that works from one that goes silently stale. One mutation touched two cached reads, so two updateTag calls: the list, so the new amount shows, and the record, so a back-button to the detail page is fresh too. Because both tag strings come from the same lib/tags.ts helper, listing them is mechanical. You’re not inventing strings, you’re naming reads.

Step through the action body below. A Server Action has five seams (parse, authorize, mutate, invalidate, redirect), and watch how they put the invalidation in exactly one place: after the write, before the redirect.

'use server';
import { redirect } from 'next/navigation';
import { updateTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
import { updateInvoiceSchema } from './schema';
export async function updateInvoice(formData: FormData) {
const input = updateInvoiceSchema.parse(Object.fromEntries(formData));
const { orgId } = await requireOrgUser();
await tenantDb(orgId)
.update(invoices)
.set({ amount: input.amount })
.where(eq(invoices.id, input.id));
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, input.id));
redirect(`/invoices/${input.id}`);
}

The 'use server' directive and the action signature. This is what makes updateTag legal here at all; outside an action it would throw.

'use server';
import { redirect } from 'next/navigation';
import { updateTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
import { updateInvoiceSchema } from './schema';
export async function updateInvoice(formData: FormData) {
const input = updateInvoiceSchema.parse(Object.fromEntries(formData));
const { orgId } = await requireOrgUser();
await tenantDb(orgId)
.update(invoices)
.set({ amount: input.amount })
.where(eq(invoices.id, input.id));
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, input.id));
redirect(`/invoices/${input.id}`);
}

Parse the input, authorize, then write the row. Nothing about the cache yet; this is just the change landing in the database.

'use server';
import { redirect } from 'next/navigation';
import { updateTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
import { updateInvoiceSchema } from './schema';
export async function updateInvoice(formData: FormData) {
const input = updateInvoiceSchema.parse(Object.fromEntries(formData));
const { orgId } = await requireOrgUser();
await tenantDb(orgId)
.update(invoices)
.set({ amount: input.amount })
.where(eq(invoices.id, input.id));
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, input.id));
redirect(`/invoices/${input.id}`);
}

After the write and before the redirect: one updateTag per affected cached read. The list so the new amount shows, the record so the detail page is fresh too. This is the fan-out.

'use server';
import { redirect } from 'next/navigation';
import { updateTag } from 'next/cache';
import { invoiceTags } from '@/lib/tags';
import { updateInvoiceSchema } from './schema';
export async function updateInvoice(formData: FormData) {
const input = updateInvoiceSchema.parse(Object.fromEntries(formData));
const { orgId } = await requireOrgUser();
await tenantDb(orgId)
.update(invoices)
.set({ amount: input.amount })
.where(eq(invoices.id, input.id));
updateTag(invoiceTags.list(orgId));
updateTag(invoiceTags.record(orgId, input.id));
redirect(`/invoices/${input.id}`);
}

The redirect’s render runs in the same request, after the tags expired, so the list it renders reads fresh. Read-your-writes, delivered.

1 / 1

That’s the shape every read-your-writes action follows. The next case keeps the same call but stretches the intuition behind it.

Worked case: an admin changes a member’s role

Section titled “Worked case: an admin changes a member’s role”

An admin demotes a teammate from admin to member. The Server Action runs in the admin’s session, but the change affects two different people, and that’s what makes this case worth your attention.

Two cached reads go stale. The org’s membership read, tagged orgTags.all(orgId), is what the admin is looking at on the members page. The demoted member has their own cached read too: the “orgs I’m in” data keyed to their user, tagged userTags.all(memberUserId).

Here’s the wrinkle on axis one. The admin is watching, on the members list expecting the role to flip, so that’s read-your-writes for them. But the demoted member is not watching the admin’s redirect; they might be asleep. So is their read eventual? It doesn’t matter, and that’s the insight. We’re already in a Server Action firing updateTag for the admin’s read, so we fire updateTag for the member’s read too. updateTag expires the entry. The member isn’t watching now, but the next time they load any page, their cached read is gone and the new role is already in effect. You don’t need someone watching for updateTag to be correct; you need to be in an action.

So you have one action, two tags, each scoping a different person’s data. That’s the multi-recipient pattern: “who triggered it” and “whose data changed” are different questions, and the tags answer the second one.

app/settings/members/actions.ts
// ...inside the demoteMember action, after the role write commits...
updateTag(orgTags.all(orgId)); // the admin's members list, refreshed on redirect
updateTag(userTags.all(memberUserId)); // the member's own data, expired for their next load
redirect('/settings/members');

One more thing, so you’re not surprised later: expiring the member’s cached data is not the same as revoking their session. Their authorization, whether they can still act as an admin, resolves separately, through the session and its short staleness window covered in the organizations and RBAC chapters. The cache tag handles their cached reads; the session handles their permissions. Two systems, two mechanisms.

You’ve now seen both ends of the read-your-writes corner. Next, make the decision machine itself the thing you carry to every mutation site.

Everything so far built the vocabulary and the intuition. This is the tree you actually run. Walk it one question at a time. The order of the questions is what matters: notice that the first question is “where does the mutation run?”, because where you are answers so much in advance.

Which invalidation call?

Every worked case in this lesson is a path through that tree. The two you’ve already seen both land on updateTag. Walk them again in your head and confirm: Server Action, user watching, tag exists. The three cases coming up land on the other leaves, one branch at a time.

Worked case: a webhook updates an invoice’s status

Section titled “Worked case: a webhook updates an invoice’s status”

Stripe sends invoice.payment_succeeded. The webhook handler, which per the billing chapters is the single writer for this invoice’s payment state, verifies the signature, claims the event, and updates the invoice row. Then it invalidates.

Run the tree. First question: where are we? A route handler. That branch terminates immediately at revalidateTag, and we don’t even ask about a watcher, because a route handler can’t be read-your-writes for anyone. updateTag here would throw, exactly as the boundary section promised. So you fire revalidateTag(invoiceTags.record(orgId, id), 'max') for the detail read, and revalidateTag(invoiceTags.list(orgId), 'max') for the list. The fan-out applies here just as it did in the action: two affected reads, two calls.

Now the point that matters for understanding what 'max' actually does. revalidateTag(tag, 'max') does not eagerly refetch. It marks the tag stale and walks away. The refresh happens on the next visit to a page using that tag: the next viewer is served the stale value once, and their request triggers the recompute behind the scenes. So “eventual” is literal. The next reader pays exactly one stale render, and everyone after them gets fresh. When nobody is waiting on the screen, and for a webhook nobody is, that’s precisely the trade you want. You don’t spend a refetch on data no one is looking at yet.

app/api/stripe/route.ts
// ...signature verified, event claimed in processed_events, invoice row updated...
revalidateTag(invoiceTags.record(orgId, id), 'max');
revalidateTag(invoiceTags.list(orgId), 'max');
return new Response(null, { status: 200 });

Invalidation is one more line on the webhook checklist from the billing chapters: verify, claim, write, invalidate, acknowledge. Forget it and the user pays their invoice but the UI shows “unpaid” until something else happens to expire the read.

One narrow escape hatch is worth naming and then setting aside: a webhook can pass revalidateTag(tag, { expire: 0 }) to hard-expire a tag immediately from a route handler, for the rare case where even one stale render is unacceptable. But 'max' is the default you reach for, and if you genuinely need read-your-writes immediacy, that belongs in a Server Action with updateTag, not in a webhook straining to fake it.

Worked case: a nightly job rebuilds the org summary

Section titled “Worked case: a nightly job rebuilds the org summary”

A Trigger.dev job runs at 3am and recomputes each org’s analytics summary. It writes the summary row and needs to invalidate the dashboard’s cached read of it.

This is the webhook case with a different trigger, so the tree path is short: background job leads to revalidateTag. The job calls revalidateTag(orgTags.all(orgId), 'max'), or a dedicated summary tag if the project defines one, kept consistent with the helper. No user is waiting at 3am, so the first person to open the dashboard in the morning sees yesterday’s summary once, then it’s fresh on the next load. Eventual is exactly right.

The one new detail: the job imports revalidateTag from next/cache and calls it directly, even though it runs in a separate process from your web server. The framework routes the invalidation through the deployment’s shared cache backend, so a tag expired by a background process is expired for the web requests too. You don’t wire anything up for this. Just know it works, and move on; the backend semantics are beyond this lesson.

src/trigger/rebuild-org-summary.ts
// ...inside the nightly task's run(), after the summary row is written...
revalidateTag(orgTags.all(orgId), 'max');

That’s two of the three non-action leaves. The last case is the one where router.refresh() finally earns its place, and where the “it doesn’t refresh the cache” warning from the top of the lesson becomes concrete.

A user clicks “Upgrade to Pro,” goes through Stripe Checkout, and lands on a success page. The natural assumption is that the success page now shows “Pro.” It often doesn’t, at least not yet, and the reason is a race you need to design around rather than a bug to fix.

The plan flip itself happens in the webhook, not in any action, because the webhook is the single writer for billing state, per the billing chapters. When that webhook lands, it calls revalidateTag(orgTags.all(orgId), 'max') to expire the entitlement read. But the redirect to the success page and the webhook delivery are two independent events racing each other. By the time the user is looking at the success page, the webhook may not have arrived, so the cached entitlement still says “Free.”

So the success page is a client component that polls: it calls router.refresh() on an interval until the entitlement flips to “Pro,” then stops. Scrub through the sequence below to watch the race resolve.

Browser / success page
redirected from Checkout success page mounting
Stripe webhook
not arrived yet
Cached entitlement Free
Checkout completes. The browser is redirected to the success page; the webhook has not arrived yet. The cached entitlement read still says Free.
Browser / success page
renders "Activating…" router.refresh()
Stripe webhook
still in flight
Cached entitlement Free re-rendered, not invalidated
The success page mounts, renders "Activating…", and fires its first router.refresh(). The server render re-runs — but the cached read is still valid, so it still says Free. The refresh re-rendered; it did not invalidate.
Browser / success page
waiting on the poll
Stripe webhook
writes entitlement row revalidateTag(…, 'max')
Cached entitlement Stale was Free
The webhook lands. The handler writes the entitlement row and calls revalidateTag(orgTags.all(orgId), 'max'). Now the cached read is stale.
Browser / success page
poll stops router.refresh()
Stripe webhook
done
Cached entitlement Pro stale read recomputed
The next router.refresh() re-runs the server render. This time the stale read recomputes and the page shows Pro. The poll stops.

Now look closely at steps 2 and 4, because this is the whole point. In step 2, router.refresh() re-ran the render and the page still said Free. The refresh did not expire anything. It was the webhook’s revalidateTag in step 3 that made the read stale. Then in step 4, router.refresh() re-ran the render again and this time picked up the now-stale-then-fresh value. The two calls are complementary, not interchangeable: the webhook invalidates, and router.refresh() re-renders so the client gets a chance to observe the result. This is the exact context where people believe router.refresh() refreshes the cache, and it’s exactly the context where you can see that it doesn’t.

The poll is not papering over a bug. The redirect-versus-webhook race is inherent, since the two systems share no transaction, and polling is the honest way to bridge it. But notice the warning sign underneath: if you reach for router.refresh() outside this kind of redirect-race, the work almost certainly belongs in a Server Action where updateTag would be cleaner and instant. router.refresh() is the right tool here precisely because there’s no action to put the invalidation in.

You’ve now seen the calls. Two ordering bugs turn correct calls into broken behavior, and both are frequent code-review catches, so it’s worth fixing them in your mind before you write a single invalidation.

The canonical sequence inside a Server Action is the one the five seams already give you. First the write lands, which when several rows change together means the transaction commits. Then the invalidation calls fire, and then redirect. There are two ways to get it wrong.

Invalidating inside the transaction. When a mutation spans several rows it runs inside a db.transaction. If you call updateTag before that transaction commits and it then rolls back, you’ve expired the cache against a change that never happened. The next read repopulates with the old value, so your invalidation was wasted, or worse, for a moment the cache serves a view of state that doesn’t exist. Invalidation describes a committed fact, so it belongs after the commit.

Redirecting before invalidating, or trusting the redirect to “refresh” things on its own. The destination’s cached read still has a valid tag, so it serves the old value, and the user lands on stale data. The redirect is not an invalidation signal. Invalidate first, then redirect.

You met this exact principle as the after-commit rule for the notification dispatcher and the no-external-calls-in-a-transaction rule in the transactions chapter. It’s the same discipline, pointed at the cache: side effects that depend on a committed change run after the commit, never inside it.

Drag the steps below into the order a correct action runs them. One of them is a trap that wants to go too early.

Order the steps of a Server Action that edits an invoice and its line items, then sends the user to the detail page. Drag the items into the correct order, then press Check.

Update the invoice and its line items inside one db.transaction
The transaction commits
updateTag(invoiceTags.list(orgId))
updateTag(invoiceTags.record(orgId, id))
redirect to the detail page

Listing the cached reads a mutation touches

Section titled “Listing the cached reads a mutation touches”

The fan-out showed up in almost every case, so let’s make it a repeatable habit instead of something you remember sometimes. The discipline is one sentence: before writing the invalidation tail, list every cached read whose underlying data this mutation changes, then fire the narrowest tag for each.

Editing one line item on an invoice changes the invoice record read and the org’s invoice list read, so that’s two reads and two tags. Miss one and that read stays stale until its TTL expires. The catch is that because 'max' is the profile these reads carry, they effectively never expire on their own. “Silently stale” can mean “stale until someone happens to edit the same entity again.” The bug doesn’t announce itself; it quietly serves wrong data for a long time.

This is the write-side half of a split you saw in the previous lesson. Reads are generous: a cached read attaches the union of every tag that could apply to it. Writes are precise: a mutation fires the narrowest set of tags that covers what it actually changed. Both sides import the same lib/tags.ts helper, and that’s what makes the discipline safe. The write-site string is the same function call as the read-site string, so a typo can’t drift them apart. You’re never matching strings by eye; you’re matching function calls the compiler checks.

Try the exercise below. An admin removes a member from the org. Sort the tags into the ones this mutation must fire and the ones it must not touch; the decoys are reads this change doesn’t affect.

An admin removes a member from the org. Sort each cached read into the bucket for whether this one mutation must invalidate it. Drag each item into the bucket it belongs to, then press Check.

Must fire Reads whose underlying data this removal actually changed
Leave alone Decoys — reads this removal doesn't touch
orgTags.all(orgId)
userTags.all(removedUserId)
invoiceTags.list(orgId)
invoiceTags.record(orgId, someInvoiceId)
userTags.all(adminUserId)

The two on the left are the fan-out: the org’s membership read (orgTags.all(orgId)) and the removed member’s own “orgs I’m in” read (userTags.all(removedUserId)) both changed. The three decoys are the trap. The invoice reads have nothing to do with membership, and the admin’s own membership didn’t change, so firing userTags.all(adminUserId) would expire a read this mutation never touched.

One last thing to file away, so a later chapter doesn’t surprise you: when a list is also read on the client through TanStack Query, the action has to invalidate that client cache too, with queryClient.invalidateQueries(...). That’s a second cache with its own invalidation, and the TanStack chapter owns it. Everything in this lesson is about the Next.js server cache only.

The tree is only useful if you can run it on a case you’ve never seen. Here’s a short set of cases this lesson didn’t walk. For each, decide the call and the reasoning before you reveal the answer; the reasoning is what transfers, not the answer key.

One thread ties these together. In the first two, notice that the same kind of mutation, a profile change, resolves to different calls depending on the trigger. That contrast is the framing that lets you handle anything the course never explicitly covered. Name the trigger, name who’s watching, pick the corner.

A user opens their account settings, edits their own display name in a form backed by a Server Action, and the form re-renders the same settings page on success. Which call invalidates the name they’re staring at?

updateTag(userTags.all(userId));
revalidateTag(userTags.all(userId), 'max');
router.refresh();

Your external identity provider pushes a display-name change for one of your users to a webhook route handler, which updates the user row. Which call invalidates that user’s cached read?

updateTag(userTags.all(userId));
revalidateTag(userTags.all(userId), 'max');
router.refresh();

An embedded third-party maps widget fires an onSave callback — plain client code, not a Server Action — after the user repositions a pin. The route is fully dynamic with no cached read, and you just need its server render re-run. Which call?

updateTag(/* ... */);
revalidatePath('/map');
router.refresh();

A sitewide footer config row changes. The affected surface is every page under the (marketing) route group, and none of those pages carries a per-entity tag from lib/tags.ts. Which call?

revalidateTag('marketing', 'max');
revalidatePath('/(marketing)', 'layout');
router.refresh();

Close on one habit that turns “my change isn’t showing” from a mystery into a one-minute check. Every invalidation call should emit a structured log line, something like { event: 'invalidate', call: 'updateTag', tag, source }. That single line is the first place you look when a change doesn’t appear: did the invalidation for the expected tag actually fire?

The signal is in the pattern. A flatline on a tag whose entity is being actively edited means the wiring is wrong: the write is firing a different tag than the read carries, or firing none at all. A spike means a hot mutation path worth a second look. You don’t need a dashboard for this yet, since that surface comes later, in the observability chapters. For now, the log line is enough to make the invisible visible.

The two main calls each have a focused docs page, and both spell out the read-your-writes versus stale-while-revalidate distinction this lesson is built around.