Caching
Cache a new read with the right tags, and recognize the reads that shouldn't be cached at all.
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.
Cache tags
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 reviewers reject any other string.
The tag system is the main subject below. The five-minute rotation window that keeps the home feed cacheable and the reads Litestore deliberately never caches are covered after it.
Tag-based invalidation
CACHE_TAGS in platform/cache.ts defines every tag Litestore uses.
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
Always pass a constant from CACHE_TAGS to revalidateTag, invalidateTag or
invalidateTags, never a string or template literal. A mistyped tag,
such as "admin-refunds" where the read used "refunds", never throws. The page
just shows stale data with no error.
CRUD invalidation
Each BaseCRUD module declares its static cacheTags once, and the base class refreshes them, plus the entity tag <model>-<id>, on every write outside a transaction. When a write touches more storefront pages, the module's override calls the matching helper in platform/cache.ts (invalidateProducts, invalidateCollections, invalidateCategories, …) after super.
Event invalidation
invalidateCachesForDomainEvent in platform/cache.ts is a single switch from each event type to the caches it clears. jobs/domain-events.ts runs it.
Some events don't affect any cached data:
cart.merged
checkout.started
wishlist.*These events fall through to the default branch, which invalidates nothing, and a comment there names them so the choice is visible.
Best-effort invalidation
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. A stale cache recovers on its own, and a 500 error doesn't.
ISR paths
Some storefront pages use time-based ISR instead of relying only on tags.
For that reason, invalidateProducts, invalidateCollections and invalidateCategories also call revalidatePath (best-effort, through safeRevalidatePath) on the entity's URL. Without it, an edit would not show until the ISR window ran out.
Write invalidation
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.
So every server-side file that runs a Prisma mutation directly should invalidate the tags it affects, either through the cache helpers or with revalidateTag or revalidatePath from next/cache. BaseCRUD subclasses do this automatically. Activity and analytics writes, seeds and tests don't need to, because nothing caches what they write.
Cache lifetime
FEED_ROTATION_WINDOW_MS in modules/storefront/feed/get-feed.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.
We tried a random seed per request 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 determines when it changes, and visitors have no effect on it.
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.
Uncached data
| Read | Why Litestore doesn't cache it |
|---|---|
| Anything in the admin that requires sign-in | It differs for each signed-in user. |
| The storefront cart | modules/checkout/cart-queries.ts reads the cart cookie, so Litestore renders the cart fresh for each request. |
| Stock at the moment it is reserved | adjustInventoryInTransaction in modules/inventory/adjust-inventory.ts runs a guarded UPDATE … WHERE quantity >= n inside the transaction, so the loser of a race for the last unit matches no row and is rejected. |
Stock is the interesting case, because it appears on both sides.
Litestore caches the stock count on a product page under CACHE_TAGS.variantStock(id). It doesn't cache the stock check when an order is placed.
We made this difference on purpose. The number on the product page is a hint. The check at checkout is a decision.
Caching new reads
For any cached read, don't ask how stale it can be. Ask this:
Is this value a hint, or a decision?