Caching
See how Litestore caches reads with tags, clears them after every write, and which reads it never caches.
Litestore caches storefront and admin reads so pages load fast, and it clears the right cache entries whenever data changes. Read this page before you add a cached read or a new kind of write.
Each cached read gets Next.js tags from one fixed list. The CRUD layer then invalidates those tags on every write.
The fixed list is the whole point of the design. A cache tag is a string that a read and a write share without ever seeing each other. If the two strings don't match, nothing raises an error, and the page simply shows stale data. Litestore therefore keeps every tag as a constant, and the build fails on any other string.
This page covers that tag system. It also explains the five-minute rotation window that keeps the home feed cacheable, and the reads Litestore deliberately never caches.
Tag-based invalidation
CACHE_TAGS in lib/cache.ts defines every tag Litestore uses.
Some tags are static, such as these:
products
orders
settings
facetsOthers are functions that build a tag for one entity:
CACHE_TAGS.product(id)
CACHE_TAGS.productBySlug(slug)
CACHE_TAGS.variantStock(id)A cached read declares the tags it depends on, and a write invalidates the same constants. The read and the write can't fall out of step.
const resolveSoleChannelCached = unstable_cache(
async (defaultCurrency: string): Promise<ChannelInfo | null> => {
const channels = await db.channel.findMany({
where: { isActive: true, deletedAt: null },
select: channelCandidateSelect,
take: 2,
})
if (channels.length !== 1) return null
const only = channels[0]
return toChannelInfo({ ...only, currency: only.currency || defaultCurrency })
},
["sole-active-channel"],
{ revalidate: 300, tags: [CACHE_TAGS.channels] },
)Never invalidate with a raw tag string
scripts/check-cache-tags.ts fails the build when you pass a string or template
literal to revalidateTag, invalidateTag or invalidateTags. It also fails a
BaseCRUD subclass that declares protected cacheTag = "...". A mistyped tag,
such as "admin-refunds" where the read used "refunds", never throws. The page
just shows stale data with no error.
Writes invalidate through the CRUD layer
BaseCRUD.getRelatedCacheTags maps each entity to the tags a write to it affects.
The base class calls it on every mutation, so a mutation can't forget a page that depends on the data. Each module lists its tags once instead of repeating them at every place that writes.
Domain events invalidate through a map
invalidateCachesForDomainEvent in lib/cache.ts is a single static map from each event type to the caches it clears. functions/domain-events.ts runs it.
Some events don't affect any cached data:
cart.merged
checkout.started
social.*
wishlist.*These events invalidate nothing, and the map lists them explicitly with an empty invalidation. An event listed that way is a decision. An event missing from the map is an oversight. If both looked the same, you couldn't tell them apart.
Invalidation is best-effort on purpose
safeRevalidateTag catches and logs failures instead of throwing them to the caller.
By the time it runs, the data is already written. If a flush is missed, the cache is stale for a while. If invalidation threw during render, the visitor would get a 500 error. You can recover from the first, but not from the second.
Time-based ISR pages also need the path
Some storefront pages use time-based ISR instead of relying only on tags.
For that reason, invalidateProducts, invalidateCollections and invalidateCategories also call revalidatePath on the entity's URL. Without it, an edit would not show until the ISR window ran out.
Why every write invalidates
When a mutation writes data without invalidating the cache, the bug is silent, comes and goes, and is easy to miss in review. The admin table shows stale data until the cache expires, and by then nobody links the two.
scripts/check-cache-invalidation.ts scans every server-side file that runs a Prisma mutation directly. Each such file must import from ~/lib/cache, or import revalidateTag or revalidatePath from next/cache.
Files that are allowed to skip this rule are listed in an explicit ALLOWLIST in that script, each with its reason:
BaseCRUDsubclasses- Activity and analytics writes
- Seeds
- Tests
Because every entry states its reason, someone has to argue for an exemption. It can't slip in by accident.
The five-minute window
FEED_ROTATION_WINDOW_MS in server/web/feed/index.ts is five minutes.
currentFeedSeed() picks the rotation bucket from the clock instead of choosing a random seed for each request. It cycles through all FEED_SEED_BUCKETS orderings in about eighty minutes.
Everyone who loads the page within one window sees the same order. That keeps exactly one cache variant in use at a time.
A random seed per request was tried first. It split getFeedCached into sixteen variants:
per-request random seed
↓
16 cache variants
↓
most storefront views miss
↓
full uncached feed build, per requestThe feed still rotates. The clock decides when it changes, not the visitor.
The caller generates the seed and passes it through pagination. Page 2 therefore uses the same order as page 1, even if a new window starts in between.
What is never cached
Litestore never caches these reads:
- Anything in the admin that requires sign-in.
- The storefront cart.
server/web/cart/queries.tsreads the cart cookie, so the cart is rendered fresh for each request. - Stock at the moment it is reserved.
lib/inventory.tstakes aSELECT … FOR UPDATErow lock inside the transaction.
Stock is the interesting case, because it appears on both sides.
The stock count on a product page is cached under CACHE_TAGS.variantStock(id). The stock check when an order is placed is not cached.
This difference is deliberate. The number on the product page is a hint. The check at checkout is a decision.
Deciding how to cache a new read
If the value comes from stored data and a write can name the pages it affects, cache the read with a tag from CACHE_TAGS. This is the usual case, and the CRUD layer already handles it.
If the read is expensive and slightly stale data is acceptable, also add a revalidate window. resolveSoleChannelCached does this with 300 seconds.
If the page uses time-based ISR, also call revalidatePath. Otherwise the edit waits for the ISR window.
Don't cache the read if it naturally differs per request, like the cart cookie or anything behind admin sign-in. Also don't cache it if the code uses the value to commit to something. Being correct at the moment of a write is not something caching can solve.
If you add a new input that a cached read varies on, count the resulting variants first. Inputs that look cheap and change per request are how one busy cache entry turns into sixteen cold ones.
For any cached read, don't ask how stale it can be. Ask this:
Is this value a hint, or a decision?