Next.js gives you server rendering with a cache in front of it and then makes invalidation your problem. In Nakodo that problem is 46 lines long, and I had never looked at all 46 of them together until this week.
Here is the whole census.
The numbers
Ten files carry "use server", holding 62 exported actions. Six of those files call revalidatePath, 46 times in total:
$ grep -rl '"use server"' src | wc -l
10
$ grep -rc "revalidatePath(" src | grep -v ':0'
src/app/(app)/settings/actions.ts:3
src/app/(app)/support/actions.ts:6
src/app/(app)/campaigns/actions.ts:27
src/app/admin/users/actions.ts:4
src/app/admin/support/actions.ts:2
src/app/admin/mailing/actions.ts:4
$ grep -rno 'revalidatePath([^)]*)' src | grep -c '"layout"'
24
$ grep -rn "revalidateTag\|'use cache'" src | wc -l
0
So: 38 of the 62 actions invalidate something, 24 invalidate nothing at all, half of the calls pass "layout" as the second argument, and tags are not used anywhere in the codebase.
Why half of them say "layout"
Every one of the 24 "layout" calls names a campaign path, and the reason is where the campaign's header lives.
export default async function CampaignLayout({ children, params }: LayoutProps<"/campaigns/[id]">) {
const { id } = await params;
return (
<div className="space-y-6">
<div className="space-y-3">
<Suspense fallback={<HeaderSkeleton />}>
<CampaignHeader id={id} />
</Suspense>
<Suspense fallback={<CampaignTabs campaignId={id} kind={null} />}>
<Tabs id={id} />
</Suspense>
</div>
{children}
</div>
);
}
Only the URL is awaited in the layout itself; the header and the tab bar stream in separately, so switching tabs shows the new page's skeleton immediately instead of waiting for a header that has not changed. That is a performance decision, and it has a cache consequence: the header is not part of any page, it is part of the layout, and it holds the three things most likely to be out of date after an action.
The header renders the campaign's name and logo, its catalogue number, a live status dot, and a progress line that is either "All N keywords searched" or "M of N keywords searched". Pause a campaign and all of that changes. Approve a batch of first emails and the status word changes. Save a brief and the name can change.
revalidatePath("/campaigns/" + id) on its own invalidates the page segment and leaves the layout above it cached, so the body of the screen would update while the status beside the title still said the old thing. The second argument is not a precaution, it is the difference between a correct screen and a screen that contradicts itself.
The cost is real and I accept it: "layout" invalidates the whole subtree, so a reply to one conversation re-renders the results tab, the outreach tab and the brief tab the next time any of them is visited. Those are all dynamic, per-user pages whose data is one round trip to Postgres, so the thing I would be optimising is a few database queries nobody is waiting on. A finer-grained version of this would need tags, and tags would need a cache key per panel, and that is a lot of machinery to avoid re-running select count(*).
The 24 that invalidate nothing
Three different reasons, and all three are deliberate.
They redirect. deleteCampaignAction, changePlanAction, deleteAccountAction, sign-in, sign-up, password reset, the opt-out page. A redirect from a Server Action navigates, and every destination here is a dynamic page that renders per user on arrival, so there is nothing to invalidate. This is the one group where I would add a comment next time: the correctness depends on the destination being dynamic, and if we ever set staleTimes to keep dynamic segments in the client router cache, a deleted campaign could appear in the list it redirects to. We set no staleTimes today, so the default behaviour is on our side.
They return a suggestion, not a fact. suggestProposalAction, suggestCampaignDetailsAction, findLogoAction, checkedGuessesAction, previewUpgradeAction. These hand an answer back to an open form, which puts it in React state next to whatever the person has already typed. Invalidating the page would be worse than useless: it would re-render the server's version of a form whose client state is the one the user is editing. The write happens later, in the save action, and the save action does invalidate.
The UI already knows. acceptTermsAction is the clearest case. The terms notice records acceptance on mount and the dismiss control only hides it, so there is no server render anybody is waiting for. askSupportAction is the same shape: the answer is appended to a transcript the client holds.
The pattern across all three is that revalidatePath is for changes to what the server would say if it were asked again. If the browser is already holding the new truth, invalidating is a request to go and fetch the old screen.
The action that invalidates in both branches
try {
...
if (campaign.status === "active") await queueRescore(campaignId);
revalidatePath(`/campaigns/${campaignId}`, "layout");
return result;
} catch (e) {
revalidatePath(`/campaigns/${campaignId}`, "layout");
return { ...failure(e), briefSaved: true };
}
That is the save-and-launch action, and the duplicate is the point. The brief is saved before the launch is attempted, so a failed launch still changed the campaign. Invalidating only on success would leave the screen showing the old brief next to an error message about launching it, which is the single most confusing thing a form can do. The briefSaved: true in the failure result is the other half of the same thought: the error message has to be able to say "your brief is saved, the launch is not".
The admin notice sender does the same thing four times, once at each of its four exits, because every exit writes to the progress row the page is showing. One path per exit, one call per path, no attempt at a finally, because two of those exits return early and a finally would hide which ones.
Paths that are not the page you are on
Operator actions invalidate the record they edited:
revalidatePath(`/admin/users/${userId}`);
except the one that deletes the account, which invalidates the list instead:
revalidatePath("/admin/users");
Invalidating /admin/users/<id> after deleting <id> would be asking the cache to refresh a page that will now 404. Obvious once written down, easy to get wrong when you are copying the line above.
The support actions invalidate two paths each, a computed thread path and the list:
revalidatePath(supportPath(reference));
revalidatePath("/settings/support");
because a reply changes both the conversation and its row in the list, and the list shows the last message time and an unread marker.
What revalidatePath cannot do here
Nakodo's screens change while nobody is pressing anything. Creators turn up, replies arrive, emails go out on a schedule. No Server Action ran, so no invalidation can be the mechanism. That half of the problem is solved by polling a 16-character fingerprint of what the page shows and calling router.refresh() only when it changes, which is a separate route and the subject of its own post. There are exactly four router.refresh() calls in the app, and that poll is one of them.
Which leaves one honest gap in the census. Twenty-three calls name a single campaign's subtree (18 of them by campaign id, five by the campaign a conversation belongs to), and exactly one call in the whole codebase names the campaigns list:
revalidatePath("/campaigns", "layout");
It is in the action that splits the daily email allowance between campaigns, because that one visibly changes every row at once. But pausing a campaign also changes its row in that list, and that action invalidates only the campaign's own subtree. The list is dynamic, so a fresh navigation re-renders it; a back-navigation inside the same session is the case I want to go and watch in a browser before claiming it is fine.
Forty-six calls, no tags, no "use cache", and the only thing in this census I would change is a comment on the 24 actions that deliberately do nothing. Code that is correct because of a framework default is code that needs a sentence saying so.
Top comments (0)