This is a genuinely quiet failure mode, since calling revalidateTag never errors, never warns, and appears to complete successfully every single time, whether or not it actually invalidated anything at all.
The Setup That Looks Correct
// lib/queries/posts.ts
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
cache: 'no-store', // added later, maybe for a different, unrelated reason
});
return res.json();
}
// actions/posts.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
await db.posts.create({ /* ... */ });
revalidateTag('posts'); // runs without error, but does genuinely nothing here
}
The tag is there. The revalidation call is there. Nothing throws. And this specific combination doesn't actually do what it looks like it does.
Why This Specific Combination Is a No-Op
cache: 'no-store' tells Next.js to never cache this fetch's result at all, fetch it fresh, every single time, full stop. Tagging with next: { tags: [...] } only matters for a fetch whose result is actually being cached, since the tag is metadata attached to a cache entry, used to find and invalidate that entry later. If there's no cache entry in the first place, because no-store explicitly prevented one from ever being created, the tag has nothing to attach to, and revalidateTag('posts') has nothing to actually invalidate when it runs. It completes, technically successfully, having done nothing, since there was never anything cached for it to clear.
Why This Is Easy to End Up With Without Noticing
This combination rarely gets written deliberately in one sitting, it usually accumulates. A fetch starts out cached, tagged, and working correctly with revalidateTag. Later, someone adds cache: 'no-store' to that same fetch, for a completely unrelated reason, debugging a different staleness issue, following advice for a different part of the app, copying a pattern from elsewhere, without registering that it silently makes the existing tag, and everything relying on revalidateTag('posts') elsewhere in the codebase, functionally inert.
The Same Silent Gap at the Route Level
This isn't limited to the fetch call itself. A route-level override has the identical effect on every fetch within that route, regardless of what caching options those individual fetch calls specify:
// app/blog/page.tsx
export const dynamic = 'force-dynamic'; // forces every fetch on this route to be uncached
export default async function BlogPage() {
const posts = await getPosts(); // tags and cache options here are now irrelevant
return <PostList posts={posts} />;
}
export const dynamic = 'force-dynamic' overrides caching behavior for the entire route, meaning any tags option on any fetch within it becomes similarly inert, even if that specific fetch call looks completely correctly configured for tag-based revalidation on its own.
Why Nothing Warns You About This
Both cache: 'no-store' and next: { tags: [...] } are individually valid, correct options, and Next.js has no way to know that combining them on the same fetch reflects a genuine mismatch in intent rather than a deliberate choice, since there's nothing inherently wrong with either option on its own. The framework can't distinguish "I meant to cache this and tag it, but also told it never to cache" from "I have some other genuine reason for both of these together," so it simply does exactly what each option says, individually, with no cross-check between them.
How to Actually Catch This
Check whether revalidateTag calls are actually having an effect, not just whether they run without error. Trigger the mutation, then check whether the next read genuinely reflects the change:
// A quick manual check: create a post, then immediately check whether
// the posts list reflects it without a hard refresh or waiting on
// the fetch's own natural cache lifetime to expire
If the change doesn't show up until a hard refresh or the cache's own natural expiration, despite revalidateTag having run, that's this exact mismatch, worth checking the actual fetch options and any route-level dynamic export for a conflict between "never cache this" and "this is tagged for cache invalidation."
The Actual Rule
A revalidateTag call only does real work against a fetch that is actually being cached under that tag, not merely tagged. cache: 'no-store' on the same fetch, or dynamic = 'force-dynamic' at the route level, both silently remove the cache entry that tag was supposed to point at, and the revalidation call becomes a harmless, successful-looking no-op. When adding or changing caching options on an existing fetch, it's worth explicitly checking whether that fetch is relied on elsewhere for tag-based revalidation, since the two settings can drift out of sync without either one looking wrong in isolation.
I run into this specific mismatch most often on projects where caching gets tuned incrementally over time, exactly the kind of pattern I try to catch early in the SaaS dashboards and templates I build at pixelanas.com. If this kind of caching nuance is useful, I go deeper into related patterns on the blog.
If you've got a revalidateTag call somewhere in your codebase, worth actually verifying it's doing real work right now, not just that it runs without error. Check the fetch it's supposed to be targeting for a cache: 'no-store' or route-level force-dynamic quietly canceling it out. Drop what you find in the comments.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)